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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Fouad Salkini
2026-09-18 00:53:16 +03:00
committed by GitHub
parent b9dd80c8c2
commit 176d632a2d
21 changed files with 794 additions and 9 deletions

View File

@@ -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.

View File

@@ -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."
}

View File

@@ -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<CatalogScope>(apiKey?.catalogScope ?? "all");
const [nameError, setNameError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const [selectedConnections, setSelectedConnections] = useState<string[]>(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)}
/>
<ApiKeyAutoCombosToggle
enabled={allowAutoCombos}
onToggle={() => setAllowAutoCombos((prev) => !prev)}
/>
<ApiKeyCatalogScopeSelect value={catalogScope} onChange={setCatalogScope} />
{/* Ban Toggle (SECURITY) */}
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-red-500/20 bg-red-500/5">
<div className="flex flex-col gap-1">

View File

@@ -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 (
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{tSettings("autoCombosTitle")}</p>
<p className="text-xs text-text-muted">{tSettings("autoCombosDesc")}</p>
</div>
<button
type="button"
role="switch"
aria-checked={enabled}
onClick={onToggle}
className={`inline-flex shrink-0 items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
enabled
? "bg-cyan-500/15 text-cyan-700 dark:text-cyan-300 border border-cyan-500/30"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
}`}
>
<span className="material-symbols-outlined text-[14px]">
{enabled ? "auto_awesome" : "block"}
</span>
{enabled ? tc("enabled") : tc("disabled")}
</button>
</div>
);
}

View File

@@ -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 (
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{tSettings("catalogScopeTitle")}</p>
<p className="text-xs text-text-muted">{tSettings("catalogScopeDesc")}</p>
</div>
<div className="flex items-center gap-1 p-1 rounded-lg bg-black/5 dark:bg-white/5">
{OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={value === opt.value}
onClick={() => onChange(opt.value)}
className={`flex-1 inline-flex items-center justify-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
value === opt.value
? "bg-primary text-white"
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
<span className="material-symbols-outlined text-[14px]">{opt.icon}</span>
{tSettings(opt.labelKey)}
</button>
))}
</div>
</div>
);
}

View File

@@ -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;

View File

@@ -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<typeof import("@/lib/db/apiKeys").getApiKeyMetadata>
> | 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<ReturnType<typeof prepareBuiltinAutoComboInputs>> | 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 (

View File

@@ -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",

View File

@@ -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",

View File

@@ -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;

View File

@@ -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<TRow = unknown> {
@@ -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<ApiKeyRow>(
"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<string, unknown>)
) {
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<string, unknown>, updates, params);
const maxSessionsUpdate = (normalized as Record<string, unknown>).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),
};

View File

@@ -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,
};
}

View File

@@ -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 {

View File

@@ -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<Response | n
}
}
/**
* Whether this key is barred from the built-in `auto/*` combos.
*
* `auto/*` ids are virtual, so they resolve to no stored combo and
* `isComboAllowedForKey()` fails open on them; `validateModelAccess()` then
* returns before the allow/deny model lists are consulted. This flag is the
* only per-key gate that reaches them. It defaults to allowed (undefined) so
* existing keys are unaffected.
*/
export function isAutoComboDeniedForKey(
apiKeyInfo: { allowAutoCombos?: boolean } | null | undefined,
modelStr: string | null | undefined
): boolean {
if (!modelStr || !modelStr.startsWith("auto/")) return false;
return apiKeyInfo?.allowAutoCombos === false;
}
async function validateModelAccess(context: PolicyContext): Promise<Response | null> {
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;

View File

@@ -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 &&

View File

@@ -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 &&

View File

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

View File

@@ -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<CatalogScope>(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"
);
});

View File

@@ -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 () => {

View File

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

View File

@@ -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<string, unknown>, 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"
);
});