Skip to content

fix(responses): emulate stop_sequences on the Responses path - #607

Open
amondnet wants to merge 8 commits into
mainfrom
amondnet/fix-responses-emulate-stop_sequences-on-the-resp
Open

amondnet wants to merge 8 commits into
mainfrom
amondnet/fix-responses-emulate-stop_sequences-on-the-resp

Conversation

@amondnet

@amondnet amondnet commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Summary

The OpenAI Responses API has no stop parameter (Chat Completions does), so the Anthropic stop_sequences field was silently dropped for every Responses-protocol upstream, over HTTP and WebSocket alike. Claude Code's auto-mode permission classifier depends on it (</block>, </severity>) and retried its first stage on every check routed to a Responses model.

This change emulates stop_sequences gateway-side in the Responses → Anthropic SSE translation:

  • StopScanner holds back at most max_len - 1 bytes of assistant text (char-boundary safe) so a stop split across deltas is still caught; reasoning summaries and tool-call arguments are never scanned.
  • On a match the machine emits the text before it, closes the block, and ends the message with stop_reason: "stop_sequence" and the matched string; later upstream events are ignored and non-streaming output is truncated at the same point. With no configured stop sequences the output stays byte-identical.
  • Transports abort the upstream at the match: HTTP drops the byte stream, the WebSocket paths drop the event receiver (evicting the half-consumed pooled socket), and the non-streaming collectors return immediately.
  • A held-back prefix that never completes is flushed before the block closes, including when the first delta is entirely a prefix.

Known limitation: usage on a stop-sequence turn carries the local input estimate and output_tokens: 0, because the upstream's completion event never arrives.

Closes #605

Milestone / spec

M1 — docs/m1-responses-translation.md §8b (stop-sequence emulation).

Checklist

  • cargo build passes
  • cargo test passes (new behavior is covered; tests run without network/loopback where possible) — cargo test --all-features --workspace: 2726 passed, 0 failed, 2 ignored
  • cargo clippy --all-targets -- -D warnings clean (run as --all-targets --all-features)
  • cargo fmt --all --check clean
  • Source files stay under 500 lines — the new src/model/stop_sequences.rs is 211 lines, but several touched files (src/model/responses.rs, src/adapters/responses/pool.rs, src/adapters/responses/http.rs) were already over the limit before this change and are not split here
  • English only; matches surrounding style
  • Frozen spec in docs/ updated if this change deviates from it — docs/m1-responses-translation.md §8b
  • User-facing docs updated for behavior/config/endpoint/CLI/provider/model changes — site/src/content/docs/providers/openai.mdx plus the ko/ja/zh-cn copies, and site/src/content/docs/reference/configuration.md plus its locale copies. README.md has no matching section, and CHANGELOG.md is release-please generated, so both are intentionally untouched.
  • Any new GitHub Action is pinned to a full commit SHA — no workflow changes

Notes for reviewers

  • The scanner sits in the SSE translation path, so the streaming semantics matter most: check that the held-back prefix accounting is char-boundary safe and that the flush-on-close path cannot drop or duplicate text.
  • The upstream abort differs per transport (HTTP byte stream drop vs. WebSocket event-receiver drop that evicts the pooled socket). The pooled-socket eviction is the part most worth a close read.
  • Not verified yet: the issue's live check — a classifier routed to a Responses upstream no longer retrying stage 1. Everything here is covered by the test suite only.

Summary by cubic

Emulates Anthropic stop_sequences on the Responses path, which the Responses API cannot accept. Previously the field was silently dropped for every Responses upstream, breaking Claude Code's permission classifier; now the gateway-side translation truncates assistant text at the first stop and ends the turn with stop_reason: "stop_sequence" on all transports (HTTP/WebSocket, streaming/non-streaming). Closes #605.

Behavior

  • Text is scanned only in assistant deltas; a split stop across deltas is caught via a held-back prefix, and a prefix that never completes is flushed before the block closes — including on a backend error or response.failed event.
  • Streaming HTTP and both WebSocket paths abort the upstream at the match; non-streaming HTTP reads the body to EOF but truncates output at the same point. A normally completed turn drains to EOF so connections stay poolable, and a final unterminated SSE frame left by a cut upstream is still parsed. No configured stop sequences leaves output byte-identical.
  • Usage on a stopped turn reports the local input estimate and output_tokens: 0, because the upstream's completion event never arrives; the estimate wait is bounded to one second so a saturated tokenizer can't stall the response.
  • Docs in all four locales plus the READMEs describe the emulation and drop the now-false claim behind classifier_model that Responses drops stop_sequences.

