Skip to content

fix(routing): count the first abandoned combo target as a fallback - #16250

Merged
diegosouzapw merged 3 commits into
cheaper-inference:release/v3.8.52from
M3stryX:fix/combo-fallbackcount-off-by-one
Oct 16, 2026
Merged

diegosouzapw merged 3 commits into
cheaper-inference:release/v3.8.52from
M3stryX:fix/combo-fallbackcount-off-by-one

Conversation

@M3stryX

@M3stryX M3stryX commented Oct 10, 2026

Copy link
Copy Markdown
Contributor

Summary

  • The combo fallback counter never counted the abandonment of the first ordered target: every fallbackCount++ in the attempt loop was gated by if (i > 0) / if (offset > 0). A failover that abandons target [WIP] Add FastAPI OpenAI-compatible LLM gateway with routing #1 and succeeds on target build(deps): bump actions/github-script from 7 to 8 #2 therefore left the counter at 0.
  • X-OmniRoute-Fallback-Attempts is documented in docs/reference/API_REFERENCE.md as part of the public cost-telemetry header set and is emitted only when > 0, so the most common failover shape (primary fails, the next target answers) reported no header at all and was indistinguishable from "no fallback happened".
  • The same under-count feeds recordComboRequest({ fallbackCount }) and the aggregated totalFallbacks metric, so combo telemetry and the Control Center under-report failovers by one.
  • Fix: the six abandonment paths now increment unconditionally. No routing decision changes — this is a counter and its consumers only.

Related Issues

#12339 (closed) reported X-OmniRoute-Fallback-Attempts as always 0 on chat completions and was fixed by threading fallbackAttempts through the streaming header path. That was a different root cause (the value was not forwarded at all). The expected behaviour stated in that issue — "X-OmniRoute-Fallback-Attempts should be >= 1 on the streaming response whenever a failover occurred" — is exactly what the guard prevents for a first-target failover, so this PR closes the remaining half.

Validation

  • Change type: routing
  • Focused tests and category gates from the golden path
  • npm run lint
  • Reconciled with the current active release base; focused checks rerun afterward
  • Production-code changes include a new or updated automated test in this PR
  • SonarQube is temporarily opt-in while the private project has no quota; it is not a PR gate.

Commands run, on this exact head commit (base release/v3.8.52 @ 048d9e2f2a):

node --import tsx/esm --test tests/unit/combo/execute-target-attempt.test.ts \
  tests/unit/combo/execute-target-gates.test.ts tests/unit/combo/round-robin-combo.test.ts
npm run test:combo:matrix
npm run check:known-symbols
npm run lint
npm run typecheck:core

Behavioural evidence (same test files, sources flipped):

Run Result
pristine release/v3.8.52 sources 49 tests, 45 pass, 4 fail
with this PR 49 tests, 49 pass, 0 fail

The four tests that go red without the change are precisely the ones asserting a first-target abandonment is counted (a retryable failure of the first ordered target counts one fallback, a first-target fallback reaches totalFallbacks, not just the counter, a gate skip of the first ordered target counts one fallback, counts an abandoned first model as a fallback).

Tests Added Or Updated

  • tests/unit/combo/execute-target-attempt.test.ts — first-target retryable failure, same-target retry that succeeds (must not count), and the metric aggregation path.
  • tests/unit/combo/execute-target-gates.test.ts — first-target and later-target gate skips.
  • tests/unit/combo/round-robin-combo.test.ts — the round-robin extraction guard test.

Coverage Notes

  • The change touches open-sse/services/combo/{comboAttemptLoop,executeTargetAttempt,executeTargetGates,roundRobinCombo}.ts. All four are exercised by the three test files above, which previously had no assertion on this counter (the only fallbackCount mentions in the existing suite were emptyState initialisers and the aggregation assertions in tests/unit/services/combo-metrics-memory.test.ts and tests/unit/services-branch-hardening.test.ts, both of which keep passing unchanged).
  • No new module and no untested branch: the guard removal only makes existing increment paths unconditional.

Reviewer Notes

  • Risky areas: none for routing. The counter is read by the response-meta headers, recordComboRequest() and the combo metrics memory; nothing gates dispatch on it.
  • Semantics to confirm: the header name and the docs say "fallback attempts", and the guard made the first abandoned target free. If the intended meaning was instead "failures that happened after the first choice", then the header/totalFallbacks naming and docs are what should change — worth a maintainer call, and I am happy to rework the patch either way.
  • One increment site is new since the last rebase (roundRobinCombo.ts refusal branch, if (offset > 0)) and is covered by the same reasoning; it was added with the current cycle.
  • No migrations, no feature flags, no generated artifacts, no i18n changes.

`fallbackCount` is the number of ordered targets abandoned before the one
that serves a combo request. It drives `totalFallbacks` / `fallbackRate` in
/api/combos/metrics and must equal the index of the target that wins — the
value stamped on that target as `fallbackAttempts` and returned to clients
as `X-OmniRoute-Fallback-Attempts`.

