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" + ); +});