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

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