Written for commit 93e1d9d. Summary will update on new commits.

Site build

site/ is not built on pull requests by CI, so the site build was run locally: npm run build completed cleanly, and the new "Stop sequences" section renders on providers/openai in all four locales (en, ko, ja, zh-cn).

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request implements gateway-side emulation of Anthropic stop_sequences for the OpenAI Responses API (addressing issue #605). Since the Responses API lacks a native stop parameter, the gateway now intercepts and emulates stop sequences during the Responses-to-Anthropic translation. This is achieved by introducing a StopScanner that buffers and scans assistant text, truncating the output and immediately aborting the upstream connection (for both HTTP and WebSocket transports, streaming and non-streaming) when a match is found. The PR also updates documentation across multiple languages and adds comprehensive unit and integration tests to verify the truncation and connection abort behaviors. No review comments were provided, and the implementation is clean, well-tested, and fully aligned with the style guide; therefore, I have no additional feedback to provide.

@amondnet
amondnet marked this pull request as ready for review September 18, 2026 13:59

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9da6278020

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/adapters/responses/mod.rs Outdated
@greptile-apps

greptile-apps Bot commented Sep 18, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

The PR appears safe to merge; the latest change resolves the remaining unbounded WebSocket estimate wait without introducing a new failure.

Summary

This PR emulates Anthropic stop_sequences for OpenAI Responses upstreams and applies the behavior consistently across HTTP and WebSocket transports.

  • Scans assistant text while excluding reasoning and tool-call arguments.
  • Handles stop sequences split across deltas without breaking UTF-8 boundaries.
  • Truncates streaming and non-streaming output and reports the matched stop sequence.
  • Aborts streaming upstream work when a stop matches and protects WebSocket pooling from half-consumed turns.
  • Updates tests, the translation specification, and localized user documentation.
  • The latest revision bounds the WebSocket token-estimation wait consistently with the HTTP and pooled paths.

Reviews (6) · Last reviewed commit: "fix(responses): bound the websocket inpu..."

Comment thread src/adapters/responses/ws_stream.rs
Comment thread src/adapters/responses/http.rs Outdated
The OpenAI Responses API has no `stop` parameter (Chat Completions does),
so the Anthropic `stop_sequences` field was silently dropped for every
Responses-protocol upstream, over HTTP and WebSocket alike. Claude Code's
auto-mode permission classifier depends on it (`</block>`, `</severity>`)
and retried its first stage on every check routed to a Responses model.

Emulate it gateway-side in the Responses -> Anthropic SSE translation:

- `StopScanner` holds back at most `max_len - 1` bytes of assistant text
  (char-boundary safe) so a stop split across deltas is still caught;
  reasoning summaries and tool-call arguments are never scanned.
- On a match the machine emits the text before it, closes the block, and
  ends the message with `stop_reason: "stop_sequence"` and the matched
  string; later upstream events are ignored and non-streaming output is
  truncated at the same point. No configured stop sequences leaves the
  output byte-identical.
- Transports abort the upstream at the match: HTTP drops the byte stream,
  the WebSocket paths drop the event receiver (evicting the half-consumed
  pooled socket), and the non-streaming collectors return immediately.
- A held-back prefix that never completes is flushed before the block
  closes, including when the first delta is entirely a prefix.

Usage on such a turn carries the local input estimate and
`output_tokens: 0`, because the upstream's completion event never arrives.

Docs: provider page (en/ko/ja/zh-cn), config reference `kind` row, and
docs/m1-responses-translation.md §8b.

Closes #605
Four fixes from an ensemble review of the emulation added in #605.

Abort the upstream only on an emulated stop. Both HTTP transports keyed
their byte-stream drop on `is_stopped()`, which `stop_events` sets on every
terminal — so an ordinary `response.completed` dropped the reqwest body
before EOF, costing the connection its place in the idle pool on the
overwhelming majority of turns, since `stop_sequences` is usually unset.
Now keyed on `hit_stop_sequence()`, the distinction `ws_stream.rs` already
made deliberately and that the translation doc already explained.

Release the stop-scanner holdback on a backend `error`/`response.failed`.
`close_any` was the only flush site, so up to `max_len - 1` bytes of text
the client was owed were dropped silently. Extracted `flush_stop_holdback`
and call it from both paths; the open block is still deliberately not
closed on that arm, as before.

Parse a final unterminated SSE frame in `json_response`. Switching from
`upstream.text()` + `parse_sse_events` to the incremental `SseParser` lost
the trailing frame a cut upstream leaves behind — possibly the last text
delta or the `response.completed` carrying usage, under a 200 OK.

Reattach `without_content_accumulation`'s doc comment, which the original
commit left documenting `with_stop_sequences`.
…stop_sequences

`classifier_model` (#608) documents itself as an in-provider remap because
"the classifier carries `stop_sequences`, which the Responses translation
drops". Emulating them on the Responses path makes that sentence false, and
it appears in nine places — the Rust doc on the config field, the
`classifier_model` row of the configuration reference, and the auto-mode
classifier section of the Anthropic provider page, each across en/ko/ja/zh-cn.

Keep the measurement that motivated the key — a classifier pointed at a Codex
upstream retried its first stage and cost 5-7 s per permission check — as the
historical observation it was, note that the emulation removes that specific
failure, and state the in-provider constraint as the validation rule it
actually is rather than deriving it from upstream behaviour that has changed.

The configuration-reference cells drop the rationale entirely: the table
states what the key accepts, and the provider page carries the reasoning.

Locale pages link their own translated `stop sequences` anchors
(`#정지-시퀀스`, `#ストップシーケンス`, `#停止序列`), since Astro derives heading
ids from rendered text and an English fragment would be a dead link.
@amondnet
amondnet force-pushed the amondnet/fix-responses-emulate-stop_sequences-on-the-resp branch from 3b8cb61 to 8e70e66 Compare September 18, 2026 15:22
…nses kind

`stop_sequences` support is a user-visible capability of `kind = "responses"`,
and AGENTS.md puts a capability change in the README in the same PR. The
translation section already said what that kind does; this adds why
`stop_sequences` survives it despite the Responses API having no `stop`
parameter.

English source plus the ko/ja/zh-CN translations, per the README translation
rule.
@greptile-apps

This comment has been minimized.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ebedaa3592

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 41 to 44
pub(super) fn machine(self) -> AnthropicSseMachine {
AnthropicSseMachine::new(self.model, self.thinking_enabled, self.tool_search_native)
.with_stop_sequences(self.stop_sequences)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Stop reading HTTP bodies after a matched stop sequence

When an HTTP Responses request uses stream: false and the upstream continues generating after a stop marker, this routes the stop-aware relay into json_response, which awaits upstream.text() before it ever applies the machine. It therefore cannot detect the match, return to the client, or drop the connection until the upstream eventually closes; a long-lived upstream makes the request hang and continues billing post-stop output. This also makes the new documentation's claim that non-streaming HTTP aborts at the match false.

AGENTS.md reference: AGENTS.md:L51-L53

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Docs corrected in 93bc562 — the claim was false and Greptile flagged the same sentence independently, so thank you both.

On the code, though: this is not a regression from the stop emulation. json_response reads the whole body with upstream.text() on origin/main today (src/adapters/responses/http.rs:191), for every non-streaming turn, stop sequence or not. The client asked for a single JSON body, which is exactly the case AGENTS.md carves out — "do not buffer upstream SSE responses unless the client requested non-streaming output". A stop sequence does not make that buffering worse; it just cannot make it better, because the machine sees no event until the body is complete.

Making this arm abort means turning the non-streaming relay into an incremental parse, on a path this PR does not otherwise touch, with its own failover and backend-error surface to re-review. That is a separate change. What this PR owes is an honest description, so the docs now say plainly that non-streaming HTTP truncates at the match like every other path but cannot cut the upstream short.

Comment thread src/model/responses.rs
@codecov

codecov Bot commented Sep 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.98582% with 17 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/adapters/responses/mod.rs 75.00% 7 Missing ⚠️
src/adapters/responses/sse_parse.rs 54.54% 5 Missing ⚠️
src/adapters/responses/http.rs 90.00% 3 Missing ⚠️
src/adapters/responses/websocket.rs 83.33% 1 Missing ⚠️
src/adapters/responses/ws_stream.rs 98.86% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will regress 2 benchmarks

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 2 improved benchmarks
❌ 2 regressed benchmarks
✅ 102 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
resolve_model[gpt-5-codex] 3.9 µs 4.9 µs -20.52%
parse_body_to_value[50] 756.7 µs 850.1 µs -10.98%
resolve_model[claude-sonnet-4-5-via-codex] 5.5 µs 4 µs +38.05%
parse_body_to_value[10] 186 µs 166.2 µs +11.92%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing amondnet/fix-responses-emulate-stop_sequences-on-the-resp (93e1d9d) with main (bd2cf38)

Open in CodSpeed

An emulated stop sequence makes every later event a no-op, including the
`response.completed` that carries the upstream's usage. On a streaming turn
that costs nothing — `forward` already seeds a local tiktoken estimate for
`message_start`, and `final_json`/`usage_value` fall back to it whenever no
usage was observed. A non-streaming turn emits no `message_start`, so the
gate skipped the estimate entirely and `json_response` never received one:
a stopped turn serialized `input_tokens: 0` for a non-empty prompt, where
the same request without `stop_sequences` would have reported real usage.

Widen `forward`'s gate to cover a non-streaming request that carries
`stop_sequences`, and thread the resolved estimate into `json_response` the
way `stream_response` already takes it. The pool path already had the value
resolved in `relay_success` and simply was not passing it on. No new
machinery: the fallback in `final_json` was already there.

The estimate stays behind the provider's `count_tokens = "tiktoken"`
opt-in, and is resolved with the same 1s `bounded_input_estimate` budget as
the streaming path — on this arm there is no commit to protect, so it is
resolved after the body read rather than overlapped with the round-trip.
… abort

The rebase onto main took `http.rs` wholesale, so `json_response` reads the
whole body with `upstream.text()` again instead of parsing incrementally.
Both doc surfaces still claimed the abort covers every transport:

- `docs/m1-responses-translation.md` said `http.rs` "breaks out of the body
  read (non-streaming)";
- the OpenAI provider page said the abort applies on "every transport (HTTP
  and WebSocket, streaming and non-streaming)".

Neither is true. Streaming HTTP aborts (`sse_parse.rs` drops the byte
stream rather than handing it to `spawn_terminal_drain`), and both
WebSocket paths abort (`ws_stream.rs`), but non-streaming HTTP reads the
body to EOF before the machine sees an event — it truncates at the match
like every other path, it just cannot cut the upstream short. That is the
pre-existing shape of the non-streaming relay, not something the emulation
introduced. Greptile and Codex each flagged the stale claim independently.

Also correct the usage caveat on both surfaces. It described only the
streaming shape (`message_delta.usage`) while the section claimed to cover
every transport; it now names the non-streaming shape too and states the
`count_tokens = "tiktoken"` opt-in the estimate depends on.

Updates the ko/ja/zh-cn copies of the provider page in the same change.
Comment thread src/adapters/responses/http.rs Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 93bc56233b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/adapters/responses/mod.rs
… collector

Two review findings on 476a200, both about the input estimate that a
stopped turn depends on.

Greptile: the non-streaming HTTP arm spawned the tiktoken encode only after
the upstream responded, then awaited it before reading the body, putting
tokenization on the critical path of every eligible request even when no
stop sequence matched — and letting a saturated blocking pool add up to the
full one-second bound. Now spawned before the send, like the pool loop's
own handle, so the encode overlaps connect/RTT; the bounded await after the
send normally resolves instantly.

That also corrects 476a200's commit message, which claimed the estimate
was "resolved after the body read". It was resolved before it. The claim
was wrong when written.

Codex: `forward`'s widened gate produces an estimate for a non-streaming
websocket turn too, but `forward_websocket` awaited and passed it only on
the streaming branch — `json_events_response` built an unseeded machine, so
a stopped websocket turn still reported `input_tokens: 0`. The estimate is
now resolved once before the branch and threaded into both arms.

An audit of every `.machine()` construction site found these were the only
two gaps: of the eight non-test sites, `json_events_response` was the sole
one missing `with_input_estimate`.

Covered by `json_events_response_reports_the_input_estimate_for_a_stopped_turn`,
verified non-vacuous — removing the seed fails it on the assertion, not on
a compile error.
Comment thread src/adapters/responses/websocket.rs
Greptile, on f57d60d: hoisting the estimate above the streaming branch gave
non-streaming websocket turns a wait they never had, and it was the bare
`handle.await` the streaming arm has always used rather than the one-second
`bounded_input_estimate` the HTTP and pooled paths use.

The websocket transport is where that matters most. Unlike the HTTP arm,
whose body stream has not been touched yet, the turn is already open by this
point: blocking here stops the collector consuming events and backpressures
the bounded `CodexWsEvents` channel until tokenization finishes. A large
request or a saturated blocking pool turns a best-effort progress figure into
a stall on the response itself.

Now bounded, which also tightens the pre-existing streaming branch — noting
that deliberately rather than burying it, since it is behavior this PR did
not otherwise own. It is strictly the safer direction, it makes all four
paths (HTTP streaming, HTTP non-streaming, pooled, websocket) share one
discipline, and the estimate was always best-effort: `bounded_input_estimate`
falls back to 0, the same value an encode failure already produced.
@sonarqubecloud

Copy link
Copy Markdown

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(responses): emulate stop_sequences on the Responses path instead of dropping them

1 participant