Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions apps/admin/src/lib/api/keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});

Expand All @@ -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');
Expand All @@ -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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand All @@ -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<typeof vi.fn>).mockResolvedValue(
new Response(JSON.stringify({ key_id: 'key_1' }), { status: 200 }),
);
await updateKey('key_1', { blocked_models: null });
const [, init] = (fetch as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(
new Response(JSON.stringify({ error: 'key not found' }), { status: 404 }),
Expand Down
8 changes: 8 additions & 0 deletions apps/admin/src/lib/api/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -177,6 +180,7 @@ async function asJson<T>(res: Response): Promise<T> {
// leak a secret even if the server response ever changed shape (defence in depth).
function normalizeView(raw: Record<string, unknown>): ApiKeyView {
const allowed = raw.allowed_lanes;
const blocked = raw.blocked_models;
return {
key_id: String(raw.key_id ?? ''),
prefix: String(raw.prefix ?? ''),
Expand All @@ -186,6 +190,7 @@ function normalizeView(raw: Record<string, unknown>): 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.
Expand Down Expand Up @@ -219,6 +224,9 @@ function toServerBody(input: CreateKeyInput): Record<string, unknown> {
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;
}
Expand Down
2 changes: 2 additions & 0 deletions apps/admin/src/lib/components/CreateKeyDialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions apps/admin/src/lib/components/CreateKeyDialog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,17 @@ 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 }));

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);
});
Expand Down
6 changes: 5 additions & 1 deletion apps/admin/src/lib/components/EditKeyDialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -128,7 +130,9 @@
bind:value={name}
/>
<span class="field-help"
>{$t('A label to help you recognize this key later — e.g. the project it belongs to.')}</span
>{$t(
'A label to help you recognize this key later — e.g. the project it belongs to.',
)}</span
>
</label>

Expand Down
36 changes: 36 additions & 0 deletions apps/admin/src/lib/components/KeyCapsForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
export type KeyCaps = {
allowedLanes: string[];
allowCustomModel: boolean;
blockedModels: string[];
allowFastMode: boolean;
rpm: number | null;
tpm: number | null;
Expand All @@ -27,6 +28,7 @@
return {
allowedLanes: [],
allowCustomModel: false,
blockedModels: [],
allowFastMode: false,
rpm: null,
tpm: null,
Expand All @@ -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,
Expand All @@ -65,6 +68,18 @@
memoryThreadSource: key.memory_thread_source,
};
}

export function parseBlockedModels(value: string): string[] {
const out: string[] = [];
const seen = new Set<string>();
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;
}
</script>

<script lang="ts">
Expand Down Expand Up @@ -113,6 +128,10 @@
: form.allowedLanes.filter((l) => l !== lane);
}

function updateBlockedModels(value: string): void {
form.blockedModels = parseBlockedModels(value);
}

// One-line state recaps shown on the closed section headers.
const ratesSummary = $derived.by(() => {
const parts: string[] = [];
Expand Down Expand Up @@ -200,6 +219,23 @@
>
</div>

<label class="flex flex-col gap-1 text-sm">
<span class="field-label">{$t('Blocked models')}</span>
<textarea
rows="3"
aria-label={$t('Blocked models')}
placeholder={$t('One model id per line')}
class="input min-h-20 resize-y"
value={form.blockedModels.join('\n')}
oninput={(e) => updateBlockedModels(e.currentTarget.value)}
></textarea>
<span class="field-help"
>{$t(
'Blocks exact model ids for this key across direct model requests and all lane/fallback routes.',
)}</span
>
</label>

<div class="flex flex-col gap-1">
<label class="checkbox-field">
<input
Expand Down
1 change: 1 addition & 0 deletions apps/admin/src/routes/keys/[keyId]/key-detail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ function keyView(overrides: Partial<ApiKeyView> = {}): ApiKeyView {
name: 'Prod backend',
allowed_lanes: ['balanced'],
allow_custom_model: false,
blocked_models: null,
allow_fast_mode: false,
disabled: false,
rate_limit_rpm: null,
Expand Down
5 changes: 5 additions & 0 deletions apps/admin/src/routes/keys/keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ function key(keyId: string, overrides: Partial<ApiKeyView> = {}): ApiKeyView {
name: null,
allowed_lanes: null,
allow_custom_model: false,
blocked_models: null,
allow_fast_mode: false,
disabled: false,
rate_limit_rpm: null,
Expand Down Expand Up @@ -365,13 +366,17 @@ describe('keys page', () => {
await fireEvent.input(within(dialog).getByLabelText(/requests per minute/i), {
target: { value: '120' },
});
await fireEvent.input(within(dialog).getByLabelText('Blocked models'), {
target: { value: 'gpt-4o' },
});
await fireEvent.click(within(dialog).getByRole('button', { name: /save changes/i }));
await waitFor(() =>
expect(updateKey).toHaveBeenCalledWith('k1', {
// Name untouched in this edit → still unnamed (null), never undefined.
name: null,
allowed_lanes: ['economy'],
allow_custom_model: false,
blocked_models: ['gpt-4o'],
allow_fast_mode: false,
rate_limit_rpm: 120,
rate_limit_tpm: null, // untouched → still inherit (null), not undefined
Expand Down
1 change: 1 addition & 0 deletions apps/admin/src/routes/memory/memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ function key(keyId: string, overrides: Partial<ApiKeyView> = {}): ApiKeyView {
name: null,
allowed_lanes: null,
allow_custom_model: false,
blocked_models: null,
allow_fast_mode: false,
disabled: false,
rate_limit_rpm: null,
Expand Down
1 change: 1 addition & 0 deletions apps/admin/src/routes/requests/requests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ function apiKey(keyId: string, overrides: Partial<ApiKeyView> = {}): ApiKeyView
name: null,
allowed_lanes: null,
allow_custom_model: false,
blocked_models: null,
allow_fast_mode: false,
disabled: false,
rate_limit_rpm: null,
Expand Down
1 change: 1 addition & 0 deletions apps/gateway/src/middleware/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ function record(overrides: Partial<ApiKeyRecord> = {}): ApiKeyRecord {
name: null,
allowed_lanes: ["economy", "balanced"],
allow_custom_model: false,
blocked_models: null,
allow_fast_mode: false,
disabled: false,
rate_limit_rpm: null,
Expand Down
2 changes: 2 additions & 0 deletions apps/gateway/src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface AuthIdentity {
caps: {
allowedLanes: string[] | null;
allowCustomModel: boolean;
blockedModels: string[] | null;
/** Per-key cap for client-requested Fast mode passthrough. Account-level Fast
* mode can still be forced by subscription account settings. */
allowFastMode: boolean;
Expand Down Expand Up @@ -105,6 +106,7 @@ export function authMiddleware(deps: AuthDeps): MiddlewareHandler {
caps: {
allowedLanes: record.allowed_lanes,
allowCustomModel: record.allow_custom_model,
blockedModels: record.blocked_models,
allowFastMode: record.allow_fast_mode,
rateLimit: { rpm: record.rate_limit_rpm, tpm: record.rate_limit_tpm },
concurrencyLimit: record.concurrency_limit,
Expand Down
Loading
Loading