Skip to content

fix(api): list registry-only Claude models (opus-5/sonnet-5) in /v1/models when a sync omits them - #9169

Closed
zoispag wants to merge 1 commit into
cheaper-inference:release/v3.8.50from
zoispag:fix/9167-registry-only-models-survive-sync
Closed

zoispag wants to merge 1 commit into
cheaper-inference:release/v3.8.50from
zoispag:fix/9167-registry-only-models-survive-sync

Conversation

@zoispag

@zoispag zoispag commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

GET /v1/models silently dropped built-in registry models claude-opus-5 and claude-sonnet-5 for the claude/cc provider, even though they are fully defined in the Claude registry, modelSpecs, and pricing — while sibling models like claude-opus-4-8 were served.

Root cause is in the /v1/models catalog builder (src/app/api/v1/models/catalog.ts). When a non-curated provider has synced ≥1 live model, the static-registry loop skipped every static entry for that provider (keeping only effort variants), on the assumption that the synced list fully replaces the static registry:

if (
  providersWithSyncedModels.has(canonicalProviderId) &&
  !isRegisteredEffortVariant(providerModels, model.id)
)
  continue;

But the synced list is the provider's live /v1/models, which lags OmniRoute's registry for brand-new models. So a model that exists in the registry but not yet in the upstream live list (Opus 5 / Sonnet 5) was dropped wholesale. claude-opus-4-8 survived only because the sync happens to return it.

This PR changes the skip so a static entry is dropped only when the synced set actually carries that same id (i.e. only when it is genuinely being replaced). Registry-only models then survive; their reasoning-effort variants follow automatically because appendClaudeEffortVariants derives them from the surviving base entry.

The decision is extracted into a pure, unit-tested helper shouldSkipStaticForSynced, and the synced-id index is keyed by the same canonicalProviderId the static loop reads, so the lookup and the existing providersWithSyncedModels.has(canonicalProviderId) check always agree. providersWithSyncedModels is left untouched (the OpenRouter block depends on its semantics).

Reproduction (live, v3.8.49–v3.8.50)

  • omniroute models / dashboard list "Claude Opus 5" and "Claude Sonnet 5".
  • GET /v1/models omits cc/claude-opus-5, claude/claude-opus-5, cc/claude-sonnet-5, claude/claude-sonnet-5 while serving cc/claude-opus-4-8, cc/claude-sonnet-4-6, etc.
  • A custom claude-opus-5[1m] on the same connection is served — proving the connection can route Opus 5 and isolating the bug to the static-vs-synced catalog path (not entitlement/routing).

Related Issues

Validation

  • Change type: provider / routing (catalog build for GET /v1/models)
  • Focused tests: tests/unit/catalog-registry-only-survives-sync-9167.test.ts (4 pure-helper cases + 1 end-to-end getUnifiedModelsResponse assertion) — all pass; the e2e assertion fails on the pre-fix condition (verified by reverting the guard) and passes with the fix.
  • No regressions in adjacent suites: noauth-imported-models-3200, claude-web-sonnet5-registry-6209, synced-model-hide-persist-3782, catalog-helpers-extraction, 8327-models-owned-by-prefix (23/23 pass).
  • npm run lint — ESLint with the repo suppressions baseline is clean on the changed files (the single no-restricted-imports note on catalog.ts line 3 is pre-existing and already in the baseline). Prettier clean on all changed files.
  • Reconciled with the current active release base (release/v3.8.50, HEAD ec150a0).
  • Production-code change includes new automated tests in this PR.

Commands run:

# focused tests
node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts \
  --test --test-force-exit tests/unit/catalog-registry-only-survives-sync-9167.test.ts   # 5/5 pass

# no-regression sweep of adjacent catalog/synced tests
node ... --test tests/unit/noauth-imported-models-3200.test.ts \
  tests/unit/claude-web-sonnet5-registry-6209.test.ts \
  tests/unit/synced-model-hide-persist-3782.test.ts \
  tests/unit/catalog-helpers-extraction.test.ts \
  tests/unit/8327-models-owned-by-prefix.test.ts                                          # 23/23 pass

# lint (with suppressions baseline) + prettier — clean on changed files
eslint --suppressions-location config/quality/eslint-suppressions.json <changed files>   # exit 0
prettier --check <changed files>                                                          # all clean

Tests Added Or Updated

  • Added tests/unit/catalog-registry-only-survives-sync-9167.test.ts:
    • unit: registry-only model survives when the synced set lacks it (the bug);
    • unit: synced-and-static model is still skipped (no duplicate, synced wins — original intent preserved);
    • unit: provider that synced nothing keeps all static entries;
    • unit: effort variant of a registry-only model is never skipped;
    • e2e: seeds an active claude OAuth connection + a synced set containing claude-opus-4-8 but not claude-opus-5, then asserts GET /v1/models includes claude/claude-opus-5 and claude/claude-sonnet-5, still includes the synced claude/claude-opus-4-8, and does not duplicate it.

Coverage Notes

  • Touches src/ (catalog builder). The new pure helper shouldSkipStaticForSynced is fully branch-covered by the four unit cases; the modified skip site is exercised by the end-to-end test through getUnifiedModelsResponse.

