From 176d632a2db454e06b6fa3bc63e009be97c3508f Mon Sep 17 00:00:00 2001 From: Fouad Salkini Date: Fri, 18 Sep 2026 00:53:16 +0300 Subject: [PATCH] feat(api): add per-key allowAutoCombos to gate the built-in auto/* combos (#13670) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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, 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> --- .../features/api-key-allow-auto-combos.md | 6 + config/quality/file-size-baseline.json | 7 +- .../api-manager/ApiManagerPageClient.tsx | 24 +++ .../components/ApiKeyAutoCombosToggle.tsx | 47 +++++ .../components/ApiKeyCatalogScopeSelect.tsx | 56 ++++++ src/app/api/keys/[id]/route.ts | 4 + src/app/api/v1/models/catalog.ts | 46 ++++- src/i18n/messages/en.json | 9 +- src/i18n/messages/vi.json | 9 +- src/lib/db/apiKeyColumnFallbacks.ts | 9 + src/lib/db/apiKeys.ts | 42 ++++- src/lib/db/apiKeys/permissionsUpdate.ts | 4 + src/lib/db/apiKeys/rowParsers.ts | 14 ++ src/shared/utils/apiKeyPolicy.ts | 51 +++++ src/shared/validation/schemas/combo.ts | 5 + src/shared/validation/schemas/keys.ts | 4 + tests/unit/api-key-allow-auto-combos.test.ts | 176 ++++++++++++++++++ tests/unit/api-key-catalog-scope.test.ts | 107 +++++++++++ ...l-catalog-policy-invalidation-8728.test.ts | 6 + .../unit/models-catalog-combo-access.test.ts | 78 ++++++++ .../models-catalog-combo-description.test.ts | 99 ++++++++++ 21 files changed, 794 insertions(+), 9 deletions(-) create mode 100644 changelog.d/features/api-key-allow-auto-combos.md create mode 100644 src/app/(dashboard)/dashboard/api-manager/components/ApiKeyAutoCombosToggle.tsx create mode 100644 src/app/(dashboard)/dashboard/api-manager/components/ApiKeyCatalogScopeSelect.tsx create mode 100644 tests/unit/api-key-allow-auto-combos.test.ts create mode 100644 tests/unit/api-key-catalog-scope.test.ts create mode 100644 tests/unit/models-catalog-combo-access.test.ts create mode 100644 tests/unit/models-catalog-combo-description.test.ts diff --git a/changelog.d/features/api-key-allow-auto-combos.md b/changelog.d/features/api-key-allow-auto-combos.md new file mode 100644 index 0000000000..2546977dae --- /dev/null +++ b/changelog.d/features/api-key-allow-auto-combos.md @@ -0,0 +1,6 @@ +- **feat(api):** add per-key `allowAutoCombos` (default `true`) to gate the built-in `auto/*` + combos, which previously bypassed a key's `allowedCombos`/`allowedModels`/`blockedModels` + restrictions entirely — a restricted key could still reach any model through `auto/best-fast`. + Also adds a per-key `catalogScope` (`all`/`combos`/`models`) to control what `/v1/models` + advertises, and the dashboard gained an Auto Combos toggle and a catalog scope selector in the + API key permissions UI. diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 23edc85767..649adfee76 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -500,9 +500,9 @@ "src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2152, "src/app/api/providers/[id]/models/route.ts": 2432, "src/app/api/providers/[id]/test/route.ts": 1252, - "src/app/api/v1/models/catalog.ts": 2075, + "src/app/api/v1/models/catalog.ts": 2117, "src/app/docs/lib/openapi.generated.ts": 1347, - "src/lib/db/apiKeys.ts": 1625, + "src/lib/db/apiKeys.ts": 1659, "src/lib/db/core.ts": 1788, "src/lib/db/migrationRunner.ts": 1206, "src/lib/tailscaleTunnel.ts": 1208, @@ -708,5 +708,6 @@ "_rebaseline_2026_09_08_13033_responses_websearch_sse": "Own growth after rebase onto v3.8.51 tip af49d4972: open-sse/handlers/chatCore.ts 6036->6035 (-1, check-file-size split-newline). Branch stamps clientRequestedResponsesStream before web_search fallback forces stream:false, then wraps JSON via synthesizeOpenAiSseFromJson. Call-site wiring next to the existing web_search non-stream fallback; no new god-file. Covered tests/unit/responses-websearch-sse-13033.test.ts.", "_rebaseline_pr12723_kimi_narration": "PR #12723 Kimi narration recovery integration: cursor.ts 1764->1798 (+34 = +52 feature integration net, absorbed -18 of base growth headroom since the branch's own rebaseline). Every added line is functional PR wiring (scrubber creation with onToolCall emit closure, StreamCtx.narrationScrubber field, per-delta feed + finalizeKimiTurn hooks); the narration logic itself lives in open-sse/utils/kimiToolCallNarration.ts (593 lines). Covered by tests/unit/kimi-tool-call-narration.test.ts.", "_rebaseline_2026_09_17_merge_wave_growth": "Crescimento da leva de merges de 2026-09-17 (lote /merge-batch + uplift). Cada teto foi atribuido a sua causa antes de subir, nao rebaselinado em bloco: src/sse/handlers/chatHelpers.ts 1245->1246 (#13551, combo scope no guard fail-closed do proxy); open-sse/handlers/chatCore.ts 6203->6219 (#12905 DSML/preamble, #13910 classificacao de 2xx disfarcado, #12904 injecao unica do system prompt pos-traducao); open-sse/utils/stream.ts 3123->3140 (#12905 e #12906 retry de empty_response 502 + timeout reasoning-aware); tests/integration/chat-pipeline.test.ts 1736->1740 (#13419, as duas assercoes de header exatas atualizadas para o charset=utf-8). Todos cobertos pelos testes das proprias PRs, verificados verdes no tip apos o merge.", - "_rebaseline_2026_09_17_11742_log_boundary_hardening": "PR #11742 (rebase para release/v3.8.51): open-sse/handlers/chatCore.ts 6219->6287. O crescimento e a unica parte da PR que sobreviveu ao tip: endurecimento da fronteira de LOG (mais amplo que a Hard Rule #12, que cobre respostas). Sao 136 linhas adicionadas, das quais ~40 sao chamadas diretas de sanitizacao — sanitizeErrorMessage em erro de plugin (onError), em timeout de semaforo e na failureMessage antes de ela chegar ao console.log e ao call-log; sanitizeUpstreamDetails no log de resposta malformada; getSafeErrorMetadata + try/catch nos pontos onde metadata hostil (Proxy) podia lançar. O resto da PR foi descartado por ja estar no tip (#12506/#12945/#13635 error boundaries, #12429 wreq-js, #11754 aposentadoria do ChatGPT Web) — open-sse/utils/ difere do tip por UMA linha (registro do identificador publico lmarena_stream_error)." + "_rebaseline_2026_09_17_11742_log_boundary_hardening": "PR #11742 (rebase para release/v3.8.51): open-sse/handlers/chatCore.ts 6219->6287. O crescimento e a unica parte da PR que sobreviveu ao tip: endurecimento da fronteira de LOG (mais amplo que a Hard Rule #12, que cobre respostas). Sao 136 linhas adicionadas, das quais ~40 sao chamadas diretas de sanitizacao — sanitizeErrorMessage em erro de plugin (onError), em timeout de semaforo e na failureMessage antes de ela chegar ao console.log e ao call-log; sanitizeUpstreamDetails no log de resposta malformada; getSafeErrorMetadata + try/catch nos pontos onde metadata hostil (Proxy) podia lançar. O resto da PR foi descartado por ja estar no tip (#12506/#12945/#13635 error boundaries, #12429 wreq-js, #11754 aposentadoria do ChatGPT Web) — open-sse/utils/ difere do tip por UMA linha (registro do identificador publico lmarena_stream_error).", + "_rebaseline_2026_09_17_13670_allow_auto_combos": "PR #13670 (@fouadSalkini): per-key allowAutoCombos para gatear os combos auto/* embutidos. src/app/api/v1/models/catalog.ts 2075->2117 e src/lib/db/apiKeys.ts 1625->1659. Crescimento e 100% proprio da PR, nao herdado: medido no tip puro, catalog.ts esta em 2074 (abaixo do teto 2075) e apiKeys.ts em 1620 (abaixo de 1625). O aumento e a propria feature — o campo de permissao por chave precisa ser lido, validado e propagado ate o filtro do catalogo, e cada ponto e chamada explicita, nao extraivel sem esconder o gate. Coberto pelos 25 testes da PR. As demais violacoes desta arvore (chatHelpers.ts, chatCore.ts e tests/unit/chatcore-translation-paths.test.ts) sao base-red herdado do tip e nao foram tocadas aqui." } diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index a167fb71d3..268e40ff8d 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -31,6 +31,9 @@ import { UsageLimitSettings } from "./components/UsageLimitSettings"; import { ChaosModeAccessToggle } from "./components/ChaosModeAccessToggle"; import { BypassProviderQuotaToggle } from "./components/BypassProviderQuotaToggle"; import { ApiKeyCompressionToggle } from "./components/ApiKeyCompressionToggle"; +import { ApiKeyAutoCombosToggle } from "./components/ApiKeyAutoCombosToggle"; +import { ApiKeyCatalogScopeSelect } from "./components/ApiKeyCatalogScopeSelect"; +import type { CatalogScope } from "./components/ApiKeyCatalogScopeSelect"; import { AllowedCombosSection } from "./components/AllowedCombosSection"; import ProviderModelPermissionList from "./components/ProviderModelPermissionList"; import RoutingEntryLink from "@/shared/components/routing/RoutingEntryLink"; @@ -136,6 +139,8 @@ interface ApiKey { allowedEndpoints?: string[]; streamDefaultMode?: StreamDefaultMode; compressionEnabled?: boolean; + allowAutoCombos?: boolean; + catalogScope?: CatalogScope; disableNonPublicModels?: boolean; allowUsageCommand?: boolean; chaosModeEnabled?: boolean; @@ -811,6 +816,8 @@ export default function ApiManagerPageClient() { allowedEndpoints: string[], streamDefaultMode: StreamDefaultMode, compressionEnabled: boolean, + allowAutoCombos: boolean, + catalogScope: CatalogScope, disableNonPublicModels: boolean, allowUsageCommand: boolean, usageLimitEnabled: boolean, @@ -888,6 +895,8 @@ export default function ApiManagerPageClient() { allowedEndpoints, streamDefaultMode, compressionEnabled, + allowAutoCombos, + catalogScope, disableNonPublicModels, allowUsageCommand, usageLimitEnabled, @@ -1738,6 +1747,8 @@ const PermissionsModal = memo(function PermissionsModal({ allowedEndpoints: string[], streamDefaultMode: StreamDefaultMode, compressionEnabled: boolean, + allowAutoCombos: boolean, + catalogScope: CatalogScope, disableNonPublicModels: boolean, allowUsageCommand: boolean, usageLimitEnabled: boolean, @@ -1827,6 +1838,8 @@ const PermissionsModal = memo(function PermissionsModal({ const [compressionEnabled, setCompressionEnabled] = useState( apiKey?.compressionEnabled !== false ); + const [allowAutoCombos, setAllowAutoCombos] = useState(apiKey?.allowAutoCombos !== false); + const [catalogScope, setCatalogScope] = useState(apiKey?.catalogScope ?? "all"); const [nameError, setNameError] = useState(null); const [saveError, setSaveError] = useState(null); const [selectedConnections, setSelectedConnections] = useState(initialConnections); @@ -2044,6 +2057,8 @@ const PermissionsModal = memo(function PermissionsModal({ allowAllEndpoints ? [] : selectedEndpoints, streamDefaultMode, compressionEnabled, + allowAutoCombos, + catalogScope, disableNonPublicModels, usageCommandEnabled, usageLimitEnabled, @@ -2084,6 +2099,8 @@ const PermissionsModal = memo(function PermissionsModal({ selectedEndpoints, streamDefaultMode, compressionEnabled, + allowAutoCombos, + catalogScope, disableNonPublicModels, usageCommandEnabled, usageLimitEnabled, @@ -2554,6 +2571,13 @@ const PermissionsModal = memo(function PermissionsModal({ onToggle={() => setCompressionEnabled((prev) => !prev)} /> + setAllowAutoCombos((prev) => !prev)} + /> + + + {/* Ban Toggle (SECURITY) */}
diff --git a/src/app/(dashboard)/dashboard/api-manager/components/ApiKeyAutoCombosToggle.tsx b/src/app/(dashboard)/dashboard/api-manager/components/ApiKeyAutoCombosToggle.tsx new file mode 100644 index 0000000000..d9a42ef07d --- /dev/null +++ b/src/app/(dashboard)/dashboard/api-manager/components/ApiKeyAutoCombosToggle.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useTranslations } from "next-intl"; + +/** + * Per-key access to the built-in `auto/*` combos, used by the API key + * permissions modal. + * + * `auto/*` ids are virtual, so `allowedCombos` cannot constrain them — this is + * the only per-key gate that reaches them. Enabled by default: a key that has + * never been configured keeps the historical access. + */ +export function ApiKeyAutoCombosToggle({ + enabled, + onToggle, +}: { + enabled: boolean; + onToggle: () => void; +}) { + const tSettings = useTranslations("settings"); + const tc = useTranslations("common"); + + return ( +
+
+

{tSettings("autoCombosTitle")}

+

{tSettings("autoCombosDesc")}

+
+ +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/api-manager/components/ApiKeyCatalogScopeSelect.tsx b/src/app/(dashboard)/dashboard/api-manager/components/ApiKeyCatalogScopeSelect.tsx new file mode 100644 index 0000000000..2a2b7d8391 --- /dev/null +++ b/src/app/(dashboard)/dashboard/api-manager/components/ApiKeyCatalogScopeSelect.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useTranslations } from "next-intl"; + +export type CatalogScope = "all" | "combos" | "models"; + +const OPTIONS: Array<{ value: CatalogScope; icon: string; labelKey: string }> = [ + { value: "all", icon: "list", labelKey: "catalogScopeAll" }, + { value: "combos", icon: "hub", labelKey: "catalogScopeCombos" }, + { value: "models", icon: "smart_toy", labelKey: "catalogScopeModels" }, +]; + +/** + * What a key's `/v1/models` advertises: combos, provider models, or both. + * + * A listing preference, not an access control — narrowing it never changes what + * the key may dispatch. Useful for a key driving a client that builds its model + * picker from the catalog and should only see curated combos. + */ +export function ApiKeyCatalogScopeSelect({ + value, + onChange, +}: { + value: CatalogScope; + onChange: (next: CatalogScope) => void; +}) { + const tSettings = useTranslations("settings"); + + return ( +
+
+

{tSettings("catalogScopeTitle")}

+

{tSettings("catalogScopeDesc")}

+
+
+ {OPTIONS.map((opt) => ( + + ))} +
+
+ ); +} diff --git a/src/app/api/keys/[id]/route.ts b/src/app/api/keys/[id]/route.ts index befb2cd20e..40b18902b1 100644 --- a/src/app/api/keys/[id]/route.ts +++ b/src/app/api/keys/[id]/route.ts @@ -86,6 +86,8 @@ export async function PATCH(request, { params }) { allowedEndpoints, streamDefaultMode, compressionEnabled, + allowAutoCombos, + catalogScope, cacheDefaultMode, disableNonPublicModels, allowUsageCommand, @@ -119,6 +121,8 @@ export async function PATCH(request, { params }) { if (allowedEndpoints !== undefined) payload.allowedEndpoints = allowedEndpoints; if (streamDefaultMode !== undefined) payload.streamDefaultMode = streamDefaultMode; if (compressionEnabled !== undefined) payload.compressionEnabled = compressionEnabled; + if (allowAutoCombos !== undefined) payload.allowAutoCombos = allowAutoCombos; + if (catalogScope !== undefined) payload.catalogScope = catalogScope; if (cacheDefaultMode !== undefined) payload.cacheDefaultMode = cacheDefaultMode; if (disableNonPublicModels !== undefined) payload.disableNonPublicModels = disableNonPublicModels; diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 0098980f46..785cb5b105 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -1,6 +1,7 @@ import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; import { NOAUTH_PROVIDERS } from "@/shared/constants/providers"; import { getCombos } from "@/lib/db/combos"; +import { isComboNameAllowedForKey } from "@/shared/utils/apiKeyPolicy"; import { getSettings } from "@/lib/db/settings"; import { getUserDatabaseSettings } from "@/lib/db/databaseSettings"; import { createLazyConnectionView } from "@/lib/db/providers/lazyConnectionView"; @@ -812,9 +813,12 @@ async function buildUnifiedModelsResponseCore( // `buildComboCatalogMetadata`) already exists here, so return before the // provider/auto-combo/registry loops start. const earlyApiKey = extractApiKey(request); + let earlyKeyMeta: Awaited< + ReturnType + > | null = null; if (earlyApiKey) { const { getApiKeyMetadata } = await import("@/lib/db/apiKeys"); - const earlyKeyMeta = await getApiKeyMetadata(earlyApiKey); + earlyKeyMeta = await getApiKeyMetadata(earlyApiKey); if (earlyKeyMeta?.allowedQuotas && earlyKeyMeta.allowedQuotas.length > 0) { const { buildQuotaExclusiveModels } = await import("@/lib/quota/quotaCombos"); const quotaModels = await buildQuotaExclusiveModels( @@ -848,6 +852,9 @@ async function buildUnifiedModelsResponseCore( // #9199: prepare the shared connection/settings/registry candidate snapshot once for this // catalog build. Runtime auto routing still prepares fresh request-scoped inputs. let preparedAutoInputs: Awaited> | undefined; + // A key with allowAutoCombos=false must not be offered ids it cannot use: + // the policy gate rejects auto/* for it at dispatch. + const autoCombosDisallowedForKey = earlyKeyMeta?.allowAutoCombos === false; let materializedAutoCount = 0; const autoMeta = memoizeTargetMetadata(getComboTargetCatalogMetadata, maybeYieldCatalogBuild); for (const autoId of [ @@ -857,7 +864,7 @@ async function buildUnifiedModelsResponseCore( ]) { // #9418: skip the entire loop when hideAutoCombos is on — the ids are still // routable when sent explicitly, just not advertised in the catalog. - if (hideAuto) break; + if (hideAuto || autoCombosDisallowedForKey) break; if (blockedProviders.has("auto") || listedIds.has(autoId)) continue; // #5192 // #6328 (follow-up to #6495 / #6512): REMOVE — not just hide — paid-tier // auto/* ids (auto/pro-* + auto/*:pro) from the advertised catalog when the @@ -953,6 +960,19 @@ async function buildUnifiedModelsResponseCore( const comboMetadata = buildComboCatalogMetadata(combo, visibleTargets); listedIds.add(combo.name); + // #13670 follow-up: advertise the combo's own description. Claude Code's + // gateway model discovery reads `description` off each /v1/models entry and + // renders it in the picker (an entry without one reads "From gateway"), and + // other OpenAI-compatible clients surface it too. Emitted only when the combo + // actually has one, so rows stay unchanged for combos that don't. + const comboDescription = + typeof combo.description === "string" ? combo.description.trim() : ""; + // Operator-set label. Claude Code uses `display_name` 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. No heuristics: if the operator did + // not set one, none is advertised. + const comboDisplayName = + typeof combo.displayName === "string" ? combo.displayName.trim() : ""; models.push({ id: combo.name, object: "model", @@ -961,6 +981,8 @@ async function buildUnifiedModelsResponseCore( permission: [], root: combo.name, parent: null, + ...(comboDisplayName ? { display_name: comboDisplayName } : {}), + ...(comboDescription ? { description: comboDescription } : {}), ...comboMetadata, }); @@ -1982,8 +2004,28 @@ async function buildUnifiedModelsResponseCore( // Without this branch, isModelAllowedForKey returns false for every model // (metadata missing → deny), collapsing /v1/models to 0 entries. } else { + // Per-key catalog scope: `combos` advertises only combo rows, `models` + // only provider models, `all` (the default) both. This is a listing + // preference, not an access control — dispatch is unaffected either way. + const catalogScope = keyMeta.catalogScope ?? "all"; const filtered = []; for (const m of models) { + const isComboRow = m.owned_by === "combo"; + if (catalogScope === "combos" && !isComboRow) continue; + if (catalogScope === "models" && isComboRow) continue; + // A combo is gated by `allowedCombos`, not by the model allow/deny lists: + // those govern provider models. Without this branch a `restricted` key with + // an empty `allowedModels` gets an EMPTY catalog even though every combo in + // its `allowedCombos` dispatches fine — the catalog contradicted the key. + // Listing a combo the key can already dispatch grants no new access. + // auto/* rows are exempt: they fail open at dispatch (they resolve to no + // stored combo), and `allowAutoCombos` already gated their synthesis above. + if (m.owned_by === "combo" && !String(m.id).startsWith("auto/")) { + if (isComboNameAllowedForKey(keyMeta.allowedCombos, String(m.id))) { + filtered.push(m); + } + continue; + } // m.id is the full identifier (e.g. openai/gpt-4o), m.root is the raw model string // check either one as the config could use either patterns if ( diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index d6dc495f36..965c1d08f8 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -7491,6 +7491,8 @@ "clearSyncedPricing": "Clear Synced Pricing", "compressionTitle": "Prompt Compression", "compressionDesc": "Reduce token usage by compressing prompts before sending to providers", + "autoCombosTitle": "Auto Combos", + "autoCombosDesc": "Allow this key to use the built-in auto/* combos, which pick a model automatically from every configured provider", "compressionGuidanceFullGuideLink": "Full compression guide", "compressionGuidanceShow": "Details", "compressionGuidanceHide": "Hide details", @@ -8398,7 +8400,12 @@ "cliproxyapiHealth": "Health", "cliproxyapiPort": "Port", "qdrantHost": "Host", - "qdrantCollection": "Collection" + "qdrantCollection": "Collection", + "catalogScopeTitle": "Models API listing", + "catalogScopeDesc": "What GET /v1/models advertises for this key. Narrowing the list never changes what the key can call.", + "catalogScopeAll": "Both", + "catalogScopeCombos": "Combos only", + "catalogScopeModels": "Models only" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index ef8c41463f..efa39099b4 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -7491,6 +7491,8 @@ "clearSyncedPricing": "Xóa giá đã đồng bộ hóa", "compressionTitle": "Nén prompt", "compressionDesc": "Giảm mức sử dụng token bằng cách nén các prompt trước khi gửi đến các nhà cung cấp", + "autoCombosTitle": "Combo tự động", + "autoCombosDesc": "Cho phép khóa này sử dụng các combo auto/* tích hợp sẵn, vốn tự động chọn mô hình từ mọi nhà cung cấp đã cấu hình", "compressionGuidanceFullGuideLink": "Hướng dẫn nén đầy đủ", "compressionGuidanceShow": "Chi tiết", "compressionGuidanceHide": "Ẩn chi tiết", @@ -8398,7 +8400,12 @@ "cliproxyapiHealth": "Sức Khỏe", "cliproxyapiPort": "Cổng", "qdrantHost": "Máy chủ", - "qdrantCollection": "Bộ Sưu Tập" + "qdrantCollection": "Bộ Sưu Tập", + "catalogScopeTitle": "Danh sách API mô hình", + "catalogScopeDesc": "Những gì GET /v1/models hiển thị cho khóa này. Thu hẹp danh sách không làm thay đổi những gì khóa có thể gọi.", + "catalogScopeAll": "Cả hai", + "catalogScopeCombos": "Chỉ combo", + "catalogScopeModels": "Chỉ mô hình" }, "contextRtk": { "title": "Công cụ RTK", diff --git a/src/lib/db/apiKeyColumnFallbacks.ts b/src/lib/db/apiKeyColumnFallbacks.ts index a550198322..ed43a75a1a 100644 --- a/src/lib/db/apiKeyColumnFallbacks.ts +++ b/src/lib/db/apiKeyColumnFallbacks.ts @@ -58,4 +58,13 @@ export const API_KEY_COLUMN_FALLBACKS = [ name: "compression_enabled", definition: "compression_enabled INTEGER NOT NULL DEFAULT 1", }, + { + name: "allow_auto_combos", + definition: "allow_auto_combos INTEGER NOT NULL DEFAULT 1", + }, + { + name: "catalog_scope", + definition: + "catalog_scope TEXT NOT NULL DEFAULT 'all' CHECK (catalog_scope IN ('all', 'combos', 'models'))", + }, ] as const; diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts index 7668945814..0764e10335 100644 --- a/src/lib/db/apiKeys.ts +++ b/src/lib/db/apiKeys.ts @@ -50,6 +50,8 @@ import { parseCacheDefaultMode, parseChaosModeEnabled, parseCompressionEnabled, + parseAllowAutoCombos, + parseCatalogScope, parseModelAccessMode, } from "./apiKeys/rowParsers"; import { @@ -123,6 +125,8 @@ interface ApiKeyMetadata { weeklyUsageLimitUsd: number | null; chaosModeEnabled: boolean; compressionEnabled: boolean; + allowAutoCombos: boolean; + catalogScope: "all" | "combos" | "models"; } interface ApiKeyRow extends JsonRecord { @@ -170,6 +174,10 @@ interface ApiKeyRow extends JsonRecord { chaosModeEnabled?: unknown; compression_enabled?: unknown; compressionEnabled?: unknown; + allow_auto_combos?: unknown; + allowAutoCombos?: unknown; + catalog_scope?: unknown; + catalogScope?: unknown; } interface StatementLike { @@ -220,6 +228,8 @@ interface ApiKeyView extends JsonRecord { weeklyUsageLimitUsd?: number | null; chaosModeEnabled?: boolean; compressionEnabled: boolean; + allowAutoCombos: boolean; + catalogScope: "all" | "combos" | "models"; } // LRU cache for API key validation (valid keys only) @@ -437,7 +447,7 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements { "SELECT id, expires_at, revoked_at, is_active, is_banned FROM api_keys WHERE key = ? OR key_hash = ?" ); _stmtGetKeyMetadata = db.prepare( - "SELECT id, name, machine_id, model_access_mode, allowed_models, blocked_models, allowed_combos, allowed_connections, allowed_quotas, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, throttle_delay_ms, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash, allowed_endpoints, stream_default_mode, cache_default_mode, disable_non_public_models, allow_usage_command, usage_limit_enabled, daily_usage_limit_usd, weekly_usage_limit_usd, chaos_mode_enabled, compression_enabled, proxy_id FROM api_keys WHERE key = ? OR key_hash = ?" + "SELECT id, name, machine_id, model_access_mode, allowed_models, blocked_models, allowed_combos, allowed_connections, allowed_quotas, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, throttle_delay_ms, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash, allowed_endpoints, stream_default_mode, cache_default_mode, disable_non_public_models, allow_usage_command, usage_limit_enabled, daily_usage_limit_usd, weekly_usage_limit_usd, chaos_mode_enabled, compression_enabled, allow_auto_combos, catalog_scope, proxy_id FROM api_keys WHERE key = ? OR key_hash = ?" ); _stmtInsertKey = db.prepare( "INSERT INTO api_keys (id, name, key, machine_id, model_access_mode, allowed_models, allowed_combos, allowed_connections, no_log, created_at, key_prefix, key_hash, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" @@ -505,6 +515,8 @@ export async function getApiKeys(limit?: number, offset?: number) { camelRow.compressionEnabled = parseCompressionEnabled( (camelRow as JsonRecord).compressionEnabled ); + camelRow.allowAutoCombos = parseAllowAutoCombos((camelRow as JsonRecord).allowAutoCombos); + camelRow.catalogScope = parseCatalogScope((camelRow as JsonRecord).catalogScope); Object.assign(camelRow, parseApiKeyUsageLimitFields(camelRow)); if (typeof camelRow.id === "string" && camelRow.id.length > 0) { setNoLog(camelRow.id, camelRow.noLog === true); @@ -642,6 +654,8 @@ export async function getApiKeyById(id: string) { camelRow.compressionEnabled = parseCompressionEnabled( (camelRow as JsonRecord).compressionEnabled ); + camelRow.allowAutoCombos = parseAllowAutoCombos((camelRow as JsonRecord).allowAutoCombos); + camelRow.catalogScope = parseCatalogScope((camelRow as JsonRecord).catalogScope); Object.assign(camelRow, parseApiKeyUsageLimitFields(camelRow)); if (typeof camelRow.id === "string" && camelRow.id.length > 0) { setNoLog(camelRow.id, camelRow.noLog === true); @@ -769,7 +783,9 @@ export async function updateApiKeyPermissions( normalized.allowedCombos !== undefined || normalized.allowedConnections !== undefined || normalized.allowedQuotas !== undefined || - normalized.disableNonPublicModels !== undefined; + normalized.disableNonPublicModels !== undefined || + normalized.allowAutoCombos !== undefined || + normalized.catalogScope !== undefined; if ( normalized.name === undefined && @@ -799,6 +815,8 @@ export async function updateApiKeyPermissions( normalized.allowUsageCommand === undefined && normalized.chaosModeEnabled === undefined && normalized.compressionEnabled === undefined && + normalized.allowAutoCombos === undefined && + normalized.catalogScope === undefined && !hasUsageLimitUpdate(normalized as Record) ) { return false; @@ -836,6 +854,8 @@ export async function updateApiKeyPermissions( weeklyUsageLimitUsd?: number | null; chaosModeEnabled?: number; compressionEnabled?: number; + allowAutoCombos?: number; + catalogScope?: string; } = { id }; if (normalized.name !== undefined) { @@ -952,6 +972,16 @@ export async function updateApiKeyPermissions( params.compressionEnabled = normalized.compressionEnabled ? 1 : 0; } + if (normalized.allowAutoCombos !== undefined) { + updates.push("allow_auto_combos = @allowAutoCombos"); + params.allowAutoCombos = normalized.allowAutoCombos ? 1 : 0; + } + + if (normalized.catalogScope !== undefined) { + updates.push("catalog_scope = @catalogScope"); + params.catalogScope = normalized.catalogScope; + } + appendUsageLimitUpdates(normalized as Record, updates, params); const maxSessionsUpdate = (normalized as Record).maxSessions; @@ -1385,6 +1415,8 @@ export async function getApiKeyMetadata( weeklyUsageLimitUsd: null, chaosModeEnabled: false, compressionEnabled: true, + allowAutoCombos: true, + catalogScope: "all", }; } @@ -1472,6 +1504,12 @@ export async function getApiKeyMetadata( compressionEnabled: parseCompressionEnabled( (record as JsonRecord).compression_enabled ?? (record as JsonRecord).compressionEnabled ), + allowAutoCombos: parseAllowAutoCombos( + (record as JsonRecord).allow_auto_combos ?? (record as JsonRecord).allowAutoCombos + ), + catalogScope: parseCatalogScope( + (record as JsonRecord).catalog_scope ?? (record as JsonRecord).catalogScope + ), ...parseApiKeyUsageLimitFields(record as JsonRecord), }; diff --git a/src/lib/db/apiKeys/permissionsUpdate.ts b/src/lib/db/apiKeys/permissionsUpdate.ts index 98e4799aa0..6f03582b46 100644 --- a/src/lib/db/apiKeys/permissionsUpdate.ts +++ b/src/lib/db/apiKeys/permissionsUpdate.ts @@ -32,6 +32,8 @@ export interface ApiKeyPermissionsUpdate { weeklyUsageLimitUsd?: number | null; chaosModeEnabled?: boolean; compressionEnabled?: boolean; + allowAutoCombos?: boolean; + catalogScope?: "all" | "combos" | "models"; } export function normalizeApiKeyPermissionsUpdate( @@ -73,5 +75,7 @@ export function normalizeApiKeyPermissionsUpdate( weeklyUsageLimitUsd: update.weeklyUsageLimitUsd, chaosModeEnabled: update.chaosModeEnabled, compressionEnabled: update.compressionEnabled, + allowAutoCombos: update.allowAutoCombos, + catalogScope: update.catalogScope, }; } diff --git a/src/lib/db/apiKeys/rowParsers.ts b/src/lib/db/apiKeys/rowParsers.ts index 1293e0ddea..22e9c02863 100644 --- a/src/lib/db/apiKeys/rowParsers.ts +++ b/src/lib/db/apiKeys/rowParsers.ts @@ -69,6 +69,20 @@ export function parseCompressionEnabled(value: unknown): boolean { return true; } +export function parseAllowAutoCombos(value: unknown): boolean { + // DEFAULT 1 — a key predating this column keeps its auto/* access. + if (value === 0 || value === "0" || value === false) return false; + return true; +} + +export type CatalogScope = "all" | "combos" | "models"; + +export function parseCatalogScope(value: unknown): CatalogScope { + // DEFAULT 'all' — a key predating this column advertises everything, as before. + // An unrecognised value must widen to 'all' rather than silently hide rows. + return value === "combos" || value === "models" ? value : "all"; +} + export function parseAccessSchedule(value: unknown): AccessSchedule | null { if (!value || typeof value !== "string" || value.trim() === "") return null; try { diff --git a/src/shared/utils/apiKeyPolicy.ts b/src/shared/utils/apiKeyPolicy.ts index ad82e10ad7..1118d3b7c5 100644 --- a/src/shared/utils/apiKeyPolicy.ts +++ b/src/shared/utils/apiKeyPolicy.ts @@ -96,6 +96,8 @@ export interface ApiKeyMetadata { dailyUsageLimitUsd?: number | null; weeklyUsageLimitUsd?: number | null; compressionEnabled?: boolean; + allowAutoCombos?: boolean; + catalogScope?: "all" | "combos" | "models"; } /** @@ -188,6 +190,28 @@ function matchesComboAccessRule(comboName: string, requestedModel: string, rule: ); } +/** + * Whether a key's `allowedCombos` permits this combo by name. + * + * The catalog uses this so a key's `/v1/models` lists exactly the combos that + * key can dispatch. `allowedCombos` is the gate for combos — `modelAccessMode` + * and `allowedModels` gate provider models — so a combo must not be hidden just + * because the key is `restricted` with an empty model allow-list. Listing a + * combo the key can already dispatch grants no new access. + * + * An absent list means "no combo restriction configured", matching + * `validateComboAccess`, which skips the check when `allowedCombos` is not an array. + */ +export function isComboNameAllowedForKey( + allowedCombos: string[] | null | undefined, + comboName: string +): boolean { + if (!Array.isArray(allowedCombos)) return true; + if (!comboName) return false; + // In the catalog the requested model IS the combo id, so both arguments match. + return allowedCombos.some((rule) => matchesComboAccessRule(comboName, comboName, rule)); +} + function isAnthropicMessagesRequest(request: Request): boolean { if (request.headers.has("anthropic-version")) return true; @@ -521,9 +545,36 @@ async function validateQuotaAccess(context: PolicyContext): Promise { const { request, apiKey, apiKeyInfo, modelStr } = context; if (!modelStr || apiKeyInfo.allowedQuotas?.length) return null; + if (isAutoComboDeniedForKey(apiKeyInfo, modelStr)) { + return policyErrorResponse( + request, + HTTP_STATUS.FORBIDDEN, + `Auto combo "${modelStr}" is not allowed for this API key`, + `Auto combos are not enabled for this API key. Choose an explicit model or combo.`, + "invalid_request_error", + HTTP_STATUS.BAD_REQUEST + ); + } const comboAccess = await validateComboAccess(apiKeyInfo.allowedCombos, modelStr); if (comboAccess.rejection) return comboAccess.rejection; let requestedComboName = comboAccess.comboName; diff --git a/src/shared/validation/schemas/combo.ts b/src/shared/validation/schemas/combo.ts index 006fabbef5..306b8da498 100644 --- a/src/shared/validation/schemas/combo.ts +++ b/src/shared/validation/schemas/combo.ts @@ -354,6 +354,9 @@ export const createComboSchema = z .object({ name: comboNameSchema, description: z.string().max(2000).optional(), + // Optional label advertised as `display_name` in /v1/models. Lets a combo + // carry a machine-oriented name while clients show something readable. + displayName: z.string().trim().max(200).optional(), models: z.array(comboModelEntry).min(1, "a combo requires at least one model"), strategy: comboStrategySchema.optional().default("priority"), config: comboRuntimeConfigSchema.optional(), @@ -413,6 +416,7 @@ export const updateComboSchema = z .object({ name: comboNameSchema.optional(), description: z.string().max(2000).optional().nullable(), + displayName: z.string().trim().max(200).optional().nullable(), // An update may not remove every model from a combo, or a working combo // loses every target. Creation refuses an empty list too: since the CLI // gained --models (#10954), an empty draft has no remaining legitimate path. @@ -448,6 +452,7 @@ export const updateComboSchema = z if ( value.name === undefined && value.description === undefined && + value.displayName === undefined && value.models === undefined && value.strategy === undefined && value.config === undefined && diff --git a/src/shared/validation/schemas/keys.ts b/src/shared/validation/schemas/keys.ts index f301158d19..17e05bb75b 100644 --- a/src/shared/validation/schemas/keys.ts +++ b/src/shared/validation/schemas/keys.ts @@ -150,6 +150,8 @@ export const updateKeyPermissionsSchema = z allowedEndpoints: z.array(z.string().trim().min(1).max(64)).max(20).optional(), streamDefaultMode: z.enum(["legacy", "json"]).optional(), compressionEnabled: z.boolean().optional(), + allowAutoCombos: z.boolean().optional(), + catalogScope: z.enum(["all", "combos", "models"]).optional(), cacheDefaultMode: z.enum(["legacy", "bypass"]).optional(), disableNonPublicModels: z.boolean().optional(), allowUsageCommand: z.boolean().optional(), @@ -208,6 +210,8 @@ export const updateKeyPermissionsSchema = z value.allowedEndpoints === undefined && value.streamDefaultMode === undefined && value.compressionEnabled === undefined && + value.allowAutoCombos === undefined && + value.catalogScope === undefined && value.cacheDefaultMode === undefined && value.disableNonPublicModels === undefined && value.allowUsageCommand === undefined && diff --git a/tests/unit/api-key-allow-auto-combos.test.ts b/tests/unit/api-key-allow-auto-combos.test.ts new file mode 100644 index 0000000000..a437edff35 --- /dev/null +++ b/tests/unit/api-key-allow-auto-combos.test.ts @@ -0,0 +1,176 @@ +/** + * Per-key control over the built-in `auto/*` combos. + * + * `auto/*` combos are virtual — they are synthesised in the catalog, not stored + * as rows — so `resolveRequestedComboName()` returns null for them and + * `isComboAllowedForKey()` FAILS OPEN (`src/shared/utils/apiKeyPolicy.ts`: + * `if (!comboName) return { allowed: true, comboName: null }`). Because + * `validateModelAccess()` then returns early on a resolved combo name, the + * `allowedModels` / `blockedModels` check is never reached for an `auto/*` id + * either. + * + * Net effect before this change: `auto/*` bypassed per-key authorisation + * completely. A key scoped via `allowedCombos` to a single cheap lane could + * still send `auto/best-coding` and reach every model on the gateway. Observed + * on a live gateway: a key whose `allowedCombos` held 24 named combos and no + * `auto` entry dispatched `auto/best-fast` successfully (HTTP 200). + * + * `blockedModels: ["auto/*"]` only hides the ids from `/v1/models`; it cannot + * deny them, for the early-return reason above. + * + * The fix is an explicit per-key flag, `allowAutoCombos`, defaulting to TRUE so + * every existing key keeps working. Setting it to false denies `auto/*` at + * dispatch and drops the ids from that key's catalog. + * + * Rules: + * R1 The column is declared with DEFAULT 1 (allowed) for legacy rows. + * R2 The row parser treats anything but an explicit falsy value as allowed. + * R3 The deny predicate fires only for auto/* ids on a key that opted out. + * R4 The PATCH schema preserves the flag and counts it as a real update. + * R5 The update route forwards it into the payload. + * R6 The catalog skips the auto/* synthesis loop for an opted-out key. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +const { API_KEY_COLUMN_FALLBACKS } = await import("../../src/lib/db/apiKeyColumnFallbacks.ts"); +const { parseAllowAutoCombos } = await import("../../src/lib/db/apiKeys/rowParsers.ts"); +const { isAutoComboDeniedForKey } = await import("../../src/shared/utils/apiKeyPolicy.ts"); +const schemas = await import("../../src/shared/validation/schemas.ts"); + +function read(relativePath: string) { + return fs.readFileSync(path.join(process.cwd(), relativePath), "utf8"); +} + +test("R1: allow_auto_combos is declared NOT NULL DEFAULT 1 so legacy keys keep auto/*", () => { + const column = API_KEY_COLUMN_FALLBACKS.find( + (c: { name: string }) => c.name === "allow_auto_combos" + ); + assert.ok(column, "api_keys must gain an allow_auto_combos column"); + assert.match( + column.definition, + /NOT NULL DEFAULT 1/, + "default must be 1 — an existing key must not silently lose auto/* access" + ); +}); + +test("R2: the row parser defaults to allowed and opts out only on an explicit falsy value", () => { + // Legacy rows predating the column, and rows that never set it. + assert.equal(parseAllowAutoCombos(undefined), true); + assert.equal(parseAllowAutoCombos(null), true); + assert.equal(parseAllowAutoCombos(1), true); + assert.equal(parseAllowAutoCombos("1"), true); + assert.equal(parseAllowAutoCombos(true), true); + // Explicit opt-out, in every shape SQLite / JSON round-trips produce. + assert.equal(parseAllowAutoCombos(0), false); + assert.equal(parseAllowAutoCombos("0"), false); + assert.equal(parseAllowAutoCombos(false), false); +}); + +test("R3: the deny predicate fires only for auto/* on a key that opted out", () => { + const optedOut = { allowAutoCombos: false }; + const optedIn = { allowAutoCombos: true }; + const legacy = {}; // flag absent entirely + + assert.equal(isAutoComboDeniedForKey(optedOut, "auto/best-coding"), true); + assert.equal(isAutoComboDeniedForKey(optedOut, "auto/coding:fast"), true); + + // Opted in, or never configured — never denied. + assert.equal(isAutoComboDeniedForKey(optedIn, "auto/best-coding"), false); + assert.equal(isAutoComboDeniedForKey(legacy, "auto/best-coding"), false); + assert.equal(isAutoComboDeniedForKey(undefined, "auto/best-coding"), false); + assert.equal(isAutoComboDeniedForKey(null, "auto/best-coding"), false); + + // Never touches anything that is not an auto/* id, even when opted out. + assert.equal(isAutoComboDeniedForKey(optedOut, "claude-haiku"), false); + assert.equal(isAutoComboDeniedForKey(optedOut, "codex/gpt-5.6-sol-xhigh"), false); + assert.equal(isAutoComboDeniedForKey(optedOut, "qtSd/pool-1"), false); + // A combo whose name merely starts with the word "auto" is not an auto/* id. + assert.equal(isAutoComboDeniedForKey(optedOut, "auto-router"), false); + assert.equal(isAutoComboDeniedForKey(optedOut, ""), false); +}); + +test("R4: the PATCH schema preserves allowAutoCombos and counts it as a real update", () => { + const parsed = schemas.updateKeyPermissionsSchema.safeParse({ allowAutoCombos: false }); + assert.equal( + parsed.success, + true, + "allowAutoCombos alone must be a valid update — the 'No valid fields' guard must count it" + ); + if (!parsed.success) return; + assert.equal(parsed.data.allowAutoCombos, false, "the flag must survive parsing"); + + const on = schemas.updateKeyPermissionsSchema.safeParse({ allowAutoCombos: true }); + assert.equal(on.success, true); + if (on.success) assert.equal(on.data.allowAutoCombos, true); + + assert.equal( + schemas.updateKeyPermissionsSchema.safeParse({ allowAutoCombos: "no" }).success, + false, + "a non-boolean must be rejected" + ); +}); + +test("R5: the update route forwards allowAutoCombos into the payload", () => { + const route = read("src/app/api/keys/[id]/route.ts"); + assert.ok( + route.includes("if (allowAutoCombos !== undefined) payload.allowAutoCombos = allowAutoCombos"), + "PATCH /api/keys/[id] must forward allowAutoCombos to updateApiKeyPermissions" + ); +}); + +test("R7: the API Manager wires the toggle and defaults it ON", () => { + const client = read("src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx"); + + assert.ok( + client.includes("ApiKeyAutoCombosToggle"), + "the permissions modal must render the auto-combos toggle" + ); + assert.ok( + client.includes("apiKey?.allowAutoCombos !== false"), + "state must default ON via `!== false` — `=== true` would render a key that predates the field as disabled" + ); + // Positional plumbing: the save handler signature, the onSave call and the + // PATCH payload must each carry the field, or later arguments shift by one. + assert.ok( + client.includes("allowAutoCombos: boolean,"), + "the save handler and modal prop signatures must declare it" + ); + assert.match( + client, + /body: JSON\.stringify\(\{[\s\S]*?allowAutoCombos,[\s\S]*?\}\)/, + "the PATCH body must include allowAutoCombos" + ); +}); + +test("R8: the toggle's UI strings exist in English and Vietnamese", () => { + // en.json is the source of truth; vi is the one locale whose completeness is + // asserted by tests/unit/i18n-vi-completeness.test.ts (it bans placeholders). + for (const locale of ["en", "vi"]) { + const messages = JSON.parse(read(`src/i18n/messages/${locale}.json`)); + for (const key of ["autoCombosTitle", "autoCombosDesc"]) { + const value = messages?.settings?.[key]; + assert.equal(typeof value, "string", `${locale}.json settings.${key} must exist`); + assert.ok(value.trim().length > 0, `${locale}.json settings.${key} must not be empty`); + assert.ok( + !/__(?:MISSING|TODO)__/i.test(value), + `${locale}.json settings.${key} must be translated, not a placeholder` + ); + } + } +}); + +test("R6: the catalog skips auto/* synthesis for a key that opted out", () => { + const catalog = read("src/app/api/v1/models/catalog.ts"); + assert.ok( + catalog.includes("autoCombosDisallowedForKey"), + "catalog must compute a per-key auto/* suppression flag" + ); + assert.ok( + /if \(hideAuto \|\| autoCombosDisallowedForKey\) break;/.test(catalog), + "the auto/* synthesis loop must break for an opted-out key, as it already does for hideAuto" + ); +}); diff --git a/tests/unit/api-key-catalog-scope.test.ts b/tests/unit/api-key-catalog-scope.test.ts new file mode 100644 index 0000000000..a6fd92f2a2 --- /dev/null +++ b/tests/unit/api-key-catalog-scope.test.ts @@ -0,0 +1,107 @@ +/** + * Per-key control over what `GET /v1/models` advertises. + * + * A key may want only its curated combos listed (a client that builds its model + * picker from the catalog), only provider models, or both. There was no way to + * express that: the catalog always advertised whatever the key's model and combo + * policies permitted, mixed together. + * + * `catalogScope` is a LISTING preference, not an access control. Narrowing it + * never changes what the key may dispatch — the model policy and `allowedCombos` + * still decide that. Default `"all"` keeps every existing key unchanged. + * + * Rules: + * R1 The column defaults to 'all' and constrains itself to the three values. + * R2 The parser defaults to 'all', and widens rather than narrows on junk. + * R3 The PATCH schema accepts the enum, alone, and rejects anything else. + * R4 The route forwards it into the update payload. + * R5 The catalog skips the rows the scope excludes, and only those. + * R6 The API Manager wires the control and defaults it to 'all'. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +const { API_KEY_COLUMN_FALLBACKS } = await import("../../src/lib/db/apiKeyColumnFallbacks.ts"); +const { parseCatalogScope } = await import("../../src/lib/db/apiKeys/rowParsers.ts"); +const schemas = await import("../../src/shared/validation/schemas.ts"); + +const read = (p: string) => fs.readFileSync(path.join(process.cwd(), p), "utf8"); + +test("R1: the column defaults to 'all' and constrains its values", () => { + const column = API_KEY_COLUMN_FALLBACKS.find((c: { name: string }) => c.name === "catalog_scope"); + assert.ok(column, "api_keys must gain a catalog_scope column"); + assert.match(column.definition, /NOT NULL DEFAULT 'all'/, "default must be 'all'"); + assert.match( + column.definition, + /CHECK \(catalog_scope IN \('all', 'combos', 'models'\)\)/, + "the column must reject values outside the enum" + ); +}); + +test("R2: the parser defaults to 'all' and widens on anything unrecognised", () => { + assert.equal(parseCatalogScope("combos"), "combos"); + assert.equal(parseCatalogScope("models"), "models"); + assert.equal(parseCatalogScope("all"), "all"); + // A key predating the column, or a value that somehow got past the CHECK, + // must show MORE rather than silently hide rows the operator expects. + assert.equal(parseCatalogScope(undefined), "all"); + assert.equal(parseCatalogScope(null), "all"); + assert.equal(parseCatalogScope(""), "all"); + assert.equal(parseCatalogScope("COMBOS"), "all"); + assert.equal(parseCatalogScope(7), "all"); +}); + +test("R3: the PATCH schema accepts the enum, alone, and rejects the rest", () => { + for (const scope of ["all", "combos", "models"]) { + const parsed = schemas.updateKeyPermissionsSchema.safeParse({ catalogScope: scope }); + assert.equal(parsed.success, true, `${scope} alone must be a valid update`); + if (parsed.success) assert.equal(parsed.data.catalogScope, scope); + } + assert.equal( + schemas.updateKeyPermissionsSchema.safeParse({ catalogScope: "combo" }).success, + false, + "a near-miss value must be rejected rather than silently coerced" + ); + assert.equal(schemas.updateKeyPermissionsSchema.safeParse({ catalogScope: true }).success, false); +}); + +test("R4: the route forwards catalogScope into the payload", () => { + const route = read("src/app/api/keys/[id]/route.ts"); + assert.ok( + route.includes("if (catalogScope !== undefined) payload.catalogScope = catalogScope"), + "PATCH /api/keys/[id] must forward catalogScope to updateApiKeyPermissions" + ); +}); + +test("R5: the catalog skips exactly the rows the scope excludes", () => { + const catalog = read("src/app/api/v1/models/catalog.ts"); + assert.ok( + catalog.includes('const catalogScope = keyMeta.catalogScope ?? "all"'), + "the filter must read the key's scope, defaulting to all" + ); + assert.ok( + catalog.includes('if (catalogScope === "combos" && !isComboRow) continue;'), + "'combos' must drop provider-model rows" + ); + assert.ok( + catalog.includes('if (catalogScope === "models" && isComboRow) continue;'), + "'models' must drop combo rows" + ); +}); + +test("R6: the API Manager wires the control and defaults it to 'all'", () => { + const client = read("src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx"); + assert.ok(client.includes("ApiKeyCatalogScopeSelect"), "the modal must render the control"); + assert.ok( + client.includes('useState(apiKey?.catalogScope ?? "all")'), + "state must default to 'all' so a key predating the field is unchanged" + ); + assert.match( + client, + /body: JSON\.stringify\(\{[\s\S]*?catalogScope,[\s\S]*?\}\)/, + "the PATCH body must include catalogScope" + ); +}); diff --git a/tests/unit/model-catalog-policy-invalidation-8728.test.ts b/tests/unit/model-catalog-policy-invalidation-8728.test.ts index 144f350e2e..de7c9bd669 100644 --- a/tests/unit/model-catalog-policy-invalidation-8728.test.ts +++ b/tests/unit/model-catalog-policy-invalidation-8728.test.ts @@ -77,6 +77,12 @@ test("updateApiKeyPermissions increments only on catalog-affecting fields", asyn await apiKeys.updateApiKeyPermissions(created.id, { disableNonPublicModels: true }); assert.equal(catalogVersion(), ++version); + + await apiKeys.updateApiKeyPermissions(created.id, { allowAutoCombos: false }); + assert.equal(catalogVersion(), ++version); + + await apiKeys.updateApiKeyPermissions(created.id, { catalogScope: "combos" }); + assert.equal(catalogVersion(), ++version); }); test("isModelAllowedForKey cache recomputes when group permissions change", async () => { diff --git a/tests/unit/models-catalog-combo-access.test.ts b/tests/unit/models-catalog-combo-access.test.ts new file mode 100644 index 0000000000..10285cd32d --- /dev/null +++ b/tests/unit/models-catalog-combo-access.test.ts @@ -0,0 +1,78 @@ +/** + * A key's /v1/models must list the combos that key can actually dispatch. + * + * `allowedCombos` gates combos; `modelAccessMode` / `allowedModels` / + * `blockedModels` gate provider models. The catalog only ever consulted 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 the same key. + * + * Listing a combo the key can already dispatch grants no new access, so the fix + * is to gate combo rows on `allowedCombos` rather than hide them. + * + * Rules: + * R1 An absent allowedCombos means no combo restriction (matches validateComboAccess). + * R2 An explicit list admits exactly its combos. + * R3 The `combo/*` wildcard admits every combo. + * R4 The `combo/` prefix is normalised on both sides of the comparison. + * R5 An empty list admits nothing, and an empty combo name is never admitted. + * R6 The catalog routes combo rows through this gate, exempting auto/*. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +const { isComboNameAllowedForKey } = await import("../../src/shared/utils/apiKeyPolicy.ts"); +const { ALL_COMBOS_ACCESS_RULE } = await import("../../src/shared/constants/comboAccess.ts"); + +test("R1: an absent allowedCombos imposes no combo restriction", () => { + assert.equal(isComboNameAllowedForKey(undefined, "codex-sol"), true); + assert.equal(isComboNameAllowedForKey(null, "codex-sol"), true); +}); + +test("R2: an explicit list admits exactly its combos", () => { + const allowed = ["codex-sol", "claude-haiku"]; + assert.equal(isComboNameAllowedForKey(allowed, "codex-sol"), true); + assert.equal(isComboNameAllowedForKey(allowed, "claude-haiku"), true); + assert.equal(isComboNameAllowedForKey(allowed, "gemini-pro"), false); + // Names are compared whole — a prefix of an allowed name is not allowed. + assert.equal(isComboNameAllowedForKey(allowed, "codex"), false); + assert.equal(isComboNameAllowedForKey(allowed, "codex-sol-max"), false); +}); + +test("R3: the combo/* wildcard admits every combo", () => { + assert.equal(isComboNameAllowedForKey([ALL_COMBOS_ACCESS_RULE], "anything-at-all"), true); + assert.equal(isComboNameAllowedForKey([ALL_COMBOS_ACCESS_RULE], "codex-sol"), true); +}); + +test("R4: the combo/ prefix is normalised on both sides", () => { + assert.equal(isComboNameAllowedForKey(["combo/codex-sol"], "codex-sol"), true); + assert.equal(isComboNameAllowedForKey(["codex-sol"], "combo/codex-sol"), true); +}); + +test("R5: an empty list admits nothing; an empty name is never admitted", () => { + assert.equal(isComboNameAllowedForKey([], "codex-sol"), false); + assert.equal(isComboNameAllowedForKey(["codex-sol"], ""), false); +}); + +test("R6: the catalog gates combo rows on allowedCombos, exempting auto/*", () => { + const catalog = fs.readFileSync( + path.join(process.cwd(), "src/app/api/v1/models/catalog.ts"), + "utf8" + ); + assert.ok( + catalog.includes('if (m.owned_by === "combo" && !String(m.id).startsWith("auto/"))'), + "combo rows must take the allowedCombos branch, and auto/* must be exempt — " + + "auto/* fails open at dispatch and is already gated by allowAutoCombos" + ); + assert.ok( + catalog.includes("isComboNameAllowedForKey(keyMeta.allowedCombos, String(m.id))"), + "the branch must decide via the key's allowedCombos" + ); +}); diff --git a/tests/unit/models-catalog-combo-description.test.ts b/tests/unit/models-catalog-combo-description.test.ts new file mode 100644 index 0000000000..3baee41c7f --- /dev/null +++ b/tests/unit/models-catalog-combo-description.test.ts @@ -0,0 +1,99 @@ +/** + * Combos advertise their own description in `GET /v1/models`. + * + * A combo's description is stored in its record and returned by + * `GET /api/combos`, but the catalog row built in + * `src/app/api/v1/models/catalog.ts` never copied it, so no client could see 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. See + * https://code.claude.com/docs/en/llm-gateway-protocol.md#model-discovery + * + * Rules: + * R1 The combo row emits `description` when the combo has one. + * R2 It is omitted entirely when the combo has none, rather than sent empty. + * R3 The value is read defensively — a non-string description cannot leak through. + * R4 `comboMetadata` still spreads last, so it keeps precedence over the literal. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +// catalog.ts contains a NUL byte, so read it explicitly as UTF-8 text rather +// than relying on tools that sniff it as binary. +const catalog = fs.readFileSync( + path.join(process.cwd(), "src/app/api/v1/models/catalog.ts"), + "utf8" +); + +test("R1/R2: the combo row emits description only when the combo has one", () => { + assert.ok( + catalog.includes("...(comboDescription ? { description: comboDescription } : {})"), + "the combo row must spread description conditionally, so a combo without one is unchanged" + ); +}); + +test("R3: the description is narrowed to a trimmed string before use", () => { + assert.match( + catalog, + /const comboDescription\s*=\s*\n?\s*typeof combo\.description === "string" \? combo\.description\.trim\(\) : "";/, + "ComboRecord is Record, so the value must be typeof-narrowed and trimmed — " + + "a non-string must collapse to the empty string and be omitted" + ); +}); + +test("R5: an operator-set display_name is advertised, and omitted when unset", () => { + assert.ok( + catalog.includes("...(comboDisplayName ? { display_name: comboDisplayName } : {})"), + "display_name must be spread conditionally so combos without one are unchanged" + ); + assert.match( + catalog, + /const comboDisplayName\s*=\s*\n?\s*typeof combo\.displayName === "string" \? combo\.displayName\.trim\(\) : "";/, + "display_name must come from an operator-set field, typeof-narrowed and trimmed — " + + "never derived heuristically from the combo name" + ); +}); + +test("R6: the combo schemas accept displayName and count it as a real update", async () => { + const schemas = await import("../../src/shared/validation/schemas.ts"); + + // Without this the field is stripped by Zod and can never be set — the same + // silent no-op that made blockedModels unreachable through the API. + const upd = schemas.updateComboSchema.safeParse({ displayName: "Codex Sol" }); + assert.equal(upd.success, true, "displayName alone must be a valid combo update"); + if (upd.success) assert.equal(upd.data.displayName, "Codex Sol"); + + const created = schemas.createComboSchema.safeParse({ + name: "claude-codex-sol", + displayName: "Codex Sol", + models: [{ kind: "model", model: "codex/gpt-5.6-sol-xhigh", providerId: "codex" }], + }); + assert.equal(created.success, true, "create must accept displayName"); + if (created.success) assert.equal(created.data.displayName, "Codex Sol"); + + // Clearing it must be expressible, and a non-string rejected. + assert.equal(schemas.updateComboSchema.safeParse({ displayName: null }).success, true); + assert.equal(schemas.updateComboSchema.safeParse({ displayName: 42 }).success, false); +}); + +test("R4: comboMetadata still spreads after the literal fields", () => { + // Bound the slice by the block itself rather than a byte count, so adding + // another field to the row cannot silently make this assertion vacuous. + const rowStart = catalog.indexOf("listedIds.add(combo.name);"); + assert.ok(rowStart > -1, "combo row builder must exist"); + const rowEnd = catalog.indexOf("maybeYieldCatalogBuild", rowStart); + assert.ok(rowEnd > rowStart, "combo row builder must be followed by the yield call"); + const row = catalog.slice(rowStart, rowEnd); + const descIndex = row.indexOf("description: comboDescription"); + const metaIndex = row.indexOf("...comboMetadata"); + assert.ok(descIndex > -1 && metaIndex > -1, "both spreads must be present in the row"); + assert.ok( + metaIndex > descIndex, + "comboMetadata must spread last so context/capability metadata keeps precedence" + ); +});