Skip to content

[Feat] sgl-router: Serve the OpenAI Responses API on /v1/responses - #42314

Open
whybeyoung wants to merge 7 commits into
mainfrom
feat/router-responses-api
Open

whybeyoung wants to merge 7 commits into
mainfrom
feat/router-responses-api

Conversation

@whybeyoung

@whybeyoung whybeyoung commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

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 adds POST /v1/responses to the router.

Modifications

A Responses request is converted to a chat request at ingress and served by the existing chat_completions handler, 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.

  • Request: string or item 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 on serde_json::Value, so sglang chat extensions (top_k, chat_template_kwargs, …) pass through.
  • Tools: function, plus the shapes Codex sends: namespace groups (flattened to <ns>__<name>) and custom freeform tools (a function with one input string, grammar appended to the description). Replies map back to namespaced function_call / custom_tool_call items, and history items of both kinds are accepted.
  • Streaming: the spec event sequence with a gapless sequence_number (response.created → in_progress → item / part / delta / done events → completed, incomplete or failed); usage, including cached_tokens, is always present. An engine abort ends the Response as failed.
  • Unsupported, rejected with 400: previous_response_id, conversation, background and stored prompts (the router is stateless; send the full conversation), and top_logprobs. reasoning.effort is passed through to the engine unchanged.
  • Errors: engine errors are re-wrapped into the {"error": {...}} envelope; an upstream reply that cannot be read or parsed is a 502 via ApiError::UpstreamStatus.
  • src/protocol/sse.rs is a small generic layer that rewrites a chat SSE body into another protocol's events (SseTransducer, transduce_body). It wraps the body produced by proxy/sse.rs, so timeouts, abort-on-disconnect and TTFT/ITL metrics are unchanged.

Built-in tools such as web_search are rejected with 400.

The companion PR for Anthropic /v1/messages (#42316) carries a byte-identical protocol/sse.rs; whichever lands second only needs a one-line rebase in protocol/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.rs drives requests end to end through a mock worker. cargo test (lib + proxy) and cargo clippy --all-targets -D warnings pass.

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 / custom tool 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.

whybeyoung and others added 2 commits October 3, 2026 09:29
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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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>) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 };

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: input is borrowed, so image parts (often large base64) are copied here and at L444. Taking input by value would avoid the copy.

whybeyoung and others added 3 commits October 6, 2026 10:06
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>
@whybeyoung

Copy link
Copy Markdown
Collaborator Author

/tag-and-rerun-ci

@github-actions github-actions Bot added the run-ci CI: run the baseline test suite on this PR label Oct 15, 2026
whybeyoung and others added 2 commits October 15, 2026 15:20
…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>
@github-actions

This comment has been minimized.

@whybeyoung

Copy link
Copy Markdown
Collaborator Author

/tag-and-rerun-ci

@whybeyoung whybeyoung changed the title [sgl-router] Serve the OpenAI Responses API on /v1/responses [Feat] sgl-router: Serve the OpenAI Responses API on /v1/responses Oct 28, 2026
@whybeyoung

Copy link
Copy Markdown
Collaborator Author

/tag-and-rerun-ci

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-ci CI: run the baseline test suite on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants