From d6c283c8217a9872cbfe7bafa182b2adac1e3644 Mon Sep 17 00:00:00 2001 From: Lukin Date: Mon, 6 Jul 2026 21:17:35 +0800 Subject: [PATCH] Add per-key model blacklist Co-Authored-By: Codex --- apps/admin/src/lib/api/keys.test.ts | 26 ++ apps/admin/src/lib/api/keys.ts | 8 + .../src/lib/components/CreateKeyDialog.svelte | 2 + .../lib/components/CreateKeyDialog.test.ts | 4 + .../src/lib/components/EditKeyDialog.svelte | 6 +- .../src/lib/components/KeyCapsForm.svelte | 36 +++ .../routes/keys/[keyId]/key-detail.test.ts | 1 + apps/admin/src/routes/keys/keys.test.ts | 5 + apps/admin/src/routes/memory/memory.test.ts | 1 + .../src/routes/requests/requests.test.ts | 1 + apps/gateway/src/middleware/auth.test.ts | 1 + apps/gateway/src/middleware/auth.ts | 2 + apps/gateway/src/routes/admin/admin.test.ts | 17 +- apps/gateway/src/routes/admin/deps.ts | 1 + apps/gateway/src/routes/admin/keys.ts | 5 + apps/gateway/src/routes/admin/replay.test.ts | 5 +- apps/gateway/src/routes/admin/replay.ts | 7 +- apps/gateway/src/routes/chat.errors.test.ts | 1 + apps/gateway/src/routes/chat.inject.test.ts | 1 + apps/gateway/src/routes/chat.memory.test.ts | 1 + apps/gateway/src/routes/chat.route.test.ts | 1 + .../src/routes/chat.session-key.test.ts | 1 + apps/gateway/src/routes/chat.ts | 2 + apps/gateway/src/routes/images.test.ts | 84 +++++++ apps/gateway/src/routes/images.ts | 43 +++- apps/gateway/src/routes/interactions.test.ts | 88 +++++++ apps/gateway/src/routes/interactions.ts | 37 ++- apps/gateway/src/routes/mcp/mcp.test.ts | 1 + apps/gateway/src/routes/mcp/oauth.test.ts | 1 + apps/gateway/src/routes/mcp/oauth.ts | 1 + .../src/routes/messages-pipeline.test.ts | 2 + apps/gateway/src/routes/messages-pipeline.ts | 8 +- apps/gateway/src/routes/messages.ts | 2 + apps/gateway/src/routes/models.test.ts | 15 ++ apps/gateway/src/routes/models.ts | 1 + apps/gateway/src/routes/usage.test.ts | 1 + apps/gateway/src/server.ts | 3 + implementation-notes.md | 133 ++--------- packages/core/src/catalog/models-list.test.ts | 35 +++ packages/core/src/catalog/models-list.ts | 16 +- .../core/src/routing/route-request.test.ts | 135 +++++++++++ packages/core/src/routing/route-request.ts | 223 ++++++++++++++---- .../core/src/store/cached-keystore.test.ts | 1 + packages/core/src/store/ports.test.ts | 3 + packages/core/src/store/ports.ts | 5 + packages/core/src/store/postgres/keystore.ts | 4 + .../core/src/store/postgres/migrate.test.ts | 34 +++ packages/core/src/store/postgres/migrate.ts | 14 +- packages/core/src/store/postgres/schema.ts | 1 + .../core/src/store/sqlite/keystore.test.ts | 7 +- packages/core/src/store/sqlite/keystore.ts | 6 + packages/core/src/store/sqlite/migrate.ts | 13 + packages/core/src/store/sqlite/schema.test.ts | 1 + packages/core/src/store/sqlite/schema.ts | 1 + .../core/src/store/store-contract.test.ts | 5 +- packages/shared/src/key/schema.test.ts | 18 ++ packages/shared/src/key/schema.ts | 9 + 57 files changed, 899 insertions(+), 187 deletions(-) diff --git a/apps/admin/src/lib/api/keys.test.ts b/apps/admin/src/lib/api/keys.test.ts index 68caeec0..598ba5e5 100644 --- a/apps/admin/src/lib/api/keys.test.ts +++ b/apps/admin/src/lib/api/keys.test.ts @@ -29,6 +29,7 @@ function summaryRow( role: 'user', allowed_lanes: null, allow_custom_model: false, + blocked_models: null, allow_fast_mode: false, disabled: false, rate_limit_rpm: null, @@ -119,6 +120,7 @@ describe('keys api client', () => { role: 'user', allowed_lanes: ['economy', 'balanced'], allow_custom_model: false, + blocked_models: ['gpt-4o'], allow_fast_mode: true, }); @@ -129,6 +131,7 @@ describe('keys api client', () => { expect(body.role).toBe('user'); expect(body.allowed_lanes).toEqual(['economy', 'balanced']); expect(body.allow_custom_model).toBe(false); + expect(body.blocked_models).toEqual(['gpt-4o']); expect(body.allow_fast_mode).toBe(true); expect(result.key_id).toBe('key_1'); expect(result.plaintext).toBe('helm_live_SECRET_ONCE'); @@ -150,9 +153,20 @@ describe('keys api client', () => { const body = JSON.parse(init.body as string); expect(body).not.toHaveProperty('max_lane'); expect(body).not.toHaveProperty('allowed_lanes'); + expect(body).not.toHaveProperty('blocked_models'); expect(body.role).toBe('user'); }); + it('listKeys surfaces blocked model ids as null-or-array', async () => { + const rows = [summaryRow('k1'), summaryRow('k2', { blocked_models: ['gpt-4o'] })]; + (fetch as ReturnType).mockResolvedValue( + new Response(JSON.stringify(rows), { status: 200 }), + ); + const keys = await listKeys(); + expect(keys[0].blocked_models).toBeNull(); + expect(keys[1].blocked_models).toEqual(['gpt-4o']); + }); + it('listKeys surfaces per-key rate limits (null = inherit, number = override)', async () => { const rows = [summaryRow('k1'), summaryRow('k2', { rate_limit_rpm: 60, rate_limit_tpm: 0 })]; (fetch as ReturnType).mockResolvedValue( @@ -267,6 +281,7 @@ describe('keys api client', () => { await updateKey('key_1', { allowed_lanes: ['economy', 'balanced'], allow_custom_model: true, + blocked_models: ['gpt-4o'], allow_fast_mode: true, rate_limit_rpm: null, rate_limit_tpm: 100, @@ -277,6 +292,7 @@ describe('keys api client', () => { const body = JSON.parse(init.body as string); expect(body.allowed_lanes).toEqual(['economy', 'balanced']); expect(body.allow_custom_model).toBe(true); + expect(body.blocked_models).toEqual(['gpt-4o']); expect(body.allow_fast_mode).toBe(true); expect(body.rate_limit_rpm).toBeNull(); // explicit null = clear expect(body.rate_limit_tpm).toBe(100); @@ -292,6 +308,16 @@ describe('keys api client', () => { expect(body.allowed_lanes).toBeNull(); }); + it('updateKey forwards null to clear the blocked-model blacklist', async () => { + (fetch as ReturnType).mockResolvedValue( + new Response(JSON.stringify({ key_id: 'key_1' }), { status: 200 }), + ); + await updateKey('key_1', { blocked_models: null }); + const [, init] = (fetch as ReturnType).mock.calls[0]; + const body = JSON.parse(init.body as string); + expect(body.blocked_models).toBeNull(); + }); + it('updateKey rejects on a non-2xx response (404)', async () => { (fetch as ReturnType).mockResolvedValue( new Response(JSON.stringify({ error: 'key not found' }), { status: 404 }), diff --git a/apps/admin/src/lib/api/keys.ts b/apps/admin/src/lib/api/keys.ts index 9def817e..b94499ae 100644 --- a/apps/admin/src/lib/api/keys.ts +++ b/apps/admin/src/lib/api/keys.ts @@ -17,6 +17,7 @@ export interface ApiKeyView { name: string | null; // human-readable label; null = unnamed (cosmetic only) allowed_lanes: string[] | null; // lane whitelist (empty/null = any lane) allow_custom_model: boolean; // explicit client-model passthrough + blocked_models: string[] | null; // exact model ids denied across direct and lane routes allow_fast_mode: boolean; // explicit client-requested Fast mode passthrough disabled: boolean; // revoked state (soft) rate_limit_rpm: number | null; // per-key RPM override; null = inherit system default @@ -49,6 +50,7 @@ export interface CreateKeyInput { name?: string; allowed_lanes?: string[]; allow_custom_model?: boolean; + blocked_models?: string[]; allow_fast_mode?: boolean; // Optional per-key rate limits at mint time. Omitted => inherit the system // default. 0 => explicitly unlimited for that dimension. @@ -81,6 +83,7 @@ export interface UpdateKeyInput { name?: string | null; allowed_lanes?: string[] | null; allow_custom_model?: boolean; + blocked_models?: string[] | null; allow_fast_mode?: boolean; rate_limit_rpm?: number | null; rate_limit_tpm?: number | null; @@ -177,6 +180,7 @@ async function asJson(res: Response): Promise { // leak a secret even if the server response ever changed shape (defence in depth). function normalizeView(raw: Record): ApiKeyView { const allowed = raw.allowed_lanes; + const blocked = raw.blocked_models; return { key_id: String(raw.key_id ?? ''), prefix: String(raw.prefix ?? ''), @@ -186,6 +190,7 @@ function normalizeView(raw: Record): ApiKeyView { name: typeof raw.name === 'string' && raw.name.trim().length > 0 ? raw.name.trim() : null, allowed_lanes: Array.isArray(allowed) ? allowed.map(String) : null, allow_custom_model: raw.allow_custom_model === true, + blocked_models: Array.isArray(blocked) ? blocked.map(String) : null, allow_fast_mode: raw.allow_fast_mode === true, disabled: raw.disabled === true, // null/absent = inherit system default; a finite number (incl. 0) = override. @@ -219,6 +224,9 @@ function toServerBody(input: CreateKeyInput): Record { if (input.allow_custom_model !== undefined) { out.allow_custom_model = input.allow_custom_model; } + if (input.blocked_models && input.blocked_models.length > 0) { + out.blocked_models = input.blocked_models; + } if (input.allow_fast_mode !== undefined) { out.allow_fast_mode = input.allow_fast_mode; } diff --git a/apps/admin/src/lib/components/CreateKeyDialog.svelte b/apps/admin/src/lib/components/CreateKeyDialog.svelte index d83350d2..bdf94489 100644 --- a/apps/admin/src/lib/components/CreateKeyDialog.svelte +++ b/apps/admin/src/lib/components/CreateKeyDialog.svelte @@ -59,6 +59,7 @@ const trimmedName = name.trim(); if (trimmedName.length > 0) input.name = trimmedName; if (form.allowedLanes.length > 0) input.allowed_lanes = [...form.allowedLanes]; + if (form.blockedModels.length > 0) input.blocked_models = [...form.blockedModels]; // Send a rate limit only when the operator set one; blank => inherit default. // `!= null` also catches the `undefined` Svelte 5 gives an emptied number input. if (form.rpm != null) input.rate_limit_rpm = form.rpm; @@ -113,6 +114,7 @@ name: name.trim().length > 0 ? name.trim() : null, allowed_lanes: form.allowedLanes.length > 0 ? [...form.allowedLanes] : null, allow_custom_model: form.allowCustomModel, + blocked_models: form.blockedModels.length > 0 ? [...form.blockedModels] : null, allow_fast_mode: form.allowFastMode, disabled: false, rate_limit_rpm: form.rpm ?? null, diff --git a/apps/admin/src/lib/components/CreateKeyDialog.test.ts b/apps/admin/src/lib/components/CreateKeyDialog.test.ts index 2009b6c2..9861fe1b 100644 --- a/apps/admin/src/lib/components/CreateKeyDialog.test.ts +++ b/apps/admin/src/lib/components/CreateKeyDialog.test.ts @@ -41,6 +41,9 @@ describe('CreateKeyDialog', () => { // Tick a subset of lanes; the whitelist is the only lane cap (no max-lane field). await fireEvent.click(screen.getByLabelText('economy')); await fireEvent.click(screen.getByLabelText('balanced')); + await fireEvent.input(screen.getByLabelText('Blocked models'), { + target: { value: 'gpt-4o\nanthropic/claude-sonnet-4-6' }, + }); await fireEvent.click(screen.getByLabelText('allow client-requested Fast mode')); // allow_custom_model defaults to false; leave that checkbox unchecked. await fireEvent.click(screen.getByRole('button', { name: /create key/i })); @@ -48,6 +51,7 @@ describe('CreateKeyDialog', () => { await waitFor(() => expect(createKey).toHaveBeenCalledTimes(1)); const input = createKey.mock.calls[0][0]; expect(input.allowed_lanes).toEqual(['economy', 'balanced']); + expect(input.blocked_models).toEqual(['gpt-4o', 'anthropic/claude-sonnet-4-6']); expect(input.allow_custom_model).toBe(false); expect(input.allow_fast_mode).toBe(true); }); diff --git a/apps/admin/src/lib/components/EditKeyDialog.svelte b/apps/admin/src/lib/components/EditKeyDialog.svelte index 6371f667..0179fb18 100644 --- a/apps/admin/src/lib/components/EditKeyDialog.svelte +++ b/apps/admin/src/lib/components/EditKeyDialog.svelte @@ -46,6 +46,7 @@ name: name.trim().length > 0 ? name.trim() : null, allowed_lanes: form.allowedLanes.length > 0 ? [...form.allowedLanes] : null, allow_custom_model: form.allowCustomModel, + blocked_models: form.blockedModels.length > 0 ? [...form.blockedModels] : null, allow_fast_mode: form.allowFastMode, rate_limit_rpm: form.rpm ?? null, rate_limit_tpm: form.tpm ?? null, @@ -68,6 +69,7 @@ name: patch.name ?? null, allowed_lanes: patch.allowed_lanes ?? null, allow_custom_model: form.allowCustomModel, + blocked_models: patch.blocked_models ?? null, allow_fast_mode: form.allowFastMode, rate_limit_rpm: patch.rate_limit_rpm ?? null, rate_limit_tpm: patch.rate_limit_tpm ?? null, @@ -128,7 +130,9 @@ bind:value={name} /> {$t('A label to help you recognize this key later — e.g. the project it belongs to.')}{$t( + 'A label to help you recognize this key later — e.g. the project it belongs to.', + )} diff --git a/apps/admin/src/lib/components/KeyCapsForm.svelte b/apps/admin/src/lib/components/KeyCapsForm.svelte index 4322f7ad..2cfb0335 100644 --- a/apps/admin/src/lib/components/KeyCapsForm.svelte +++ b/apps/admin/src/lib/components/KeyCapsForm.svelte @@ -7,6 +7,7 @@ export type KeyCaps = { allowedLanes: string[]; allowCustomModel: boolean; + blockedModels: string[]; allowFastMode: boolean; rpm: number | null; tpm: number | null; @@ -27,6 +28,7 @@ return { allowedLanes: [], allowCustomModel: false, + blockedModels: [], allowFastMode: false, rpm: null, tpm: null, @@ -50,6 +52,7 @@ return { allowedLanes: [...(key.allowed_lanes ?? [])], allowCustomModel: key.allow_custom_model, + blockedModels: [...(key.blocked_models ?? [])], allowFastMode: key.allow_fast_mode, rpm: key.rate_limit_rpm, tpm: key.rate_limit_tpm, @@ -65,6 +68,18 @@ memoryThreadSource: key.memory_thread_source, }; } + + export function parseBlockedModels(value: string): string[] { + const out: string[] = []; + const seen = new Set(); + for (const raw of value.split(/[\n,;]+/)) { + const model = raw.trim(); + if (model.length === 0 || seen.has(model)) continue; + seen.add(model); + out.push(model); + } + return out; + }