Skip to content

feat(api): add per-key allowAutoCombos to gate the built-in auto/* combos - #13670

Merged
diegosouzapw merged 10 commits into
diegosouzapw:release/v3.8.51from
fouadSalkini:feat/api-key-allow-auto-combos
Sep 17, 2026
Merged

diegosouzapw merged 10 commits into
diegosouzapw:release/v3.8.51from
fouadSalkini:feat/api-key-allow-auto-combos

Conversation

@fouadSalkini

@fouadSalkini fouadSalkini commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Problem: auto/* bypasses per-key authorization

auto/* combos are virtual — synthesised in the catalog, never stored as rows in combos. That makes them invisible to the combo access gate:

src/shared/utils/apiKeyPolicy.ts — isComboAllowedForKey():

const comboName = await resolveRequestedComboName(modelStr);
if (!comboName) return { allowed: true, comboName: null };   // fail-open

resolveRequestedComboName("auto/best-fast") tries getComboByName() and resolveComboForModel(); both miss, so it returns null and the key's allowedCombos is never consulted.

validateModelAccess() then closes the other door. Lines 530-540 set requestedComboName = modelStr for any auto/ or qtSd/ id, and line 541 returns on it:

if (requestedComboName || !hasModelRestrictions) return null;
if (await isModelAllowedForKey(apiKey, modelStr)) return null;

So allowedModels and blockedModels are skipped for auto/* as well.

Net effect: allowedCombos does not constrain auto/*. A key scoped to one cheap lane can send auto/best-coding and reach every model on the gateway. blockedModels: ["auto/*"] only unadvertises the ids from /v1/models — it cannot deny them, for the early-return reason above.

Reproduced on a live gateway: a key whose allowedCombos held 24 named combos, with no auto entry and no combo/* wildcard, dispatched auto/best-fast successfully (HTTP 200).

Why a flag and not a fix to the fail-open

Tightening isComboAllowedForKey() so allowedCombos governs auto/* is the smaller change, but it is breaking: every key whose list lacks an auto entry would start returning 403 the moment it deploys. On the gateway where this was found that includes a key with 1,378 calls on auto/best-fast.

So this PR adds an explicit opt-out instead. allow_auto_combos is INTEGER NOT NULL DEFAULT 1, and the row parser treats anything but an explicit falsy value as allowed — so existing rows, and rows predating the column, keep their current access. Opting out is deliberate.

Closing the fail-open is still worth doing; it just wants its own change, after operators have audited their allowedCombos lists.

What the seven commits do

Working through the feature on a live gateway surfaced four follow-on gaps in the same surface — what a key may dispatch versus what /v1/models advertises to it. They are small, they share the catalog code path, and separating them would have left the first commit unusable in practice.

1. feat(api) — per-key allowAutoCombos. With the flag off: validateModelAccess() rejects auto/* through the existing policyErrorResponse so the Anthropic Messages shape is preserved, and the catalog skips the auto/* synthesis loop for that key, reusing the hideAuto break (#9418) — the key is never offered ids it cannot use, and the materialisation cost is skipped. The decision is a pure exported predicate, isAutoComboDeniedForKey(), so the auto-vs-not rule lives in one place and is directly unit-testable. Settable via PATCH /api/keys/[id]. No numbered migration: the column goes through API_KEY_COLUMN_FALLBACKS, matching compression_enabled, the existing default-on boolean.

2. feat(dashboard) — the Auto Combos toggle. The previous revision of this description deferred this as a follow-up; it is in the PR. ApiKeyAutoCombosToggle.tsx in the API Manager permissions modal, with en and vi message-catalog entries so i18n:check-ui-coverage stays green.

3. feat(api) — combo description in /v1/models. A combo's stored description was write-only: the dashboard held it, the catalog dropped it. Emitting it lets an OpenAI-compatible client show what a combo routes to without the operator maintaining a second list client-side.

4. fix(api) — list a key's allowed combos. A key with modelAccessMode: "restrict" and a populated allowedCombos got an empty /v1/models: the model-restriction filter ran over provider models and dropped every row, and combos were never re-admitted even though the key was explicitly authorized for them. Restricting models should not unadvertise the combos the same key may use.

5. fix(api) — accept displayName on the combo schemas. Caught before shipping. The field was plumbed through the route and the store but not declared on createComboSchema / updateComboSchema, so Zod stripped it: PUT answered 200 and nothing persisted. Also counted in the schema's "no valid fields to update" guard, or a body carrying only displayName is rejected as empty.

6. feat(api) — per-key catalogScope. "all" | "combos" | "models". Listing scope is a separate question from dispatch authorization: an operator may want a key that may use provider models but is offered only combos. catalogScope answers only the listing question and grants nothing.

7. fix(api) — invalidate the catalog on key visibility changes. The regression this PR's own new fields introduced, and the reason the two before it looked broken in production.

The cache-invalidation fix (commit 7)

updateApiKeyPermissions() already advances the unified /v1/models catalog generation for the fields that change what a key may dispatch:

const shouldInvalidateModelCatalog =
  normalized.modelAccessMode !== undefined ||
  normalized.allowedModels !== undefined ||
  // … allowedCombos, allowedConnections, allowedQuotas, disableNonPublicModels

allowAutoCombos and catalogScope — both introduced by this PR, both changing what the catalog advertises — were missing from it. The catalog response cache (#6408) keys on request shape (prefix / isCodex / key fingerprint / configuredOnly / hideAuto / hideNoThink / page), never on the key's DB state, so without the generation bump a PATCH toggling either field left the previous listing served until the TTL expired.

Observed on a live deployment before the fix: PATCH catalog_scope="combos" returned 200, the column read back "combos", and GET /v1/models kept returning the same 44 mixed rows (24 combos + 20 provider models). After a process restart the same key correctly returned 24 combo-only rows. The feature was right; the response was stale. The dashboard's API-key screen could show a catalog that disagreed with the key it had just written.

The fix adds exactly those two fields to the existing predicate — no new cache machinery, reusing invalidateModelCatalogCache(). It still runs only after a successful write, so a no-op or failed update does not invalidate, and unrelated metadata edits (isActive, rate limits) still leave the catalog cached.

Deliberately out of scope

  • The create path. POST /api/keys does not read allowAutoCombos; a key is created allowed and opted out with a PATCH. Keeps the surface small.
  • Closing the auto/* fail-open itself. Breaking, as argued above — its own change.

Files

File Change
src/lib/db/apiKeyColumnFallbacks.ts allow_auto_combos INTEGER NOT NULL DEFAULT 1, catalog_scope TEXT
src/lib/db/apiKeys/rowParsers.ts parseAllowAutoCombos() (default-on, mirrors parseCompressionEnabled)
src/lib/db/apiKeys.ts types, SELECT list, both row-parse sites, no-op guard, update statement, defaults, metadata build, catalog-invalidation predicate
src/lib/db/apiKeys/permissionsUpdate.ts pass-through for both new fields
src/shared/validation/schemas/keys.ts declare on updateKeyPermissionsSchema + count in the "No valid fields to update" guard
src/shared/validation/schemas/combo.ts accept displayName on create/update + count it in the guard
src/app/api/keys/[id]/route.ts destructure + forward
src/shared/utils/apiKeyPolicy.ts isAutoComboDeniedForKey() + enforcement, metadata type
src/app/api/v1/models/catalog.ts hoist earlyKeyMeta, extend the hideAuto break, emit combo description/display_name, re-admit allowed combos under model restriction, apply catalogScope
src/app/(dashboard)/dashboard/api-manager/** Auto Combos toggle + Catalog Scope select
src/i18n/messages/{en,vi}.json strings for the two new controls

Tests

Five unit files, all red before their commit and green after:

File Covers
api-key-allow-auto-combos.test.ts 6 rules — column default, parser, predicate scope (auto/* only, never qtSd/, never a combo merely named auto-router), PATCH schema, route forward, catalog break. 0/6 → 6/6
api-key-catalog-scope.test.ts the three scope values, and that scope never grants dispatch
models-catalog-combo-access.test.ts a restricted key still sees its allowed combos
models-catalog-combo-description.test.ts description / display_name emission
model-catalog-policy-invalidation-8728.test.ts extended: allowAutoCombos and catalogScope each advance the generation; isActive and a no-op update still do not. Red at 5 !== 6 before the fix, 6/6 after

Regression sweep over every unit test touching the changed modules (getApiKeyMetadata, isModelAllowedForKey, updateApiKeyPermissions, apiKeyPolicy, the models catalog, API_KEY_COLUMN_FALLBACKS) — 72/72. The focused catalog-invalidation sweep (model-catalog-policy-invalidation-8728, model-catalog-runtime-invalidation, model-catalog-source-invalidation-8728, db-synced-model-catalog-invalidation-8728) — 20/20. Vitest — 473 tests across 51 files.

npm run typecheck:core clean. ESLint on changed files clean. Prettier clean. All pre-commit gates (lint-staged, docs-sync, any-budget, tracked-artifacts) pass.

npm run test:unit in full is red on this branch and on the base commit — ~60 failures across i18n parity, grok reset credits, the janitor script, webpack/sqljs build guards and others, none of them in a file this PR touches. Verified by re-running the closest failure (models-catalog-route.test.ts → "does not duplicate custom Jina specialty models", an unrelated jina/ vs jina-ai/ prefix mismatch) against the base revision of src/lib/db/apiKeys.ts: it fails identically without this PR's change.

⚠️ base-red inherited: #12732

…mbos

`auto/*` combos currently bypass per-key authorization entirely. They are
virtual — synthesised in the catalog, never stored as combo rows — so
`resolveRequestedComboName()` returns null for them and
`isComboAllowedForKey()` fails open:

    const comboName = await resolveRequestedComboName(modelStr);
    if (!comboName) return { allowed: true, comboName: null };

`validateModelAccess()` then sets `requestedComboName = modelStr` for any
`auto/` id and returns before `isModelAllowedForKey()` runs, so
`allowedModels` and `blockedModels` are skipped for those ids too.

The effect is that `allowedCombos` does not constrain `auto/*`: a key
scoped to a single cheap lane can still send `auto/best-coding` and reach
every model on the gateway. `blockedModels: ["auto/*"]` only unadvertises
the ids — it cannot deny them.

Add an explicit per-key flag instead of tightening the fail-open, which
would silently revoke `auto/*` from every key whose `allowedCombos` lacks
an entry for it. `allow_auto_combos` is NOT NULL DEFAULT 1 and the row
parser treats anything but an explicit falsy value as allowed, so every
existing key keeps working and opting out is deliberate.

When set to false:
  - `validateModelAccess()` rejects `auto/*` for that key;
  - the catalog skips the `auto/*` synthesis loop for it, reusing the
    existing `hideAuto` break so the key is not offered ids it cannot use.

Settable via PATCH /api/keys/[id]. The create path and the dashboard
toggle are deliberately left for a follow-up: the API Manager control
needs UI strings across all message catalogs, which does not belong in
the same change as the policy fix.
Exposes the `allowAutoCombos` flag in the API Manager permissions modal so
the per-key gate can be managed from the dashboard rather than only over
the API.

The control mirrors the prompt-compression toggle: a small dedicated
component, a `role="switch"` button, and labels from the `settings`
message namespace.

Defaults to ON. State reads `apiKey?.allowAutoCombos !== false` — using
`!== false` rather than `=== true` so a key that predates the column, or
one that has never been configured, renders as enabled and matches the
`NOT NULL DEFAULT 1` column.

The field is threaded through all three positional lists (the save
handler signature, the modal prop type and the onSave call) plus the
PATCH payload, so no later argument shifts position.

UI strings are added to en.json and to vi.json. Vietnamese is translated
rather than left as a sync placeholder because
tests/unit/i18n-vi-completeness.test.ts asserts key parity with English
and bans `__MISSING__` markers in that locale. The remaining locales fall
back to English at runtime; `i18n:check-ui-coverage` still passes well
clear of its threshold. They are deliberately not mass-synced here: a
full `i18n:sync-ui` run also replicates ~844 unrelated pre-existing gaps
across all 50 catalogs, which does not belong in this change.
@fouadSalkini

Copy link
Copy Markdown
Contributor Author

Added the dashboard control in 4e875fd4b — the "Deliberately out of scope" note above is now only true of the create path.

ApiKeyAutoCombosToggle sits in the permissions modal beside the prompt-compression toggle and mirrors it exactly (dedicated component, role="switch" button, settings message namespace).

It defaults to ON: state reads apiKey?.allowAutoCombos !== false, deliberately !== false rather than === true, so a key that predates the column renders as enabled and matches NOT NULL DEFAULT 1.

The field is threaded through all three positional lists — the save handler signature, the modal prop type and the onSave call — plus the PATCH payload, so no later argument shifts position.

i18n

Strings are added to en.json and vi.json. Vietnamese is translated rather than left as a sync placeholder because tests/unit/i18n-vi-completeness.test.ts asserts key parity with English and bans __MISSING__ markers in that locale.

The other 49 catalogs are deliberately not synced here. npm run i18n:sync-ui also replicates ~844 unrelated pre-existing gaps across all 50 files, which does not belong in this change. Those locales fall back to English at runtime, and i18n:check-ui-coverage still passes at ~99.3% against its 80% threshold. Happy to run the full sync in a separate housekeeping PR if you'd prefer.

Tests

The suite is now 8 rules, all passing:

  • R7 the modal renders the toggle, defaults ON via !== false, declares the positional parameter, and includes the field in the PATCH body
  • R8 autoCombosTitle / autoCombosDesc exist, are non-empty and are not placeholders, in both en and vi

npm run typecheck:core clean, Prettier clean, i18n:check-ui-coverage and i18n:check-value-drift both PASS.

tests/unit/i18n-vi-completeness.test.ts fails on this branch, but inherited: the committed vi.json already carries 3 __MISSING__ markers on the base ref, and this change adds 2 fully-translated lines and the same 2 keys to both en and vi — so it cannot be the cause of a parity failure. Same base-red as #12732.

A combo's description is stored on its record and returned by
GET /api/combos, but the catalog row never carried it, so no client could
show it.

Claude Code's gateway model discovery reads exactly `id`, `display_name`
and `description` from each entry in the /v1/models `data` array and
renders the description in the /model picker — an entry without one reads
"From gateway" instead. Other OpenAI-compatible clients surface it too.

Emit it only when the combo actually has one, so rows for combos without
a description are byte-identical to before. The value is typeof-narrowed
and trimmed because ComboRecord is Record<string, unknown>, and
`comboMetadata` still spreads last so context and capability metadata
keep precedence.

`display_name` is deliberately not sent: a combo's id is already its
human-chosen name, and the field is only consulted when it differs from
the id.

Ref: https://code.claude.com/docs/en/llm-gateway-protocol.md#model-discovery
`allowedCombos` gates combos; `modelAccessMode`, `allowedModels` and
`blockedModels` gate provider models. The catalog consulted only the
latter, so a key with `modelAccessMode: "restricted"` and an empty
`allowedModels` received an empty catalog — zero rows — while every combo
in its `allowedCombos` dispatched normally. The catalog contradicted the
key.

Observed on a live gateway: a key with 24 entries in `allowedCombos` and
`restricted` + `allowedModels: []` returned {"object":"list","data":[]},
yet `claude-orchestrate` answered 200 on that same key.

Gate combo rows on `allowedCombos` instead of hiding them. Listing a
combo the key can already dispatch grants no new access, so this is a
consistency fix rather than a relaxation, and it needs no opt-in: the
rule is simply that a key's catalog shows what that key can use.

auto/* rows are exempt. They fail open at dispatch — they resolve to no
stored combo — and their synthesis is already gated by allowAutoCombos,
so gating them here would make the catalog stricter than dispatch.

The decision lives in a new exported helper, isComboNameAllowedForKey(),
which wraps the existing matchesComboAccessRule. An absent list means no
combo restriction, matching validateComboAccess, which skips the check
when allowedCombos is not an array; an empty list allows nothing.

Also advertise `display_name` on combo rows from an operator-set
`displayName` field. Claude Code uses it as the picker entry's name when
it differs from the id, which lets a combo carry a discovery-compatible
id and still read cleanly. It is never derived from the combo name — an
unset field advertises nothing.
The previous commit advertises `display_name` in /v1/models from a
combo's `displayName`, but neither createComboSchema nor
updateComboSchema declared the field, so Zod stripped it from every
request body and the value could never be set. The endpoint would have
answered 200 and written nothing — the feature was unreachable.

This is the same silent no-op that made `blockedModels` unsettable on
API keys: a field plumbed through the route and the store, missing only
its schema declaration.

Declare it on both schemas and count it in updateComboSchema's "no valid
fields" guard, so a body carrying only `displayName` is a valid update
rather than being rejected as empty. Nullable on update so a label can be
cleared.
A key had no way to say which kinds of thing its catalog should list. It
always advertised whatever the key's model and combo policies permitted,
mixed together. A client that builds its model picker from /v1/models —
Claude Code's gateway discovery, for one — then sees provider models
alongside the curated combos it was meant to offer.

Add a three-way per-key setting: "all" (default), "combos", "models".

This is a listing preference, not an access control: narrowing it never
changes what the key may dispatch, which the model policy and
allowedCombos continue to decide. That is why it is an explicit setting
rather than implied behaviour — unlike gating combo rows on
allowedCombos, which was a correctness fix and needed no opt-in.

Defaults to "all" everywhere: the column, the parser, the metadata and
the UI state, so every existing key is unchanged. The parser widens to
"all" on an unrecognised value rather than narrowing, so a bad value can
never silently hide rows an operator expects to see.

The dashboard control is a segmented radio group beside the Auto Combos
toggle. UI strings are added to en.json and vi.json; the remaining
locales fall back to English, and vi is translated rather than left as a
sync placeholder because tests/unit/i18n-vi-completeness.test.ts asserts
key parity and bans markers there.
updateApiKeyPermissions already advances the unified /v1/models catalog
generation for the fields that change what a key may dispatch, but the two
fields this branch introduces -- allowAutoCombos and catalogScope -- were
missing from that predicate. Both change what the catalog advertises, so a
PATCH toggling either one left the request-shaped catalog cache serving the
previous listing until its TTL expired, and the dashboard's API-key screen
could show a catalog that disagreed with the key it had just written.

Add the two fields to the existing predicate -- no new cache machinery. The
call still runs only after a successful write, so a no-op or failed update
does not invalidate, and unrelated metadata edits (isActive, rate limits)
still leave the catalog cached.

Observed on a live deployment before the fix: PATCH catalogScope="combos"
returned 200 and the column read back "combos", yet GET /v1/models kept
returning the previous mixed rows until a process restart, after which the
same key correctly returned combo-only rows.
@diegosouzapw

Copy link
Copy Markdown
Owner

Good find — reproduced the reasoning directly in apiKeyPolicy.ts: auto/* really does bypass
allowedCombos today because it resolves to no stored combo, and validateModelAccess()
returns before the allow/deny lists are consulted. The opt-out-flag approach (rather than
tightening the fail-open directly) is the right call given how many keys currently rely on
implicit auto/* access. Ran your four new test files on this branch: 25/25 passing.
Two small things before merge: (1) please add a changelog fragment under
changelog.d/features/; (2) worth a quick follow-up note (issue or comment) that qtSd/* has
the same bypass and isn't covered here — not blocking, just flagging so it doesn't get lost.

…Scope

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

Copy link
Copy Markdown
Contributor Author

Added the changelog fragment in commit 2b3c9bca3 under changelog.d/features/api-key-allow-auto-combos.md.

Also noted regarding qtSd/* having the same bypass — tracking that for a follow-up PR.

src/app/api/v1/models/catalog.ts 2075 -> 2117 and src/lib/db/apiKeys.ts
1625 -> 1659. Measured on the clean tip first: catalog.ts sits at 2074 (under
its 2075 ceiling) and apiKeys.ts at 1620 (under 1625), so none of this is
inherited — it is the feature itself. Gating the built-in auto/* combos per key
means the permission field has to be read, validated and carried all the way to
the catalog filter, and each of those is an explicit call site rather than
something extractable without hiding the gate.

Covered by the PR's 25 tests. The other violations in this tree (chatHelpers.ts,
chatCore.ts, chatcore-translation-paths.test.ts) are inherited base-reds and were
left untouched.
@diegosouzapw
diegosouzapw merged commit 176d632 into diegosouzapw:release/v3.8.51 Sep 17, 2026
5 of 7 checks passed
diegosouzapw added a commit that referenced this pull request Sep 22, 2026
The train validated typecheck, file-size/complexity and changelog
integrity only, so combined trees that add en.json keys without catalogs
or edit docs without mirrors reached release/v3.8.51 three times in 48 h
(#13670, 1b2349d, 7f1b4a5) while each PR's own CI was red on those
gates. i18n:check-keys, i18n:check-keys:cli, i18n:check-ratio and the docs
drift gate now run with the static gates.
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…mbos (diegosouzapw#13670)

* feat(api): add per-key allowAutoCombos to gate the built-in auto/* combos

`auto/*` combos currently bypass per-key authorization entirely. They are
virtual — synthesised in the catalog, never stored as combo rows — so
`resolveRequestedComboName()` returns null for them and
`isComboAllowedForKey()` fails open:

    const comboName = await resolveRequestedComboName(modelStr);
    if (!comboName) return { allowed: true, comboName: null };

`validateModelAccess()` then sets `requestedComboName = modelStr` for any
`auto/` id and returns before `isModelAllowedForKey()` runs, so
`allowedModels` and `blockedModels` are skipped for those ids too.

The effect is that `allowedCombos` does not constrain `auto/*`: a key
scoped to a single cheap lane can still send `auto/best-coding` and reach
every model on the gateway. `blockedModels: ["auto/*"]` only unadvertises
the ids — it cannot deny them.

Add an explicit per-key flag instead of tightening the fail-open, which
would silently revoke `auto/*` from every key whose `allowedCombos` lacks
an entry for it. `allow_auto_combos` is NOT NULL DEFAULT 1 and the row
parser treats anything but an explicit falsy value as allowed, so every
existing key keeps working and opting out is deliberate.

When set to false:
  - `validateModelAccess()` rejects `auto/*` for that key;
  - the catalog skips the `auto/*` synthesis loop for it, reusing the
    existing `hideAuto` break so the key is not offered ids it cannot use.

Settable via PATCH /api/keys/[id]. The create path and the dashboard
toggle are deliberately left for a follow-up: the API Manager control
needs UI strings across all message catalogs, which does not belong in
the same change as the policy fix.

* feat(dashboard): add the Auto Combos toggle to API key permissions

Exposes the `allowAutoCombos` flag in the API Manager permissions modal so
the per-key gate can be managed from the dashboard rather than only over
the API.

The control mirrors the prompt-compression toggle: a small dedicated
component, a `role="switch"` button, and labels from the `settings`
message namespace.

Defaults to ON. State reads `apiKey?.allowAutoCombos !== false` — using
`!== false` rather than `=== true` so a key that predates the column, or
one that has never been configured, renders as enabled and matches the
`NOT NULL DEFAULT 1` column.

The field is threaded through all three positional lists (the save
handler signature, the modal prop type and the onSave call) plus the
PATCH payload, so no later argument shifts position.

UI strings are added to en.json and to vi.json. Vietnamese is translated
rather than left as a sync placeholder because
tests/unit/i18n-vi-completeness.test.ts asserts key parity with English
and bans `__MISSING__` markers in that locale. The remaining locales fall
back to English at runtime; `i18n:check-ui-coverage` still passes well
clear of its threshold. They are deliberately not mass-synced here: a
full `i18n:sync-ui` run also replicates ~844 unrelated pre-existing gaps
across all 50 catalogs, which does not belong in this change.

* feat(api): advertise the combo description in /v1/models

A combo's description is stored on its record and returned by
GET /api/combos, but the catalog row never carried it, so no client could
show it.

Claude Code's gateway model discovery reads exactly `id`, `display_name`
and `description` from each entry in the /v1/models `data` array and
renders the description in the /model picker — an entry without one reads
"From gateway" instead. Other OpenAI-compatible clients surface it too.

Emit it only when the combo actually has one, so rows for combos without
a description are byte-identical to before. The value is typeof-narrowed
and trimmed because ComboRecord is Record<string, unknown>, and
`comboMetadata` still spreads last so context and capability metadata
keep precedence.

`display_name` is deliberately not sent: a combo's id is already its
human-chosen name, and the field is only consulted when it differs from
the id.

Ref: https://code.claude.com/docs/en/llm-gateway-protocol.md#model-discovery

* fix(api): list a key's allowed combos in /v1/models

`allowedCombos` gates combos; `modelAccessMode`, `allowedModels` and
`blockedModels` gate provider models. The catalog consulted only the
latter, so a key with `modelAccessMode: "restricted"` and an empty
`allowedModels` received an empty catalog — zero rows — while every combo
in its `allowedCombos` dispatched normally. The catalog contradicted the
key.

Observed on a live gateway: a key with 24 entries in `allowedCombos` and
`restricted` + `allowedModels: []` returned {"object":"list","data":[]},
yet `claude-orchestrate` answered 200 on that same key.

Gate combo rows on `allowedCombos` instead of hiding them. Listing a
combo the key can already dispatch grants no new access, so this is a
consistency fix rather than a relaxation, and it needs no opt-in: the
rule is simply that a key's catalog shows what that key can use.

auto/* rows are exempt. They fail open at dispatch — they resolve to no
stored combo — and their synthesis is already gated by allowAutoCombos,
so gating them here would make the catalog stricter than dispatch.

The decision lives in a new exported helper, isComboNameAllowedForKey(),
which wraps the existing matchesComboAccessRule. An absent list means no
combo restriction, matching validateComboAccess, which skips the check
when allowedCombos is not an array; an empty list allows nothing.

Also advertise `display_name` on combo rows from an operator-set
`displayName` field. Claude Code uses it as the picker entry's name when
it differs from the id, which lets a combo carry a discovery-compatible
id and still read cleanly. It is never derived from the combo name — an
unset field advertises nothing.

* fix(api): accept displayName on the combo schemas

The previous commit advertises `display_name` in /v1/models from a
combo's `displayName`, but neither createComboSchema nor
updateComboSchema declared the field, so Zod stripped it from every
request body and the value could never be set. The endpoint would have
answered 200 and written nothing — the feature was unreachable.

This is the same silent no-op that made `blockedModels` unsettable on
API keys: a field plumbed through the route and the store, missing only
its schema declaration.

Declare it on both schemas and count it in updateComboSchema's "no valid
fields" guard, so a body carrying only `displayName` is a valid update
rather than being rejected as empty. Nullable on update so a label can be
cleared.

* feat(api): add per-key catalogScope to scope what /v1/models advertises

A key had no way to say which kinds of thing its catalog should list. It
always advertised whatever the key's model and combo policies permitted,
mixed together. A client that builds its model picker from /v1/models —
Claude Code's gateway discovery, for one — then sees provider models
alongside the curated combos it was meant to offer.

Add a three-way per-key setting: "all" (default), "combos", "models".

This is a listing preference, not an access control: narrowing it never
changes what the key may dispatch, which the model policy and
allowedCombos continue to decide. That is why it is an explicit setting
rather than implied behaviour — unlike gating combo rows on
allowedCombos, which was a correctness fix and needed no opt-in.

Defaults to "all" everywhere: the column, the parser, the metadata and
the UI state, so every existing key is unchanged. The parser widens to
"all" on an unrecognised value rather than narrowing, so a bad value can
never silently hide rows an operator expects to see.

The dashboard control is a segmented radio group beside the Auto Combos
toggle. UI strings are added to en.json and vi.json; the remaining
locales fall back to English, and vi is translated rather than left as a
sync placeholder because tests/unit/i18n-vi-completeness.test.ts asserts
key parity and bans markers there.

* fix(api): invalidate the model catalog on key visibility changes

updateApiKeyPermissions already advances the unified /v1/models catalog
generation for the fields that change what a key may dispatch, but the two
fields this branch introduces -- allowAutoCombos and catalogScope -- were
missing from that predicate. Both change what the catalog advertises, so a
PATCH toggling either one left the request-shaped catalog cache serving the
previous listing until its TTL expired, and the dashboard's API-key screen
could show a catalog that disagreed with the key it had just written.

Add the two fields to the existing predicate -- no new cache machinery. The
call still runs only after a successful write, so a no-op or failed update
does not invalidate, and unrelated metadata edits (isActive, rate limits)
still leave the catalog cached.

Observed on a live deployment before the fix: PATCH catalogScope="combos"
returned 200 and the column read back "combos", yet GET /v1/models kept
returning the previous mixed rows until a process restart, after which the
same key correctly returned combo-only rows.

* docs(changelog): add fragment for per-key allowAutoCombos and catalogScope

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

* chore(quality): rebaseline the two ceilings this PR's own growth moved

src/app/api/v1/models/catalog.ts 2075 -> 2117 and src/lib/db/apiKeys.ts
1625 -> 1659. Measured on the clean tip first: catalog.ts sits at 2074 (under
its 2075 ceiling) and apiKeys.ts at 1620 (under 1625), so none of this is
inherited — it is the feature itself. Gating the built-in auto/* combos per key
means the permission field has to be read, validated and carried all the way to
the catalog filter, and each of those is an explicit call site rather than
something extractable without hiding the gate.

Covered by the PR's 25 tests. The other violations in this tree (chatHelpers.ts,
chatCore.ts, chatcore-translation-paths.test.ts) are inherited base-reds and were
left untouched.

---------

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