Reviewer Notes

  • Highest-risk detail: the synced-id index is keyed by canonicalProviderId (resolved with the same resolveCanonicalProviderId(alias, providerId) the synced loop uses) so it agrees with the providersWithSyncedModels.has(canonicalProviderId) check. For claude raw id === canonical; the canonical keying is what keeps this correct for compatible providers with UUID raw ids.
  • providersWithSyncedModels semantics are unchanged (the OpenRouter block at !providersWithSyncedModels.has("openrouter") relies on them).
  • No duplicates introduced: a model in both registry and synced set is still emitted once (by the synced loop); a registry-only model is emitted by the registry loop; a synced-only model by the synced loop. dedupeExactCatalogIds remains as an independent backstop but the logic does not rely on it.
  • Depends on modelSpecs entries for claude-opus-5 / claude-sonnet-5 (both present with supportsThinking: true) so their effort variants and enrichment work — verified in src/shared/constants/modelSpecs.ts.

…odels when a sync omits them

The catalog builder skipped every static registry model for any non-curated
provider that had synced >=1 live model, keeping only effort variants. That
silently dropped brand-new registry models the upstream provider's live
/v1/models does not advertise yet.

cheaper-inference#9167: claude-opus-5 / claude-sonnet-5 are defined in the Claude registry,
modelSpecs and pricing, and the connection can route them (a custom
claude-opus-5[1m] on the same connection was served), but they were missing
from GET /v1/models while claude-opus-4-8 (which the sync returns) survived.

Skip a static entry only when the synced set actually carries that id, keyed
by the same canonicalProviderId the static loop reads. Extracted the decision
into shouldSkipStaticForSynced (pure, unit-tested) and added a regression test
covering the unit cases plus an end-to-end /v1/models assertion.
@zoispag

zoispag commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

CI failures are pre-existing on the base, not from this PR

I reproduced every red check against a pristine release/v3.8.50 worktree (ec150a0) with none of this PR's changes applied, and the same failures occur.

Unit Tests fast-path (2/4, 3/4, 4/4)

The failing tests are all in the Claude-Code-headers / executor / Codex-discovery / golden-snapshot area — none touch the model catalog. Run on the unmodified base:

✖ Anthropic billing header fingerprint (#1638)
✖ uses the immutable build revision captured from the signed CLI
✖ provider models route merges live Codex models with the local catalog then filters denylist
✖ GOLDEN provider.ts translate-path is stable across all providers
ℹ tests 11 | pass 8 | fail 3

Files: tests/unit/anthropic-cache-fingerprint.test.ts, tests/unit/provider-models-route-codex.test.ts, tests/unit/provider-translate-path-golden.test.ts — identical failures with and without this PR.

Fast Quality Gates

The file-size baseline gate flags two files that are not in this PR's diff:

✗ open-sse/handlers/chatCore.ts: 5028 > frozen 5020
✗ tests/unit/chatcore-translation-paths.test.ts: 2776 > frozen 2769

This PR's complete diff is only:

src/app/api/v1/models/catalog.ts
src/app/api/v1/models/syncedStaticEligibility.ts
tests/unit/catalog-registry-only-survives-sync-9167.test.ts

so the frozen-size drift in chatCore.ts / chatcore-translation-paths.test.ts predates it and needs a baseline refresh on the release branch.

This PR's own tests

tests/unit/catalog-registry-only-survives-sync-9167.test.ts
ℹ tests 5 | pass 5 | fail 0

Also green: No new ESLint warnings, Vitest (fast-path), Docs Gates, Merge integrity, semgrep, dast-smoke, Change Classification.

Happy to rebase once the base-branch failures and the file-size baseline are sorted, or to fold a baseline bump into this PR if you'd prefer — just say which you want.

@zoispag

zoispag commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #9217 — closing.

#9217 (feat(models): functional gateway mirrors + fix synced-substitution) landed the same fix on release/v3.8.50, and it arrived at essentially the identical design: catalogSyncedCoverage.ts suppresses a static registry model only when the synced list actually covers that id, using a Map<canonicalProviderId, Set<syncedModelId>> — including the same "keyed by canonical provider id because the static loop addresses providers that way" reasoning. It also ships its own regression test (catalog-synced-static-preservation.test.ts).

I verified #9217 resolves the #9167 case rather than assuming it: I ran this PR's end-to-end assertion (seed an active claude OAuth connection + a synced set containing claude-opus-4-8 but not claude-opus-5, then call getUnifiedModelsResponse) against 84b1e5e without any of this PR's changes:

✔ #9167 end-to-end: GET /v1/models lists a registry-only model the sync omits
ℹ tests 1 | pass 1 | fail 0

So claude/claude-opus-5 and claude/claude-sonnet-5 are now listed on the current release branch. Rebasing this PR would only re-implement merged behavior, so closing as redundant.

Thanks for the quick turnaround on #9217.

@zoispag zoispag closed this Aug 3, 2026
@zoispag
zoispag deleted the fix/9167-registry-only-models-survive-sync branch August 3, 2026 09:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant