Skip to content

fix(combo): failover when upstream SSE is truncated mid-lifecycle - #7545

Merged
diegosouzapw merged 4 commits into
diegosouzapw:release/v3.8.49from
Chewji9875:fix/combo-fallback-502
Jul 19, 2026
Merged

diegosouzapw merged 4 commits into
diegosouzapw:release/v3.8.49from
Chewji9875:fix/combo-fallback-502

Conversation

@Chewji9875

Copy link
Copy Markdown
Contributor

Summary

Fix combo silent-stop fallback: when a combo target's upstream SSE stream
ends mid-lifecycle without a recognised terminator (data: [DONE],
message_stop, finish_reason, or terminal usage), the combo now
fails over to the next target instead of returning a half-finished
stream that hangs the downstream SSE parser.

Before this fix the streaming quality validator in
open-sse/services/combo/validateQuality.ts had a !sawAnyBytes gate
that any byte — even unparseable garbage or a partial data: payload —
satisfied. The validator returned valid: true, the combo passed the
truncated stream through, and OpenCode (or any OpenAI-compatible SSE
client) hung waiting for events that never came.

The gate is now tightened to require the stream to have either seen a
recognised terminator (sawTerminator) or any parseable SSE framing
(sawStructuredSSE). Only when neither is true does the stream get
classified as invalid for combo failover. The #3399/#3685 pass-through
contract for ping/metadata/[DONE]-only streams is preserved.

Related Issues

Validation

  • node --import tsx/esm --test tests/unit/validate-response-quality.test.ts — 12/12 pass
  • node --import tsx/esm --test tests/unit/combo-quality-validator-reasoning.test.ts — pass
  • node --import tsx/esm --test tests/unit/masked-200-exhaustion-fallback-6427.test.ts — pass
  • node --import tsx/esm --test tests/unit/combo-streaming-empty-content-failover.test.ts tests/unit/combo-empty-content-failover-5085.test.ts tests/unit/combo-response-validation-failover.test.ts tests/unit/combo-response-validation.test.ts — 20/20 pass
  • prettier --write on touched files (clean)
  • eslint on touched files (clean)
  • npm run lint (full repo)
  • npm run test:unit (full repo)
  • npm run test:coverage
  • Coverage is still >= 60% for statements, lines, functions, and branches
  • SonarQube PR analysis is green or any remaining issues are explicitly documented below

Tests Added Or Updated

Coverage Notes

The touched files (open-sse/services/combo/validateQuality.ts and
tests/unit/validate-response-quality.test.ts) are covered by the new
test cases and the existing combo-streaming-empty-content-failover
suite. The new flags (sawTerminator, sawStructuredSSE) and the
tightened done-branch guard are exercised by all four new tests; the
existing #3685 tests continue to confirm the pass-through contract for
incomplete Claude lifecycles.

Reviewer Notes

  • Blast radius: change is scoped to the streaming branch of
    validateResponseQuality (lines ~106–327). Non-streaming path is
    untouched. All three combo call sites
    (combo.ts::handleComboChat, combo.ts::handleRoundRobinCombo,
    runtimeUnits.ts) share the validator, so the fix propagates
    automatically.
  • Public contracts preserved: the #3399 (ping/metadata
    pass-through) and #3685 (incomplete Claude lifecycle
    pass-through) contracts are explicitly preserved via sawStructuredSSE
    and sawTerminator; see the comment block at the flag declaration
    in validateQuality.ts.
  • New tracking in parseAccumulatedSse:
    • data: [DONE] is now marked as a terminator (previously skipped)
    • OpenAI finish_reason in any choice delta is now tracked
    • Terminal usage-only chunks (no choices, not a Responses API
      event) are now tracked
    • Claude message_stop and message_delta with stop_reason are
      co-located with the existing hasLifecycleEnd updates
  • Risk: low. The new gate is strictly tighter than the previous one
    (more streams are now classified as invalid); the four new tests are
    designed to catch any over-tightening regression. The 27 existing
    validator / combo-quality / failover tests still pass.
  • Manual validation: no manual steps required to validate — the
    four new tests reproduce the user's exact failure mode at the unit
    level.

