Skip to content

fix(combo): advance to next model on 400 'model not supported' - #5249

Merged
diegosouzapw merged 1 commit into
cheaper-inference:release/v3.8.40from
Chewji9875:fix/combo-400-model-fallback
Jun 29, 2026
Merged

diegosouzapw merged 1 commit into
cheaper-inference:release/v3.8.40from
Chewji9875:fix/combo-400-model-fallback

Conversation

@Chewji9875

Copy link
Copy Markdown
Contributor

Problem

When a combo target returns 400 with "requested model is not supported", the combo routing engine stops instead of advancing to the next target. This means a request routed through a combo that hits a provider lacking a specific model gets a hard 400 failure — even though other targets in the combo (different providers) may support that model.

Root Cause

In open-sse/services/combo.ts, the 400 guard condition in handleComboChat (priority strategy) included fallbackResult.reason === RateLimitReason.MODEL_CAPACITY in its "block fallback" list:

if (
  result.status === 400 &&
  fallbackResult.shouldFallback &&
  !isContextOverflow400(errorText) &&
  !isParamValidation400(errorText) &&
  (fallbackResult.reason === RateLimitReason.MODEL_CAPACITY ||  // ← bug
   errorText.toLowerCase().includes("context") ||
   errorText.toLowerCase().includes("prompt") ||
   /* ... more blocking patterns ... */)
) {
  return { ok: false, response: result };  // combo STOPS here
}

checkFallbackError() correctly classifies "model not supported" as MODEL_CAPACITY with shouldFallback: true — meaning "yes, try the next target." But the guard condition blocked exactly this case, contradicting the comment at line 2106: "Model access denied: different providers serve different model sets — These should fall through."

Fix

1 line removed from the guard condition + cleanup of the now-unused RateLimitReason import:

