Repository navigation
[Feat] sgl-router: Serve the OpenAI Responses API on /v1/responses - #42314
whybeyoung wants to merge 7 commits into
Conversation
A Responses request is converted to a chat request at ingress and served by
`chat_completions`, so it is routed exactly like chat: tokenization,
cache-aware routing, admission, retry and PD all apply unchanged. The reply
is converted back on the outer body.
- Conversion works on `serde_json::Value`, so sglang chat extensions
(`top_k`, `chat_template_kwargs`, ...) pass through.
- Input items: messages (`developer` becomes `system`), reasoning,
function calls and outputs; text and image parts only.
- Tools: `function`, plus Codex's `namespace` groups (flattened) and
`custom` freeform tools (a function with one `input` string). Replies map
back to namespaced `function_call` / `custom_tool_call` items.
- Streaming emits the spec event sequence with a gapless
`sequence_number`, ending in `response.completed`, `.incomplete` or
`.failed`; usage is always present.
- Stateless: `previous_response_id`, `conversation`, `background` and
stored prompts are rejected with 400. Engine errors are re-wrapped into
`{"error": {...}}`.
The SSE conversion layer (`protocol::SseTransducer`, `transduce_body`) is
generic so other protocols can reuse it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- An engine abort (`finish_reason: "abort"`) ends the Response as `failed`, not `completed`. - A successful upstream reply that cannot be read or parsed answers 502 via `ApiError::UpstreamStatus`, not 200 with an error body. - `reasoning.effort` is passed through: the engine accepts every tier. - Reject `top_logprobs` (not translated) and tools whose chat names collide. - Move the SSE layer to `protocol/sse.rs`; share the status → OpenAI error type mapping with `ApiError`; narrow visibility. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| args, | ||
| .. | ||
| } => { | ||
| let item = call_item(&id, &call_id, &name, &args, "completed", &self.echo.tools); |
There was a problem hiding this comment.
[P2] Tool calls are always marked completed, even when the output was cut off (length) or aborted, so a truncated call looks complete. Use finish.item_status() here and in response.rs:66?
| fn tool_delta(&mut self, call: &Value, out: &mut Vec<u8>) { | ||
| let tool_index = call.get("index").and_then(Value::as_u64).unwrap_or(0); | ||
| let same = | ||
| matches!(&self.open, Some(Open::Function { tool_index: t, .. }) if *t == tool_index); |
There was a problem hiding this comment.
[P2] Only one call is tracked, matched by index, which breaks two ways:
- Interleaved indices (0, 1, 0, 1) become four calls, the later two with made-up IDs, empty names and partial arguments.
- SGLang's Gemma 4 parser reuses one index for repeated calls to the same tool, so two separate calls merge into one.
Track calls by id, and attach chunks without an id to the call with that index?
| Ok(json!({"type": "function", "function": {"name": name}})) | ||
| } | ||
| // Keep the mode, drop the allow-list. | ||
| Some("allowed_tools") => Ok(o |
There was a problem hiding this comment.
[P2] Dropping the allow-list lets the model call tools the client excluded. Filter tools to the list, or reject?
| self.output.push(item); | ||
| } | ||
|
|
||
| fn emit_failed(&mut self, message: &str, out: &mut Vec<u8>) { |
There was a problem hiding this comment.
[P2] The open item is dropped on failure: after streaming "partial answer", response.failed has output: [] and the item never gets output_item.done. Close it as incomplete first?
| fn merge_leading_system(messages: &mut Vec<Value>) { | ||
| let n = messages | ||
| .iter() | ||
| .take_while(|m| m["role"] == "system" && m["content"].is_string()) |
There was a problem hiding this comment.
Only string system messages are merged. instructions plus a developer item with two input_text parts gives two leading system messages, which Qwen3.5 rejects. Flatten text-only parts before merging?
| "user" | "system" | "developer" => { | ||
| pending.flush(messages); | ||
| // Chat templates know `system`; some drop `developer` messages. | ||
| let role = if role == "developer" { "system" } else { role }; |
There was a problem hiding this comment.
A developer item after the first turn becomes a system message mid-conversation, which some templates reject (e.g. Qwen3.5). Moving it to the top would reorder the history, so maybe send it as user instead?
| } | ||
| let text = content_text(content, i)?; | ||
| match &mut pending.content { | ||
| Some(c) => c.push_str(&text), |
There was a problem hiding this comment.
nit: adjacent assistant messages are joined with no separator ("A." + "B." → "A.B."). Same for reasoning below.
| } | ||
| json!({"type": "image_url", "image_url": image_url}) | ||
| } | ||
| "image_url" => part.clone(), |
There was a problem hiding this comment.
nit: input is borrowed, so image parts (often large base64) are copied here and at L444. Taking input by value would avoid the copy.
Tool-call items were keyed by the chunk's `index`, so a reused index (Gemma 4 repeats it per tool name) merged two calls into one item, and text arriving mid-call closed the item and split the call in two. A chunk with a function name now starts a new item; argument-only chunks extend the open one. Reasoning or text arriving while a call is open is held and emitted after it (whitespace alone is dropped). A string `error` in the stream keeps its message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main now carries the Messages API (#42316) with the shared SSE layer. Conflicts: protocol/mod.rs declares both protocols; protocol/sse.rs takes main's version (`data_payload` returns `Data`), and the Responses stream reads `Data::Json` only for now; app.rs and tests/proxy/main.rs keep both sides. WorkerSpec gained `services`; the Responses test fills the rest with `..Default::default()`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Stream: several function calls stay open at once. A chunk with a name starts a new call; others attach by `id`, else to the latest call with their `index`, so interleaved indices (0, 1, 0, 1) stay apart and a reused index (Gemma 4) or index-derived id (Kimi) starts a new call. - A call cut off by `length` or an abort is `incomplete`, streamed and buffered; only the last call can be cut off. - On failure, open items are closed as `incomplete` and kept in `response.failed`. `[DONE]` completes the response; a non-JSON line fails it. - `allowed_tools` restricts `tools` to its list; an unknown name is 400. - Leading system / developer messages with text-only parts are merged. - A `developer` item after the first turn is sent as `user`, since some templates reject a mid-conversation system message. - Adjacent assistant and reasoning items are joined with newlines. - `input` is taken by value, so image parts are moved, not copied. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
/tag-and-rerun-ci |
…l helpers - A turn whose calls have no outputs yet stays open: the stream emits text or reasoning that arrived during a call after the call, and sent back as input it no longer splits into two assistant turns. - Error paths keep the upstream reply's parts (access-log context) with a specific 502; every non-2xx body gets the OpenAI envelope, and 5xx or in-stream engine text is scrubbed as on /v1/messages. - Shared by both protocols: `new_id`, `scrub_message`, `openai_error_type` and `upstream_error` in `protocol`, and the reply helpers (`is_sse`, `rebuild`, `MAX_REPLY_BYTES`) in `routes::reply`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main moved the router to the top-level sgl-router/ (#45414); the Responses files move with it (protocol/responses/*, routes/responses.rs, routes/reply.rs, tests/proxy/responses_api.rs). Conflicts: server/error.rs takes main's restructured IntoResponse (client_message, gRPC status) and keeps only the shared `protocol::openai_error_type` for the type mapping. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
|
/tag-and-rerun-ci |
|
/tag-and-rerun-ci |
Motivation
Clients built on the OpenAI Responses API (the openai SDK's
responses.*, Codex) cannot use sgl-router today: it only serves chat completions. This addsPOST /v1/responsesto the router.Modifications
A Responses request is converted to a chat request at ingress and served by the existing
chat_completionshandler, so it is routed exactly like chat (tokenization, cache-aware routing, admission, retry, PD). The reply is converted back on the outer body, streaming or not.input; messages (developer→system), reasoning, function calls and outputs; text and image parts;instructions,max_output_tokens,reasoning.effort,text.format(JSON schema),tool_choice. Conversion works onserde_json::Value, so sglang chat extensions (top_k,chat_template_kwargs, …) pass through.function, plus the shapes Codex sends:namespacegroups (flattened to<ns>__<name>) andcustomfreeform tools (a function with oneinputstring, grammar appended to the description). Replies map back to namespacedfunction_call/custom_tool_callitems, and history items of both kinds are accepted.sequence_number(response.created→in_progress→ item / part / delta / done events →completed,incompleteorfailed); usage, includingcached_tokens, is always present. An engine abort ends the Response asfailed.previous_response_id,conversation,backgroundand stored prompts (the router is stateless; send the full conversation), andtop_logprobs.reasoning.effortis passed through to the engine unchanged.{"error": {...}}envelope; an upstream reply that cannot be read or parsed is a 502 viaApiError::UpstreamStatus.src/protocol/sse.rsis a small generic layer that rewrites a chat SSE body into another protocol's events (SseTransducer,transduce_body). It wraps the body produced byproxy/sse.rs, so timeouts, abort-on-disconnect and TTFT/ITL metrics are unchanged.Built-in tools such as
web_searchare rejected with 400.The companion PR for Anthropic
/v1/messages(#42316) carries a byte-identicalprotocol/sse.rs; whichever lands second only needs a one-line rebase inprotocol/mod.rs.Accuracy Tests
N/A: no model or kernel change.
Unit tests cover conversion, buffered and streamed replies, and the Codex tool shapes;
tests/proxy/responses_api.rsdrives requests end to end through a mock worker.cargo test(lib + proxy) andcargo clippy --all-targets -D warningspass.Also validated against a live deployment: the official openai Python SDK's types (strict validation of every Response object and stream event), and Codex-style
namespace/customtool calls, streamed and buffered, including history replay.Speed Tests and Profiling
N/A: no change to the chat path.
Checklist
🤖 Generated with Claude Code
CI States
Latest PR Test (Base): ✅ Run #40508465628
Latest PR Test (Extra): ❌ Run #40508465466
Latest PR Test (AMD ROCm 10): ➖ No AMD PR run found for this commit.