@Chewji9875
Chewji9875 requested a review from diegosouzapw as a code owner July 17, 2026 04:20
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@Chewji9875
Chewji9875 changed the base branch from main to release/v3.8.49 July 17, 2026 04:21
@diegosouzapw

Copy link
Copy Markdown
Owner

Thanks for tracking down log 1784230812441-bf3789 — the root cause is real and still present on release/v3.8.49 tip: the generic silent-stop gate in validateResponseQuality (!anyContentFound && !sse.hasContentBlock && !sawAnyBytes) lets ANY byte, even unparseable garbage, bypass combo failover. I reproduced this with your own regression test against the current release tip (fails as expected) and confirmed your fix (tightening to require a recognised terminator or structured SSE frame) resolves it.

One issue: this branch predates two fixes that already merged into release/v3.8.49 — issue #7285 (OpenAI-shape stream truncated without finish_reason, OpenAiLifecycleFlags) and issue #1382 (Claude real-content vs. empty-content_block nuance, SseLifecycleFlags.hasRealContent). Your diff deletes both and replaces them with an older, coarser implementation, which would be a regression if merged as-is. The base also carries some unrelated CI-infra commits (.mergify.yml, config/quality/quality-baseline.json, a check-test-masking test) that don't belong in this PR and are already superseded on release.

We're going to rebase your fix onto the current file — keeping the #7285/#1382 logic intact and adding your sawTerminator/sawStructuredSSE tracking alongside it, plus your 4 new regression tests. I validated that combination locally: all 51 tests across the touched area (yours + the #7285/#1382/#6427/failover suites) pass green. You'll be credited as co-author on the landed commit. Appreciate the sharp bug catch and the clean TDD test — this was a real gap.

User log 1784230812441-bf3789: a combo target returned an SSE stream that
carried bytes but never sent a recognised terminator (`data: [DONE]`,
`message_stop`, `message_delta` with `stop_reason`, or a `finish_reason`)
and never produced a single parseable SSE frame. The streaming quality
validator's generic done-branch gate only checked `!sawAnyBytes`, so any
byte at all — even unparseable garbage — passed the stream through. The
combo did not fail over to the next target and the downstream SSE client
hung waiting for events that never arrived.

Rebuilt against the current release/v3.8.49 tip instead of the original
branch diff: the original diff predates and deletes two fixes already
merged to release — issue diegosouzapw#7285 (`OpenAiLifecycleFlags` /
`applyOpenAiLifecycleEvent`, the OpenAI-shape "truncated without
finish_reason" failover branch) and issue diegosouzapw#1382 (`SseLifecycleFlags
.hasRealContent`, the Claude real-content vs. empty-content_block
nuance). Both are preserved untouched here. Two new flags are tracked
in parallel to that existing machinery instead of replacing it:

  * sawStructuredSSE — any parseable `event:` or `data:` frame was seen,
    even one carrying no recognised content (ping/metadata) — keeps the
    diegosouzapw#3399/diegosouzapw#3685 pass-through contract for those streams.
  * sawTerminator     — a recognised terminator arrived: `data: [DONE]`,
    an OpenAI `finish_reason` (mirrors `openAi.hasTerminalMarker`), a
    Claude `message_stop`/`message_delta` with `stop_reason` (mirrors
    `sse.hasLifecycleEnd`), or a terminal `usage`-only chunk (new).

The generic done-branch gate now requires neither flag to be true before
marking the stream invalid, replacing the old `!sawAnyBytes` check (now
dead and removed). The diegosouzapw#7285 and diegosouzapw#1382 branches are untouched.

Tests added in tests/unit/validate-response-quality.test.ts (adapted
from the original branch, same scenarios):
  1. incomplete lifecycle (the bug) -> invalid
  2. `[DONE]` only -> valid (regression guard for diegosouzapw#3685)
  3. `event: ping` only -> valid (regression guard for diegosouzapw#3399)
  4. OpenAI `finish_reason`-only chunk (no `[DONE]`) -> valid, isolates
     the new finish_reason check

Full touched-area regression set verified green (51/51): the new tests
plus combo-streaming-openai-no-finish-reason-7285, streaming-empty-
content-block-1382, combo-quality-validator-reasoning, masked-200-
exhaustion-fallback-6427, combo-streaming-empty-content-failover,
combo-empty-content-failover-5085, combo-response-validation-failover,
and combo-response-validation.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
@diegosouzapw
diegosouzapw force-pushed the fix/combo-fallback-502 branch from cc1c735 to 3b91f9c Compare July 18, 2026 11:25
Chewji9875 and others added 3 commits July 18, 2026 21:21
Resolves conflict in open-sse/services/combo/validateQuality.ts: keep both
the release's isStreamingUpstreamError() early-return and this PR's
terminal usage-only-chunk detection, error check taking priority.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
…lpers (complexity gate on parseAccumulatedSse)

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
…ity-gate compensation)

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
@diegosouzapw
diegosouzapw merged commit 6ca3531 into diegosouzapw:release/v3.8.49 Jul 19, 2026
4 of 5 checks passed
@diegosouzapw

Copy link
Copy Markdown
Owner

Merged into release/v3.8.49 — thank you for the contribution, @Chewji9875! 🎉 Validated in the final R3 merge-train (full static gates + unit suite on a dedicated 32-core box) before landing.

@diegosouzapw diegosouzapw mentioned this pull request Jul 23, 2026
HouMinXi pushed a commit to HouMinXi/OmniRoute that referenced this pull request Aug 2, 2026
…egosouzapw#7545)

* fix(combo): failover when upstream SSE is truncated mid-lifecycle

User log 1784230812441-bf3789: a combo target returned an SSE stream that
carried bytes but never sent a recognised terminator (`data: [DONE]`,
`message_stop`, `message_delta` with `stop_reason`, or a `finish_reason`)
and never produced a single parseable SSE frame. The streaming quality
validator's generic done-branch gate only checked `!sawAnyBytes`, so any
byte at all — even unparseable garbage — passed the stream through. The
combo did not fail over to the next target and the downstream SSE client
hung waiting for events that never arrived.

Rebuilt against the current release/v3.8.49 tip instead of the original
branch diff: the original diff predates and deletes two fixes already
merged to release — issue diegosouzapw#7285 (`OpenAiLifecycleFlags` /
`applyOpenAiLifecycleEvent`, the OpenAI-shape "truncated without
finish_reason" failover branch) and issue diegosouzapw#1382 (`SseLifecycleFlags
.hasRealContent`, the Claude real-content vs. empty-content_block
nuance). Both are preserved untouched here. Two new flags are tracked
in parallel to that existing machinery instead of replacing it:

  * sawStructuredSSE — any parseable `event:` or `data:` frame was seen,
    even one carrying no recognised content (ping/metadata) — keeps the
    diegosouzapw#3399/diegosouzapw#3685 pass-through contract for those streams.
  * sawTerminator     — a recognised terminator arrived: `data: [DONE]`,
    an OpenAI `finish_reason` (mirrors `openAi.hasTerminalMarker`), a
    Claude `message_stop`/`message_delta` with `stop_reason` (mirrors
    `sse.hasLifecycleEnd`), or a terminal `usage`-only chunk (new).

The generic done-branch gate now requires neither flag to be true before
marking the stream invalid, replacing the old `!sawAnyBytes` check (now
dead and removed). The diegosouzapw#7285 and diegosouzapw#1382 branches are untouched.

Tests added in tests/unit/validate-response-quality.test.ts (adapted
from the original branch, same scenarios):
  1. incomplete lifecycle (the bug) -> invalid
  2. `[DONE]` only -> valid (regression guard for diegosouzapw#3685)
  3. `event: ping` only -> valid (regression guard for diegosouzapw#3399)
  4. OpenAI `finish_reason`-only chunk (no `[DONE]`) -> valid, isolates
     the new finish_reason check

Full touched-area regression set verified green (51/51): the new tests
plus combo-streaming-openai-no-finish-reason-7285, streaming-empty-
content-block-1382, combo-quality-validator-reasoning, masked-200-
exhaustion-fallback-6427, combo-streaming-empty-content-failover,
combo-empty-content-failover-5085, combo-response-validation-failover,
and combo-response-validation.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* refactor(combo): extract consumeSseLine + isTerminalUsageOnlyChunk helpers (complexity gate on parseAccumulatedSse)

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* refactor(combo): move parseJsonRecord to module scope (finish complexity-gate compensation)

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…egosouzapw#7545)

* fix(combo): failover when upstream SSE is truncated mid-lifecycle

User log 1784230812441-bf3789: a combo target returned an SSE stream that
carried bytes but never sent a recognised terminator (`data: [DONE]`,
`message_stop`, `message_delta` with `stop_reason`, or a `finish_reason`)
and never produced a single parseable SSE frame. The streaming quality
validator's generic done-branch gate only checked `!sawAnyBytes`, so any
byte at all — even unparseable garbage — passed the stream through. The
combo did not fail over to the next target and the downstream SSE client
hung waiting for events that never arrived.

Rebuilt against the current release/v3.8.49 tip instead of the original
branch diff: the original diff predates and deletes two fixes already
merged to release — issue diegosouzapw#7285 (`OpenAiLifecycleFlags` /
`applyOpenAiLifecycleEvent`, the OpenAI-shape "truncated without
finish_reason" failover branch) and issue diegosouzapw#1382 (`SseLifecycleFlags
.hasRealContent`, the Claude real-content vs. empty-content_block
nuance). Both are preserved untouched here. Two new flags are tracked
in parallel to that existing machinery instead of replacing it:

  * sawStructuredSSE — any parseable `event:` or `data:` frame was seen,
    even one carrying no recognised content (ping/metadata) — keeps the
    diegosouzapw#3399/diegosouzapw#3685 pass-through contract for those streams.
  * sawTerminator     — a recognised terminator arrived: `data: [DONE]`,
    an OpenAI `finish_reason` (mirrors `openAi.hasTerminalMarker`), a
    Claude `message_stop`/`message_delta` with `stop_reason` (mirrors
    `sse.hasLifecycleEnd`), or a terminal `usage`-only chunk (new).

The generic done-branch gate now requires neither flag to be true before
marking the stream invalid, replacing the old `!sawAnyBytes` check (now
dead and removed). The diegosouzapw#7285 and diegosouzapw#1382 branches are untouched.

Tests added in tests/unit/validate-response-quality.test.ts (adapted
from the original branch, same scenarios):
  1. incomplete lifecycle (the bug) -> invalid
  2. `[DONE]` only -> valid (regression guard for diegosouzapw#3685)
  3. `event: ping` only -> valid (regression guard for diegosouzapw#3399)
  4. OpenAI `finish_reason`-only chunk (no `[DONE]`) -> valid, isolates
     the new finish_reason check

Full touched-area regression set verified green (51/51): the new tests
plus combo-streaming-openai-no-finish-reason-7285, streaming-empty-
content-block-1382, combo-quality-validator-reasoning, masked-200-
exhaustion-fallback-6427, combo-streaming-empty-content-failover,
combo-empty-content-failover-5085, combo-response-validation-failover,
and combo-response-validation.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* refactor(combo): extract consumeSseLine + isTerminalUsageOnlyChunk helpers (complexity gate on parseAccumulatedSse)

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* refactor(combo): move parseJsonRecord to module scope (finish complexity-gate compensation)

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
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.

2 participants