-  (fallbackResult.reason === RateLimitReason.MODEL_CAPACITY ||
-   errorText.toLowerCase().includes("context") ||
+  (errorText.toLowerCase().includes("context") ||

handleRoundRobinCombo does not have this same guard — only one fix location.

Test

Added test: "priority combo advances to next model when first returns 400 'model not supported'"

  • Creates 2-model priority combo (openai/gpt-4 → openai/gpt-3.5-turbo)
  • First model returns 400 with { error: { message: "requested model is not supported" } }
  • Second model returns 200
  • Asserts: status=200, both models tried, correct call order

16/16 tests pass on release/v3.8.40.

When a combo target returns 400 with 'model not supported', the combo
should advance to the next target (different providers serve different
models). The 400 guard in handleComboChat included MODEL_CAPACITY in its
block list, which contradicted the comment stating model access denied
errors 'should fall through'.

Remove RateLimitReason.MODEL_CAPACITY from the 400 guard condition and
clean up the now-unused import.

Test: combo advances from first model (400) to second model (200).
@Chewji9875
Chewji9875 requested a review from diegosouzapw as a code owner June 28, 2026 18:32

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

Copy link
Copy Markdown
Contributor

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 removes the RateLimitReason.MODEL_CAPACITY check from the fallback condition in handleComboChat and adds a unit test to verify that the priority combo strategy correctly falls back to the next model when a 400 error is encountered. The feedback suggests updating the test case to use an error message containing 'invalid' to ensure the fallback logic is robust against providers returning 'invalid' in their unsupported model errors.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +732 to +737
if (modelStr === "openai/gpt-4") {
return Response.json(
{ error: { message: "requested model is not supported" } },
{ status: 400 }
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

To ensure the fallback logic is robust against providers returning "invalid" in their unsupported model errors (e.g., "requested model is invalid"), we should update the test case to use an error message containing "invalid".

This will verify that the guard condition in handleComboChat correctly ignores "invalid" when it is associated with the model, allowing the fallback to proceed.

      if (modelStr === "openai/gpt-4") {
        return Response.json(
          { error: { message: "requested model is invalid" } },
          { status: 400 }
        );
      }

@KooshaPari

Copy link
Copy Markdown
Contributor

I reproduced the failing #4279 body-specific 400 regression on this PR and opened a small helper PR against the source branch:

Chewji9875#2

What it does:

Local validation on the helper branch:

  • targeted combo 400 tests: 22/22 pass
  • npm run check:file-size: ok

Commit: KooshaPari:fix/5249-body-400-guard @ bc9e85b

@KooshaPari

Copy link
Copy Markdown
Contributor

I reproduced the current #5249 unit failure and pushed a helper fix that preserves the intended model-catalog 400 fallthrough without regressing the body-specific 400 stop guard.

Helper PR into the source fork:
Chewji9875#2

Branch/commit:

  • KooshaPari:fix/5249-body-400-guard
  • bc9e85b fix(combo): preserve body-specific 400 stop

Validation on the #5249 head + helper commit:

  • env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit tests/unit/combo-body-specific-400-stop-4279.test.ts tests/unit/combo-strategies.test.ts tests/unit/accountfallback-ratelimit-400-4976.test.ts
    • 22/22 pass
  • npm run check:file-size
    • pass

The narrow change adds a cross-target model unsupported classifier and keeps account/body-specific messages such as not supported when using Codex with a ChatGPT account from falling through every combo target.

@KooshaPari

Copy link
Copy Markdown
Contributor

I reproduced the red #5249 CI locally and pushed a focused helper fix for the guard regression:

Helper PR: Chewji9875#2
Branch: KooshaPari:fix/5249-body-400-guard
Commit: a98671ca6

Root cause: the PR correctly wants generic requested model is not supported 400s to advance to the next combo target, but dropping the body-specific 400 guard let the existing #4279 Codex/ChatGPT-account rejection try every combo target again. The helper keeps generic model-not-supported fallback while preserving terminal handling for body-specific 400s.

Validation on the PR head + helper commit:

  • node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --test tests/unit/combo-body-specific-400-stop-4279.test.ts tests/unit/combo-strategies.test.ts -> 18/18 pass
  • npm run check:file-size -> pass

@diegosouzapw

Copy link
Copy Markdown
Collaborator

Thanks for this, @Chewji9875 — the goal (a combo should advance to the next model when one model isn't supported) is valid. But as written this can't merge yet, because it regresses an existing anti-infinite-loop guard.

The conflict: removing the fallbackResult.reason === RateLimitReason.MODEL_CAPACITY branch from the #2101/#4279 stop-guard in combo.ts makes tests/unit/combo-body-specific-400-stop-4279.test.ts fail. That test guards a real reported bug (#4279): a Codex combo whose models are all rejected with "The 'X' model is not supported when using Codex with a ChatGPT account." (which classifies as MODEL_CAPACITY) must stop at the first 400 instead of marching through all 143 targets with the same account-level rejection.

So we have two legitimate-but-opposite cases that both surface as a 400 "model not supported":

Blanket-removing the MODEL_CAPACITY branch collapses both into "advance", which reintroduces the #4279 symptom.

Suggested direction: keep the stop-guard, but narrow it so it only fires for the account-scoped variant (e.g. detect the account/plan-scoped phrasing, or check whether the same rejection applies to all remaining targets) and let the genuinely per-model "not supported" fall through to the next target. A test covering both combo-body-specific-400-stop-4279 (still green) and your new advance-on-400 case would lock the behavior in.

Leaving this open for you to refine — happy to review again once both scenarios pass. 🙏

@KooshaPari

Copy link
Copy Markdown
Contributor

I reproduced the current #5249 failure and opened a focused helper PR into the source branch:

Chewji9875#3

It keeps this PR's intended behavior for generic requested model is not supported 400s (advance to the next combo target), while restoring the existing #4279 guard for body/account-specific 400s like the Codex + ChatGPT-account rejection.

Validation on KooshaPari:fix/5249-body-400-stop (1d7f6017c):

  • env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --test --test-force-exit tests/unit/combo-body-specific-400-stop-4279.test.ts tests/unit/combo-strategies.test.ts -> 17/17 pass
  • npm run check:file-size -> pass
  • npx eslint open-sse/services/combo.ts tests/unit/combo-strategies.test.ts tests/unit/combo-body-specific-400-stop-4279.test.ts -> pass

@diegosouzapw
diegosouzapw merged commit 12de25b into cheaper-inference:release/v3.8.40 Jun 29, 2026
3 checks passed
diegosouzapw added a commit that referenced this pull request Jun 29, 2026
#5300)

#5249 deliberately changed a MODEL_CAPACITY 400 ('model X not supported with
this account') from STOP to ADVANCE-to-next-combo-target, since a different
model in the combo may be supported. It added a regression test in
combo-strategies.test.ts but did not update the separate
combo-body-specific-400-stop-4279.test.ts, which still asserted the old STOP
behavior for that exact model-not-supported text — leaving the release branch
red on the Unit 2/2 shard (every PR inherited the failure).

Re-point the #4279 test at a genuinely body-specific malformed 400 ('invalid
message format'), which still recurs identically on every target and must STOP.
This preserves the #2101 anti-loop {ok,response} contract coverage while the
advance-on-model-400 path stays covered by combo-strategies.test.ts.

Test-only; no production behavior change.
@diegosouzapw diegosouzapw mentioned this pull request Jun 29, 2026
@RaviTharuma

RaviTharuma commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

@diegosouzapw @Chewji9875 I am still facing this issue, I think we have to dig deep into this again..
#8251

diegosouzapw pushed a commit that referenced this pull request Jul 23, 2026
…st (#8252)

#2101 still hard-stopped model-not-supported failures when upstream
wrapped them as invalid_request_error / Bad Request. Keep models in the
combo and try the next target (#8251, residual of #5249).

- export MODEL_ACCESS_DENIED_PATTERNS + broaden does-not-support shapes
- add isModelScoped400() and exempt it from the body-specific stop guard
- regression tests for wrapper forms; body-specific stop still intact

Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
HouMinXi pushed a commit to HouMinXi/OmniRoute that referenced this pull request Aug 2, 2026
…st (cheaper-inference#8252)

cheaper-inference#2101 still hard-stopped model-not-supported failures when upstream
wrapped them as invalid_request_error / Bad Request. Keep models in the
combo and try the next target (cheaper-inference#8251, residual of cheaper-inference#5249).

- export MODEL_ACCESS_DENIED_PATTERNS + broaden does-not-support shapes
- add isModelScoped400() and exempt it from the body-specific stop guard
- regression tests for wrapper forms; body-specific stop still intact

Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
KooshaPari added a commit to KooshaPari/OmniRoute that referenced this pull request Aug 9, 2026
…on fork-main

Fork-only commit. Strategy note (Aug 2026): upstream cadence
slowed to 1 release every 2-3 weeks. We land changes here first
and cherry-pick upstream only when diffs are small enough.

Adds tests/unit/combo-decision-matrix.test.ts with 25+ matrix
rows encoding the (provider, status, error envelope) → {advance |
stop | retry-then-stop} contract that the combo loop enforces
through checkFallbackError.

Recent history (cheaper-inference#2101 / cheaper-inference#4279 / cheaper-inference#5249 / cheaper-inference#8251 / cheaper-inference#8252) has been
six rounds of 'a substring in the 400 envelope was broadened;
combo advances where it used to stop.' Having one canonical
matrix file lets a future maintainer (and a code reviewer)
verify the contract at a glance.

Differs from the upstream-targeted PR-β draft:
- That draft assumed MODEL_ACCESS_DENIED_PATTERNS and
  isModelScoped400 were exported from comboPredicates.ts.
- Audit correction: MODEL_ACCESS_DENIED_PATTERNS is 'const'
  (internal to accountFallback.ts); isModelScoped400 does not
  exist as a function. The matrix here exercises
  checkFallbackError directly — the public predicate surface
  combo dispatchers actually consult in production.

Case matrix covers:
- 400 advance on model-not-supported across anthropic/openai/
  gemini/azure-openai (structured + text patterns)
- 400 stop on malformed/overflow/param-validation
- 400 stop on auth-credential 400 (must never re-classify to
  model-access advance)
- 401 stop on plain auth failures
- 429 retry-then-stop on plain rate limits
- 429 stop on quota-exhausted with full reset window
- 5xx retry-then-stop on transient upstream outages
- 503 retry-then-stop via ALL_ACCOUNTS_RATE_LIMITED_PATTERNS
- Negative-path tests guarding against substring leakage on
  non-400 statuses

Files: +1 (test only), 0 production-code lines touched.
Behavior-preserving.
diegosouzapw pushed a commit that referenced this pull request Aug 29, 2026
… requests (#11781)

Follow-up to #11762/#11774, same bug class in combo's own model-lockout wiring: GitHub rejects several models (gpt-5.4, gpt-5.3-codex, etc.) with a 400 that's permanently unavailable for this account's Copilot integration, but nothing recorded a cross-request lockout — combo's #5249 in-request advance guard is correct but doesn't persist, so the same doomed model gets retried from scratch on every new request, indefinitely.

Fix: on a model-scoped 400 (`isModelScoped400`), call `lockModelIfPerModelQuota(provider, connectionId, rawModel, "model_capacity", 1h)`. GitHub already has per-model-quota enabled, so only the rejected model locks — siblings keep working. `isModelLocked()` is already checked pre-dispatch, so no other wiring needed.

Validated: 3/3 new tests + fixed a pre-existing test-isolation gap in combo-model-scoped-400-advance.test.ts (shared model name across sub-tests without clearing lockout state). Thanks!
tkgo11 pushed a commit to tkgo11/OmniRoute that referenced this pull request Sep 23, 2026
…er-inference#5249)

A 400 classified as MODEL_CAPACITY (e.g. 'requested model is not supported') hit the cheaper-inference#2101 anti-loop stop-branch and halted the combo instead of advancing. Drop the MODEL_CAPACITY trigger from that stop condition so model-specific 400s fall through to the next combo target; genuinely body-specific 400s (malformed/invalid/bad-request substrings) still stop. Validated: combo-strategies suite 16/16 (incl. the new regression test) green on current release tip, typecheck clean. Stale pre-merge CI was from an older base.

Co-authored-by: Chewji9875 <Chewji9875@users.noreply.github.com>
tkgo11 pushed a commit to tkgo11/OmniRoute that referenced this pull request Sep 23, 2026
…eaper-inference#5249 advance policy (cheaper-inference#5300)

cheaper-inference#5249 deliberately changed a MODEL_CAPACITY 400 ('model X not supported with
this account') from STOP to ADVANCE-to-next-combo-target, since a different
model in the combo may be supported. It added a regression test in
combo-strategies.test.ts but did not update the separate
combo-body-specific-400-stop-4279.test.ts, which still asserted the old STOP
behavior for that exact model-not-supported text — leaving the release branch
red on the Unit 2/2 shard (every PR inherited the failure).

Re-point the cheaper-inference#4279 test at a genuinely body-specific malformed 400 ('invalid
message format'), which still recurs identically on every target and must STOP.
This preserves the cheaper-inference#2101 anti-loop {ok,response} contract coverage while the
advance-on-model-400 path stays covered by combo-strategies.test.ts.

Test-only; no production behavior change.
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…st (cheaper-inference#8252)

cheaper-inference#2101 still hard-stopped model-not-supported failures when upstream
wrapped them as invalid_request_error / Bad Request. Keep models in the
combo and try the next target (cheaper-inference#8251, residual of cheaper-inference#5249).

- export MODEL_ACCESS_DENIED_PATTERNS + broaden does-not-support shapes
- add isModelScoped400() and exempt it from the body-specific stop guard
- regression tests for wrapper forms; body-specific stop still intact

Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
… requests (cheaper-inference#11781)

Follow-up to cheaper-inference#11762/cheaper-inference#11774, same bug class in combo's own model-lockout wiring: GitHub rejects several models (gpt-5.4, gpt-5.3-codex, etc.) with a 400 that's permanently unavailable for this account's Copilot integration, but nothing recorded a cross-request lockout — combo's cheaper-inference#5249 in-request advance guard is correct but doesn't persist, so the same doomed model gets retried from scratch on every new request, indefinitely.

Fix: on a model-scoped 400 (`isModelScoped400`), call `lockModelIfPerModelQuota(provider, connectionId, rawModel, "model_capacity", 1h)`. GitHub already has per-model-quota enabled, so only the rejected model locks — siblings keep working. `isModelLocked()` is already checked pre-dispatch, so no other wiring needed.

Validated: 3/3 new tests + fixed a pre-existing test-isolation gap in combo-model-scoped-400-advance.test.ts (shared model name across sub-tests without clearing lockout state). Thanks!
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.

4 participants