Every increment site was gated on `i > 0` / `offset > 0`, which conflated
"not the first target" with "is a fallback": abandoning the primary for the
secondary — i.e. the first fallback — was never counted. A 2-target combo
that fell back once recorded `fallbackCount: 0` (0% fallback rate) while the
response advertised `X-OmniRoute-Fallback-Attempts: 1`.

Drop the guards: one abandoned ordered target is one fallback, whatever its
index. Sites: executeTargetAttempt.ts (8 abandon paths), the `bumpFallback`
helper in executeTargetGates.ts and roundRobinCombo.ts (9 skip paths).
Retrying the *same* target is not an abandon path and still counts nothing.

Tests (failing against the unfixed sources, passing after):
- execute-target-attempt.test.ts: a retryable failure of target 0 counts
  one fallback and reaches `totalFallbacks` in the metrics record; a
  same-target retry that succeeds keeps the counter at 0.
- execute-target-gates.test.ts: a gate skip of target 0 counts one
  fallback, a skip of a later target still counts one.
- round-robin-combo.test.ts: source guard so neither condition returns.
@M3stryX
M3stryX requested a review from diegosouzapw as a code owner October 10, 2026 16:26
Review follow-up on cheaper-inference#16250. Three branches of executeTargetAttempt abort the
combo instead of advancing to the next target — local queue capacity,
input-bound failure, and a body-specific 400 that must surface as-is. They
still bumped state.fallbackCount, so a request that simply failed was
recorded as having failed over, and a first-target stop was indistinguishable
from a real fallback in totalFallbacks.

The counter tracks the number of ordered targets abandoned before the one
that serves the request, so a branch that tries no further target counts
nothing. roundRobinCombo.ts already behaves that way on its own
queue-capacity branch, whose log line reads "returning without upstream
fallback" — the priority path was the outlier.

Adds behavioural non-regression coverage for the three stops (each one turns
red when the increment is re-added), and replaces the source-regex guard on
the round-robin fallback with a test that drives handleComboChat and asserts
the observable totalFallbacks metric.
@M3stryX

M3stryX commented Oct 10, 2026

Copy link
Copy Markdown
Contributor Author

Review follow-up — every stop that ends the combo no longer counts as a fallback

Thanks for the review. Point 1 was right, and it was one branch wider than reported.

What changed (commit 245045e739)

state.fallbackCount must mean how many ordered targets were abandoned before the one that served the request — that value is what feeds X-OmniRoute-Fallback-Attempts and totalFallbacks. Three branches of executeTargetAttempt abort the combo instead of advancing, yet still bumped the counter, so a request that simply failed was recorded as having failed over:

Branch Why it is not a fallback
local queue capacity (isLocalQueueCapacityErrorBody) its own log line reads "returning without upstream fallback"
input-bound failure (shouldAbortOnInputBoundFailure) the same input would fail identically on every remaining target, so none is tried
body-specific 400 ("stopping combo") the upstream error is surfaced as-is, no further target is attempted

The third one was not in the review but behaves the same way; it is corrected too. roundRobinCombo.ts already ignores its own queue-capacity branch, so the priority path was the outlier, not the rule.

Point 2 — the round-robin test

The source-regex guard is replaced by a test that drives the real handleComboChat with a two-model round-robin combo (first model 500, second serves) and asserts the observable getComboMetrics(...).totalFallbacks === 1. Re-adding the guard makes it fail with 0 !== 1 instead of failing on a string match.

Non-regression proof

Injection Result
re-add state.fallbackCount++ on the three terminal stops 3 tests red (local queue-capacity stop, input-bound stop, body-specific 400 stop), 22/25 pass
re-add the offset > 0 guard in roundRobinCombo.ts round-robin behavioural test red — totalFallbacks asserted 1, got 0

Gates on this head, run locally: lint 0, typecheck:core 0, check:known-symbols 0, test:combo:matrix 40/40 pass. Full combo unit set 52/52.

Point 3 — the workflows in action_required

Expected for a PR from a fork: the GitHub Actions jobs stay queued until a maintainer approves them. Nothing on our side can start them, and it is not a test failure. Semgrep ran and passed independently.

Diff is now 8 files: 4 engine + 3 tests + 1 changelog fragment. No routing condition is touched — the counter only.

@diegosouzapw

Copy link
Copy Markdown
Collaborator

Hi @M3stryX — closing this pull request to clear the open queue. The branch fix/combo-fallbackcount-off-by-one is kept, so the commits stay. Comment here if it should be reopened.

@diegosouzapw

Copy link
Copy Markdown
Collaborator

Hi @M3stryX — reopening this pull request. The close was a mistake, and it is back in the merge queue. The branch fix/combo-fallbackcount-off-by-one was kept.

@diegosouzapw diegosouzapw reopened this Oct 10, 2026
@diegosouzapw
diegosouzapw merged commit 639fb13 into cheaper-inference:release/v3.8.52 Oct 16, 2026
3 checks passed
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