feat(api): add per-key allowAutoCombos to gate the built-in auto/* combos - #13670
diegosouzapw merged 10 commits into
Conversation
…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.
|
Added the dashboard control in
It defaults to ON: state reads The field is threaded through all three positional lists — the save handler signature, the modal prop type and the i18nStrings are added to The other 49 catalogs are deliberately not synced here. TestsThe suite is now 8 rules, all passing:
|
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.
|
Good find — reproduced the reasoning directly in |
…Scope Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
|
Added the changelog fragment in commit Also noted regarding |
# Conflicts: # src/i18n/messages/vi.json
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.
176d632
into
diegosouzapw:release/v3.8.51
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.
…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>
Problem:
auto/*bypasses per-key authorizationauto/*combos are virtual — synthesised in the catalog, never stored as rows incombos. That makes them invisible to the combo access gate:src/shared/utils/apiKeyPolicy.ts—isComboAllowedForKey():resolveRequestedComboName("auto/best-fast")triesgetComboByName()andresolveComboForModel(); both miss, so it returnsnulland the key'sallowedCombosis never consulted.validateModelAccess()then closes the other door. Lines 530-540 setrequestedComboName = modelStrfor anyauto/orqtSd/id, and line 541 returns on it:So
allowedModelsandblockedModelsare skipped forauto/*as well.Net effect:
allowedCombosdoes not constrainauto/*. A key scoped to one cheap lane can sendauto/best-codingand 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
allowedCombosheld 24 named combos, with noautoentry and nocombo/*wildcard, dispatchedauto/best-fastsuccessfully (HTTP 200).Why a flag and not a fix to the fail-open
Tightening
isComboAllowedForKey()soallowedCombosgovernsauto/*is the smaller change, but it is breaking: every key whose list lacks anautoentry would start returning 403 the moment it deploys. On the gateway where this was found that includes a key with 1,378 calls onauto/best-fast.So this PR adds an explicit opt-out instead.
allow_auto_combosisINTEGER 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
allowedComboslists.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/modelsadvertises 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-keyallowAutoCombos. With the flag off:validateModelAccess()rejectsauto/*through the existingpolicyErrorResponseso the Anthropic Messages shape is preserved, and the catalog skips theauto/*synthesis loop for that key, reusing thehideAutobreak (#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 viaPATCH /api/keys/[id]. No numbered migration: the column goes throughAPI_KEY_COLUMN_FALLBACKS, matchingcompression_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.tsxin the API Manager permissions modal, withenandvimessage-catalog entries soi18n:check-ui-coveragestays green.3.
feat(api)— combodescriptionin/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 withmodelAccessMode: "restrict"and a populatedallowedCombosgot 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)— acceptdisplayNameon the combo schemas. Caught before shipping. The field was plumbed through the route and the store but not declared oncreateComboSchema/updateComboSchema, so Zod stripped it:PUTanswered 200 and nothing persisted. Also counted in the schema's "no valid fields to update" guard, or a body carrying onlydisplayNameis rejected as empty.6.
feat(api)— per-keycatalogScope."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.catalogScopeanswers 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/modelscatalog generation for the fields that change what a key may dispatch:allowAutoCombosandcatalogScope— 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", andGET /v1/modelskept 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
POST /api/keysdoes not readallowAutoCombos; a key is created allowed and opted out with a PATCH. Keeps the surface small.auto/*fail-open itself. Breaking, as argued above — its own change.Files
src/lib/db/apiKeyColumnFallbacks.tsallow_auto_combos INTEGER NOT NULL DEFAULT 1,catalog_scope TEXTsrc/lib/db/apiKeys/rowParsers.tsparseAllowAutoCombos()(default-on, mirrorsparseCompressionEnabled)src/lib/db/apiKeys.tssrc/lib/db/apiKeys/permissionsUpdate.tssrc/shared/validation/schemas/keys.tsupdateKeyPermissionsSchema+ count in the "No valid fields to update" guardsrc/shared/validation/schemas/combo.tsdisplayNameon create/update + count it in the guardsrc/app/api/keys/[id]/route.tssrc/shared/utils/apiKeyPolicy.tsisAutoComboDeniedForKey()+ enforcement, metadata typesrc/app/api/v1/models/catalog.tsearlyKeyMeta, extend thehideAutobreak, emit combodescription/display_name, re-admit allowed combos under model restriction, applycatalogScopesrc/app/(dashboard)/dashboard/api-manager/**src/i18n/messages/{en,vi}.jsonTests
Five unit files, all red before their commit and green after:
api-key-allow-auto-combos.test.tsauto/*only, neverqtSd/, never a combo merely namedauto-router), PATCH schema, route forward, catalog break. 0/6 → 6/6api-key-catalog-scope.test.tsmodels-catalog-combo-access.test.tsmodels-catalog-combo-description.test.tsdescription/display_nameemissionmodel-catalog-policy-invalidation-8728.test.tsallowAutoCombosandcatalogScopeeach advance the generation;isActiveand a no-op update still do not. Red at5 !== 6before the fix, 6/6 afterRegression 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:coreclean. ESLint on changed files clean. Prettier clean. All pre-commit gates (lint-staged, docs-sync, any-budget, tracked-artifacts) pass.npm run test:unitin 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 unrelatedjina/vsjina-ai/prefix mismatch) against the base revision ofsrc/lib/db/apiKeys.ts: it fails identically without this PR's change.