Skip to content

fix(guardrails): stop Vision Bridge from re-selecting a model locked after a 404 (#12111) - #13259

Merged
diegosouzapw merged 1 commit into
release/v3.8.51from
fix/12111-vision-bridge-lockout
Sep 12, 2026
Merged

diegosouzapw merged 1 commit into
release/v3.8.51from
fix/12111-vision-bridge-lockout

Conversation

@diegosouzapw

Copy link
Copy Markdown
Owner

Closes #12111

Summary

The Vision Bridge auto-router could keep selecting a model that
was already model-locked after a 404, because getVisionCapableModels()
(src/lib/guardrails/visionBridgeRouter.ts) only filtered candidates on the
registry supportsVision flag and connection-scoped credential usability
(hasUsableCredentialsForModel) — it never consulted the per-connection
model lockout that open-sse/services/accountFallback.ts sets when
chatCore.ts locks a model for 120s after a 404 "model not found" (the
connection itself stays active, so the credential check never saw it). The
60s selection cache (cachedModelRemainsAvailable) had the same gap, so a
pick that became locked mid-cache-window kept being served for up to another
60s of failing requests.

Root cause

isModelLocked(provider, connectionId, model) is scoped per
provider+connection+model, so the fix can't just drop a model when it's
locked on some connection — it has to enumerate the provider's usable
connections and exclude the model only when it is locked on every one of
them (mirrors isConnectionEligibleForModel in
open-sse/services/autoCombo/resilienceCandidateFilter.ts, which already
solves this for auto-combo pools).

Fix

  • src/lib/guardrails/visionBridgeCredentials.ts: added
    getUsableConnectionsForModel(), a connection-aware sibling of
    hasUsableCredentialsForModel() that returns the actual usable
    provider_connections rows for a model's provider (reusing the existing
    getProviderConnections + isProviderConnectionUsable/
    hasTerminalConnectionStatus logic) instead of collapsing to a boolean.
  • src/lib/guardrails/visionBridgeRouter.ts:
    • isModelUsableGivenLockouts() checks a model against every connection
      that could serve it — DB-known usable connections plus any connectionId
      already carrying an active lockout for that provider (a 404 lock can
      target a connectionId the DB lookup doesn't independently surface) —
      and excludes the model only when all of them are locked. Fails open
      when nothing is known about the provider's connections, matching
      hasUsableCredentialsForModel's existing fail-open contract.
    • getVisionCapableModels() now runs this check after the existing
      credential filter.
    • cachedModelRemainsAvailable() now also re-checks the lockout, so the
      60s selection cache drops a pick that becomes locked mid-window instead
      of continuing to serve it.
    • VisionBridgeRouterDeps gained an injectable isModelLocked hook
      (defaulting to the real accountFallback.isModelLocked), following the
      same DI pattern already used for hasUsableCredentials/
      getActiveSyncedCatalog — node:test has no supported ESM
      module-mocking mechanism.

Regression tests

  • tests/unit/guardrails/visionBridge12111Repro.test.ts — the TDD repro
    from the analysis plan, reproducing the reporter's exact setup
    (lockModel("nvidia", "conn-nvidia-1", ..., "not_found", 120000) then
    asking getBestVisionModel() for a pick with only nvidia credentialed).
    The plan's original model id (moonshotai/kimi-k2.6) no longer exists in
    the nvidia registry (renamed to kimi-k3 since the analysis) — updated to
    the current id, which resolves the same way (first vision-capable nvidia
    model in registry order).

    RED (before the fix):

    ✖ getBestVisionModel must not select a model locked after a 404 (#12111)
      AssertionError: getBestVisionModel selected a model that accountFallback
      has locked after a 404 -- getVisionCapableModels() never consults
      isModelLocked (src/lib/guardrails/visionBridgeRouter.ts)
          actual: 'nvidia/moonshotai/kimi-k3',
          expected: 'nvidia/moonshotai/kimi-k3',
          operator: 'notStrictEqual'
    

    GREEN (after the fix): passes.

  • tests/unit/guardrails/visionBridgeRouter.test.ts — 3 new cases added
    alongside the existing 21 (all still pass):

    • excludes a model locked on its only usable connection
    • keeps a model locked on one connection while a second, unlocked
      connection on the same provider stays usable (the multi-connection
      risk called out in the analysis)
    • drops a cached selection once it becomes locked mid-window

Full suite: node --import tsx/esm --test tests/unit/guardrails/visionBridgeRouter.test.ts tests/unit/guardrails/visionBridge12111Repro.test.ts → 25/25 pass.

Gates run

  • node scripts/check/check-file-size.mjs — OK
  • node scripts/check/check-complexity.mjs — OK (2798 violations vs baseline 3218)
  • node scripts/check/check-cognitive-complexity.mjs — OK (1265 vs baseline 1437)
  • npm run typecheck:core — exit 0
  • npx eslint --suppressions-location config/quality/eslint-suppressions.json <changed files> — exit 0, no output
  • node scripts/check/check-changelog-integrity.mjs — OK
  • Full existing + new router test suite — 25/25 pass

⚠️ base-red inherited: #12732 — unit #12058, integration codex-cache, package-artifact, tarball-smoke, agent-skills-sync

@diegosouzapw
diegosouzapw merged commit 9982e37 into release/v3.8.51 Sep 12, 2026
15 of 21 checks passed
Githab-capibara added a commit to Githab-capibara/OmniRoute that referenced this pull request Sep 17, 2026
…after a 404 (diegosouzapw#12111) (diegosouzapw#13259)

Merged as part of the 39-PR owner batch of 2026-09-11, validated as a unit.

Boarded into one consolidated worktree cut from `release/v3.8.51` with the other 38 — zero conflicts between them.

- ESLint over every changed file: no errors (the only finding was one suppression entry the batch emptied, pruned on diegosouzapw#13243)
- `typecheck:core` clean; `check:dashboard-typecheck` OK (206 pre-existing, within baseline); `check:changelog-integrity` OK
- complexity 2821 / baseline 3218 and cognitive-complexity 1272 / baseline 1437 — both under baseline
- 256 assertions green: 246 under node:test and 10 under vitest, which is where `tests/unit/**/*.test.tsx` actually runs
- `check-file-size`: `chatCore.ts` rebaselined 6144 → 6146 for diegosouzapw#13278 and diegosouzapw#13276, annotated and landed on diegosouzapw#13243

⚠️ base-red inherited: diegosouzapw#12732 — the provider count (356 in the docs vs the 358 the modules define) and `open-sse/utils/stream.ts` at 3115 > frozen 3098 both reproduce on the pure tip with zero contribution from this batch.
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…after a 404 (diegosouzapw#12111) (diegosouzapw#13259)

Merged as part of the 39-PR owner batch of 2026-09-11, validated as a unit.

Boarded into one consolidated worktree cut from `release/v3.8.51` with the other 38 — zero conflicts between them.

- ESLint over every changed file: no errors (the only finding was one suppression entry the batch emptied, pruned on diegosouzapw#13243)
- `typecheck:core` clean; `check:dashboard-typecheck` OK (206 pre-existing, within baseline); `check:changelog-integrity` OK
- complexity 2821 / baseline 3218 and cognitive-complexity 1272 / baseline 1437 — both under baseline
- 256 assertions green: 246 under node:test and 10 under vitest, which is where `tests/unit/**/*.test.tsx` actually runs
- `check-file-size`: `chatCore.ts` rebaselined 6144 → 6146 for diegosouzapw#13278 and diegosouzapw#13276, annotated and landed on diegosouzapw#13243

⚠️ base-red inherited: diegosouzapw#12732 — the provider count (356 in the docs vs the 358 the modules define) and `open-sse/utils/stream.ts` at 3115 > frozen 3098 both reproduce on the pure tip with zero contribution from this batch.
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(backend): Vision Bridge auto-router can select a model already locked after a 404

1 participant