Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions packages/sie_gateway/src/handlers/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7949,6 +7949,8 @@ fn chat_params_from_json(body: &serde_json::Value) -> ChatParamsResult {
/// * Must be a non-empty JSON array.
/// * Each entry must be ``{type: "function", function: {name: string,
/// description?: string, parameters?: object}}``.
/// * ``function.name`` must match the OpenAI pattern
/// ``^[a-zA-Z0-9_-]{1,64}$``.
/// * ``function.parameters`` (when present) is run through the shared
/// JSON-Schema safety walker so the same depth / size / unsupported-
/// keyword caps that apply to ``response_format.json_schema`` also
Expand Down Expand Up @@ -8033,6 +8035,17 @@ fn validate_chat_tools(v: &serde_json::Value) -> Result<Vec<serde_json::Value>,
oai_code::INVALID_REQUEST,
));
}
if name.len() > 64
|| !name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
{
return Err(bad(
format!("tools[{i}].function.name must match ^[a-zA-Z0-9_-]{{1,64}}$"),
&format!("tools[{i}].function.name"),
oai_code::INVALID_REQUEST,
));
}
if let Some(desc) = func.get("description") {
if !desc.is_string() && !desc.is_null() {
return Err(bad(
Expand Down Expand Up @@ -18227,6 +18240,54 @@ mod tests {
}
}

#[tokio::test]
async fn chat_tool_names_outside_the_openai_pattern_are_refused_before_routing() {
let chat = |name: &str| {
serde_json::json!({
"model": "org/g",
"messages": [{"role": "user", "content": "hello"}],
"max_tokens": 4,
"tools": [
{"type": "function", "function": {"name": "get_weather"}},
{"type": "function", "function": {"name": name}}
]
})
};
let too_long = "a".repeat(65);
let longest = "A".repeat(64);
for (name, valid) in [
("math.factorial", false),
("two words", false),
("caf\u{e9}", false),
("a/b", false),
(too_long.as_str(), false),
("get-weather_2", true),
(longest.as_str(), true),
] {
let (state, policy) = recording_generation_state().await;
let response = proxy_chat(
State(state),
json_request("/v1/chat/completions", chat(name)),
)
.await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{name}");
let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
.await
.expect("read body");
let error: serde_json::Value = serde_json::from_slice(&body).expect("body is JSON");
assert!(policy.take_calls().is_empty(), "{name} reached routing");
if valid {
// The fixture model declares no tool support, so a request
// that passed validation is refused by the capability gate.
assert_eq!(error["error"]["code"], "unsupported_field", "{name}");
assert_eq!(error["error"]["param"], "tools", "{name}");
} else {
assert_eq!(error["error"]["code"], "invalid_request", "{name}");
assert_eq!(error["error"]["param"], "tools[1].function.name", "{name}");
}
}
}

#[tokio::test]
async fn governed_generation_dispatch_targets_are_exact_and_order_independent() {
for reverse_insertion in [false, true] {
Expand Down
44 changes: 44 additions & 0 deletions packages/sie_server/REMOTE_BACKENDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,50 @@ returns the original local refusal with its retry delay. A client error is
never retried remotely, and fallback cannot replay work after local acceptance
or after output reaches the caller.

A generation request sends the upstream the caller's messages or prompt and
sampling parameters, together with defaults the model configures, such as
sampling defaults, stop sequences or chat template arguments. This holds for
chat, completions and native generation, streamed or buffered, and for chat
that the server renders through the model's template before it calls the
upstream's completions or native generation endpoint. An upstream that answers
a generation request with `400` or `422` and no known SIE error code refused
that input. An OpenAI-compatible API answers this way, for example, to a
prompt longer than its context. The caller receives `400` `invalid_request`,
as for an `INVALID_INPUT` refusal, with fixed text that repeats nothing the
upstream sent and does not name the upstream. An upstream that refuses a
configured default answers the same way, so the server logs every refused
generation request at `WARNING` with the upstream's name and status, where a
refused default shows as a refusal of every request. Encode, score and
extraction report the same upstream answer as a server error. A `400` or `422`
with a known SIE error code other than `INVALID_INPUT` or `INPUT_TOO_LONG`, and
other upstream `4xx` answers such as `401`, `403` or `404`, point at the
operator's credential, model name or route rather than at the request, so the
caller receives a server error. `429` and `5xx` answers are retryable as
described above.

Chat checks an upstream answer against the request's tools on a single node
and in a cluster, streamed and buffered. The answer fails as an upstream error
when it calls a function the request did not declare, calls any function under
`tool_choice: "none"`, calls a function other than the one `tool_choice` names,
makes several calls under `parallel_tool_calls: false`, or makes no call when
`tool_choice` is `"required"` or names a function. Before output, a single node
answers that failure with `502` `upstream_error` and a cluster with `500`
`inference_error`, as each answers any other upstream failure. A stream that
has already started ends with an error event. A streamed function name is
checked as OpenAI clients assemble it, by concatenating the fragments of each
call index. A fragment is delivered while the name so far begins a permitted
name, so a stream can deliver the start of a name before the call fails. An
upstream that repeats the whole name on every delta fails, because the
concatenated name is not one the request declared.

Tool function names must match `^[a-zA-Z0-9_-]{1,64}$`, the pattern
OpenAI-compatible APIs accept, so a local and a remote profile accept the same
requests. The gateway and the single-node chat route refuse any other name
with `400` before dispatch and name the field, for example
`tools[1].function.name`. The error code is each route's code for an invalid
chat request: `invalid_request` from the gateway and `INVALID_INPUT` from a
single node.

TLS is required outside loopback. URLs containing credentials, a query or a
fragment are rejected. Redirects are refused, ambient proxy variables are
ignored, and inbound authorization is not forwarded. An explicit `proxy_url`
Expand Down
38 changes: 29 additions & 9 deletions packages/sie_server/src/sie_server/adapters/remote/_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,18 @@
connection, a timeout, a dropped response, another 5xx). A 501, a 505 and the
codes ``MODEL_LOAD_FAILED``, ``ACCOUNT_STATE_UNAVAILABLE`` and
``COLD_START_RATE_LIMITED`` are final. A refused input raises
:class:`InputTooLongError` or :class:`InvalidInputError`. Everything else is a
:class:`InputTooLongError` or :class:`InvalidInputError`. A generation call,
streamed or buffered, carries the caller's input and sampling, so there a 400
or 422 without a known code is a refused input too, and is logged because a
configured default the upstream refuses looks the same. Everything else is a
:class:`RemoteUpstreamError`.
"""

from __future__ import annotations

import asyncio
import json
import logging
import math
import re
import time
Expand All @@ -46,6 +50,8 @@
from sie_server.core.upstream_deadline import DEADLINE_EXTENSION
from sie_server.types.inputs import InvalidInputError

logger = logging.getLogger(__name__)

DEFAULT_RETRY_AFTER_S = 5
_ERROR_BODY_MAX_BYTES = 64 << 10
_NOT_READY_CODES = frozenset({"MODEL_LOADING", "PROVISIONING", "LORA_LOADING"})
Expand Down Expand Up @@ -126,9 +132,16 @@ def _send(client: httpx.Client, request: httpx.Request, *, upstream: str, max_by
response.close()


def failure_for_status(status: int, code: str | None, retry_after_s: int | None, *, upstream: str) -> Exception:
"""The failure an upstream error status amounts to, given its known error code and wait."""
answered = f"answered {status} {code}" if code else f"answered {status}"
def failure_for_status(
status: int, code: str | None, retry_after_s: int | None, *, upstream: str, caller_request: bool = False
) -> Exception:
"""The failure an upstream error status amounts to, given its known error code and wait.

``caller_request`` says the call forwarded the caller's own request, so a
400 or 422 without a known code refused that request.
"""
reply = f"{status} {code}" if code else str(status)
answered = f"answered {reply}"
wait = retry_after_s if retry_after_s is not None else DEFAULT_RETRY_AFTER_S
if code in _TERMINAL_CODES or status in _TERMINAL_SERVER_STATUSES:
return RemoteUpstreamError(f"upstream {answered}")
Expand All @@ -142,8 +155,8 @@ def failure_for_status(status: int, code: str | None, retry_after_s: int | None,
return UpstreamUnavailableError(upstream, "unavailable", retry_after_s=wait, reason=answered)
if code == "INPUT_TOO_LONG":
return InputTooLongError(f"the upstream refused the input as too long ({status} INPUT_TOO_LONG)")
if code == "INVALID_INPUT" and status in {400, 422}:
return InvalidInputError(f"the upstream refused the input ({status} INVALID_INPUT)")
if status in {400, 422} and (code == "INVALID_INPUT" or (caller_request and code is None)):
return InvalidInputError(f"the upstream refused the input ({reply})")
return RemoteUpstreamError(f"upstream {answered}")


Expand Down Expand Up @@ -173,8 +186,11 @@ async def open_stream(
The call goes through the upstream's limiter, whose slot is held until the
block ends. The answer is a success with an uncompressed body, which the
block reads. Failures raise as :func:`send_bounded` describes, including a
connection lost while the block reads. Leaving the block closes the answer,
which cancels work still running upstream.
connection lost while the block reads. The request carries the caller's
input, so a 400 or 422 without a known code also raises
:class:`InvalidInputError`. A refused input is logged with the status.
Leaving the block closes the answer, which cancels work still running
upstream.
"""
upstream_for_serving(upstream)
with upstream_limiter(upstream).call():
Expand All @@ -197,14 +213,18 @@ async def open_stream(
raise failure_for_transport_error(exc, upstream=upstream) from None
try:
if response.status_code >= 400:
raise failure_for_status(
failure = failure_for_status(
response.status_code,
upstream_error_code(
response.headers, await _read_error_body_async(response, timeout_s=error_body_timeout_s)
),
parse_retry_after(response.headers.get("retry-after")),
upstream=upstream,
caller_request=True,
)
if isinstance(failure, InputTooLongError | InvalidInputError):
logger.warning("upstream %r refused a generation request: %s", upstream, failure)
raise failure
if _is_compressed(response):
raise RemoteUpstreamError("upstream sent a compressed body, which is refused")
yield response
Expand Down
23 changes: 22 additions & 1 deletion packages/sie_server/src/sie_server/api/openai_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import logging
import math
import os
import re
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Annotated, Any, cast
Expand Down Expand Up @@ -90,6 +91,7 @@
sniff_video_container,
)
from sie_server.observability.tracing import tracer
from sie_server.processors.remote_chat import _check_tools
from sie_server.processors.remote_chat_prompt import collect_rendered_chat, prepare_rendered_chat
from sie_server.processors.schema_prompt import (
json_schema_response_format,
Expand Down Expand Up @@ -127,6 +129,7 @@
# fence for malformed/non-token responses.
_MAX_CHAT_RESPONSE_BYTES = int(os.environ.get("SIE_CHAT_MAX_RESPONSE_BYTES", str(32 * 1024 * 1024)))
_MAX_CHAT_TOOLS = 64
_TOOL_NAME = re.compile(r"[a-zA-Z0-9_-]{1,64}")
_MAX_CHAT_MESSAGES = 4096
_MAX_CHAT_CHOICES = 128
_MAX_CHAT_VIDEOS = 1
Expand Down Expand Up @@ -593,13 +596,18 @@ def _validate_chat_tools(body: dict[str, Any]) -> None:
if tools is not None:
if not isinstance(tools, list) or len(tools) > _MAX_CHAT_TOOLS:
raise _bad_request("'tools' must be an array of at most 64 functions", param="tools")
for tool in tools:
for index, tool in enumerate(cast("list[Any]", tools)):
function = tool.get("function") if isinstance(tool, dict) else None
if not isinstance(function, dict) or tool.get("type") != "function":
raise _bad_request("'tools' entries must declare a function object", param="tools")
name = function.get("name")
if not isinstance(name, str) or not name:
raise _bad_request("tool function names must be non-empty strings", param="tools")
if not _TOOL_NAME.fullmatch(name):
raise _bad_request(
f"'tools[{index}].function.name' must match ^[a-zA-Z0-9_-]{{1,64}}$",
param=f"tools[{index}].function.name",
)
names.add(name)
if function.get("description") is not None and not isinstance(function["description"], str):
raise _bad_request("tool descriptions must be strings", param="tools")
Expand Down Expand Up @@ -1266,6 +1274,16 @@ def _check_remote_chat_usage(payload: dict[str, Any], body: dict[str, Any], conf
raise RemoteUpstreamError("upstream chat usage exceeded the configured token bounds")


def _check_remote_chat_tools(payload: dict[str, Any], body: dict[str, Any], calls: dict[int, dict[int, str]]) -> None:
for choice in payload["choices"]:
message = choice["delta"] if "delta" in choice else choice["message"]
names = calls.setdefault(choice["index"], {})
for position, tool in enumerate(message.get("tool_calls") or []):
index = tool.get("index", position)
names[index] = names.get(index, "") + tool.get("function", {}).get("name", "")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
_check_tools(names, body, finished=choice["finish_reason"] is not None)


async def _remote_chat_events(
iterator: AsyncIterator[dict[str, Any]],
*,
Expand All @@ -1279,12 +1297,14 @@ async def _remote_chat_events(
states: dict[int, ThinkingBlockStripper] = {}
strict_contents: dict[int, list[str]] = {}
strict_tool_choices: set[int] = set()
calls: dict[int, dict[int, str]] = {}
delivered = False
received = 0
include_usage = bool((body.get("stream_options") or {}).get("include_usage"))
try:
async for payload in iterator:
_check_remote_chat_usage(payload, body, config)
_check_remote_chat_tools(payload, body, calls)
sanitized = _sanitize_chat_payload(
payload,
requested_model=requested_model,
Expand Down Expand Up @@ -1387,6 +1407,7 @@ async def _remote_chat_response(
)
)
_check_remote_chat_usage(payload, body, config)
_check_remote_chat_tools(payload, body, {})
payload = _sanitize_chat_payload(
payload,
requested_model=requested_model,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def _logprobs(choice: dict[str, Any], body: dict[str, Any]) -> tuple[dict[str, A

def _check_tools(calls: dict[int, str], body: dict[str, Any], *, finished: bool) -> None:
mode, name = normalize_tool_choice(body.get("tool_choice"))
allowed = {tool["function"]["name"] for tool in body.get("tools", [])}
allowed = {tool["function"]["name"] for tool in body.get("tools") or []}
if calls and (mode == "none" or not allowed or (not body.get("parallel_tool_calls", True) and len(calls) > 1)):
raise RemoteUpstreamError("upstream chat did not honor the requested tools")
permitted = {name} if name is not None and name in allowed else allowed if name is None else set()
Expand Down
Loading
Loading