Repository navigation
fix(sse): price the default keepalive threshold below the client watchdog and stop leaking Anthropic finish reasons - #14247
Merged
diegosouzapw merged 2 commits intoSep 22, 2026
Conversation
…ropic finish reasons Two independent faults made combo streaming unusable from a strict OpenAI client (oh-my-pi/omp) that had previously worked. 1. The early-stream keepalive threshold equalled the tightest client first-byte watchdog. Every combo request reached the resolver as a bare name with no provider prefix, so it took the 2 000 ms default — and a combo handler essentially never resolves inside 2 s, because it probes candidate legs first. The default threshold therefore *was* the combo time-to-first-byte: the synthetic keepalive byte landed at 2 003 ms while omp aborted at 2 011–2 016 ms having received nothing, so the request died as a 499 with no diagnosable error. Price the default tier at half the observed watchdog (1 000 ms) and tighten the keepalive cadence from 2 500 ms to 1 500 ms so the inter-byte gap stays inside the same budget. The slow tier keeps its deliberately long value: for browser-session and anonymous-fallback providers, committing early only adds SSE framing to a request the caller already expects to wait on. 2. normalizeOpenAICompatibleFinishReason passed cross-vendor synonyms through raw, so an Anthropic-style `end_turn` returned by an OpenAI-format leg landed on the OpenAI wire. omp reads an unrecognized finish_reason as a provider fault and fails the whole turn with `Provider finish_reason: end_turn` — after the text had already streamed, discarding a completed assistant message. Map the Claude stop_reason vocabulary onto its exact OpenAI equivalent, keeping the deliberate raw passthrough for genuinely unknown values and for the abort reasons whose whole purpose is to not present as a clean `stop`. Also make the combo 403 actionable: the bare "Combo X is not allowed for this API key" reads like a routing bug, so callers retry the same doomed model or fall through a whole compaction cascade instead of adding the combo to the key.
Owner
|
Thanks @ishan-parihar — merging via the release merge-train. Validated in local merge-train (mt-train10c) on the devbox @ train tip 4d841aa1c740bbaa03868dc0a403c62099a99a42 with the 72 sibling PRs of this batch: typecheck:core, file-size, complexity, cognitive-complexity, changelog-integrity green; changed-area node:test 831/831 (0 failing) and vitest 480/482 — the two reds are |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two independent faults made combo streaming unusable from a strict OpenAI-compatible client —
oh-my-pi(omp), which had previously worked. Both are observable from the client side as the same symptom (a combo that looks like it "does not stream"), so they are fixed together.1. The default keepalive threshold raced the client's first-byte watchdog
resolveKeepaliveThreshold()returns the delay after whichwithEarlyStreamKeepalivestops waiting for the handler and commits to a 200text/event-streamresponse — whose first byte is the synthetic keepalive frame. That first byte is the client's time-to-first-byte, so for the default tier the threshold is a hard latency budget, not a tuning knob.Every combo reaches the resolver as a bare name with no provider prefix (
small-stack,paper-stack,free-stack), so a combo always takes the default tier — and a combo handler essentially never resolves inside 2 s, because it probes candidate legs first. The default threshold therefore was the combo time-to-first-byte, and it equalled the tightest watchdog observed in practice.Measured on this deployment (call-log artifacts,
api_key_name=hermes-agents):free-stack(combo)nvidia/z-ai/glm-5.3(direct)has_response_body: 0on those records — zero bytes reached the client. The keepalive byte landed at 2.003 s and the client aborted at 2.011–2.016 s, i.e. the two 2 s budgets raced and the abort won by a few milliseconds of scheduling jitter.Fix: price the default tier at half the observed watchdog (1 000 ms) and tighten the keepalive cadence from 2 500 ms to 1 500 ms so the inter-byte gap stays inside the same budget. Fast validation errors are unaffected (they resolve in single-digit milliseconds), and
stream: truecallers already expect SSE.The slow tier keeps its deliberately long 15 000 ms value on purpose: for browser-session and anonymous-fallback providers, committing early only adds SSE framing to a request the caller already expects to wait on. This PR does not change that policy — the invariant is pinned per tier in the test so a future re-tuning cannot silently reintroduce the race on the default tier.
2. An Anthropic
stop_reasonleaked onto the OpenAI wire and failed the whole turnnormalizeOpenAICompatibleFinishReason()maps the OpenAI vocabulary,max_tokens→length, and safety reasons→content_filter, then passes everything else through raw. That raw fallthrough is deliberate for genuinely unknown values and for the abort reasons (malformed_function_call, …) whose entire purpose is to not present as a cleanstop— but it also let cross-vendor synonyms through.omniroute/paper-stackresolves tozenmux-free/openai/gpt-5.6-luna, which returns Anthropic-style stop reasons. Reproduced end-to-end against the live gateway withomp -p --mode=json:{"type":"message_end","message":{ "role":"assistant","content":[{"type":"text","text":"PONG"}], "provider":"omniroute","model":"paper-stack", "stopReason":"error","errorMessage":"Provider finish_reason: end_turn"}}The text streamed completely and was then discarded — the client reads an unrecognized
finish_reasonas a provider fault and fails the turn. Raw SSE confirms the leak:claude-to-openai.ts's ownconvertStopReason()switch already maps these; this normalizer is the other door into an OpenAI-format response (responseSanitizer.tsstreaming + non-streaming,jsonToSse.ts) and was missing the same mapping.Fix: map the Claude
stop_reasonvocabulary onto its exact OpenAI equivalent (end_turn/stop_sequence/pause_turn→stop,tool_use→tool_calls,refusal→content_filter,model_context_window_exceeded→length), checked before the raw fallthrough.3. The combo 403 did not say what to do
Combo "small-stack" is not allowed for this API keyis a key-policy decision that reads like a routing bug, so callers retry the same doomed model — or fall through an entire compaction cascade (Auto-compaction failed → Auto-shake → …) on every attempt. The message now names the combo and the two real remedies.Related Issues
ompcombo-streaming failure (403 Combo "small-stack" is not allowed for this API key, thenProvider finish_reason: end_turn).Validation
npm run typecheck:corerelease/v3.8.51); focused checks rerun afterwardFocused runs on
release/v3.8.51:tests/unit/finishReason.test.ts+tests/unit/keepalive-threshold.test.ts+tests/unit/early-stream-keepalive.test.ts— 47 pass / 0 failcombo-streaming-*,combo-round-robin-streaming-lock-3811,combo-stream-readiness-fallback,apikeypolicy-*,combo-account-allowlist-3266,combo-pin-implicit-allowlist— 55 pass / 0 failnpm run typecheck:core— cleanLive end-to-end reproduction before/after (
omp -p, non-interactive):omniroute/small-stackPONGomniroute/paper-stackProvider finish_reason: end_turn(text discarded)PONGTests Added Or Updated
tests/unit/finishReason.test.ts— Claude→OpenAI stop-reason mapping, case-insensitivity, and a guard that abort reasons still pass through raw.tests/unit/keepalive-threshold.test.ts— default tier recalibrated; bare combo names asserted to take the default tier; a per-tier invariant test pinning the default tier strictly below the observed watchdog.Coverage Notes
open-sse/utils/finishReason.ts,open-sse/utils/keepaliveThreshold.tsandopen-sse/utils/earlyStreamKeepalive.tsare fully exercised by the two updated unit files above (pure resolvers plus the wrapper's existing behavioural tests).src/shared/utils/apiKeyPolicy.tschange is message-only on two existing rejection paths;apikeypolicy-*and the combo-allowlist suites cover both branches.Reviewer Notes
withEarlyStreamKeepalive's internaloptions.thresholdMs ?? 2_000fallback is left as-is: every route passesresolveKeepaliveThreshold(...), so the resolver remains the single source of truth.chatcmpl-keepalive) for clients that key off the first chunk.