Skip to content

Commit 771ee5a

Browse files
authored
Merge pull request #300 from mcowger/claude/issue-299-20260426-1749
feat: add excludedModels/excludedProviders to key access policy
2 parents d78abad + b3c8133 commit 771ee5a

15 files changed

Lines changed: 305 additions & 17 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,15 +82,14 @@ Backend tests run on **Vitest** with two parallel projects (sqlite + postgres) d
8282
| Setting | Value | Effect |
8383
|---------|-------|--------|
8484
| `pool` | `forks` | Each test file runs in a child process fork |
85-
| `isolate` | `false` | All files in one worker share the **same module registry** — no reset between files |
86-
| `maxWorkers` | `1` | One fork at a time (sequential files within each project) |
85+
| `isolate` | `true` | Each test file gets its own module registry — modules are re-imported fresh per file |
8786
| `mockReset` | `true` | `vi.resetAllMocks()` before every test — clears `vi.fn()` call history and resets implementations to original |
8887
| `clearMocks` | `true` | Clears call history (redundant with mockReset but explicit) |
8988
| `restoreMocks` | `true` | Restores `vi.spyOn` mocks to originals after every test |
9089

9190
**Global setup:** `packages/backend/test/vitest.global-setup.ts` — creates a temporary DB, runs migrations once, cleans up after the run. Runs once total.
9291

93-
**Per-file setup:** `packages/backend/test/vitest.setup.ts`**runs once per test file** (even with `isolate: false`). Installs the logger mock and the `@mariozechner/pi-ai` mock.
92+
**Per-file setup:** `packages/backend/test/vitest.setup.ts`**runs once per test file**. Installs the logger mock and the `@mariozechner/pi-ai` mock.
9493

9594
**Rule 4 — Test shared mutable state through the system under test, not through direct mutation.**
9695
The `utils/logger` mock in `vitest.setup.ts` closes over a `currentLogLevel` variable that both the mock functions and route handlers share. Adding a competing `vi.mock` for the same path in a test file can bind different closures to different variables, causing silent mismatches. Tests for stateful behaviour (e.g., logging routes) must interact exclusively through the API/HTTP layer — never by calling `setCurrentLogLevel` or `getCurrentLogLevel` directly from test code.
@@ -135,14 +134,14 @@ This means the global mock's `complete: vi.fn(async () => ({...}))` is safe —
135134

136135
### Singletons and test isolation
137136

138-
Several services are singletons (e.g., `OAuthAuthManager`, `CooldownManager`, `DebugManager`). Always reset them in `beforeEach`/`afterEach` using their provided `resetForTesting()` or equivalent methods. With `isolate: false`, a singleton left in a non-default state in one test leaks into every subsequent test in the same worker.
137+
Several services are singletons (e.g., `OAuthAuthManager`, `CooldownManager`, `DebugManager`). Always reset them in `beforeEach`/`afterEach` using their provided `resetForTesting()` or equivalent methods. With `pool: forks` and `isolate: true`, singletons are re-initialized per file, but can still leak state between tests within the same file.
139138

140139
### Other rules
141140
- `packages/backend/bunfig.toml` blocks raw `bun test` — use `bun run test` / `bun run test:watch`
142141
- Root `bunfig.toml` blocks raw `bun test` at repo root — use `cd packages/backend && bun run test`
143142
- **Prefer `bun run test` (affected only) over `bun run test:force-all`.** The default test command uses `--changed HEAD` and runs only tests affected by uncommitted changes — use it unless you have a specific reason to run the full suite (e.g., verifying a cross-cutting refactor or diagnosing flakiness unrelated to your changes). Never reach for `test:force-all` out of habit.
144143
- If you must mock a module, implement its **full public interface**
145-
- Do not use `__mocks__` directories for node_modules mocks — they are not reliably loaded with `isolate: false` when the real module may already be cached
144+
- Do not use `__mocks__` directories for node_modules mocks — they are not reliably loaded by Vitest with `pool: forks`
146145

147146
---
148147

packages/backend/drizzle/schema/postgres/api-keys.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export const apiKeys = pgTable('api_keys', {
99
quotaName: text('quota_name'),
1010
allowedModels: text('allowed_models'),
1111
allowedProviders: text('allowed_providers'),
12+
excludedModels: text('excluded_models'),
13+
excludedProviders: text('excluded_providers'),
1214
createdAt: bigint('created_at', { mode: 'number' }).notNull(),
1315
updatedAt: bigint('updated_at', { mode: 'number' }).notNull(),
1416
});

packages/backend/drizzle/schema/sqlite/api-keys.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export const apiKeys = sqliteTable('api_keys', {
99
quotaName: text('quota_name'),
1010
allowedModels: text('allowed_models'),
1111
allowedProviders: text('allowed_providers'),
12+
excludedModels: text('excluded_models'),
13+
excludedProviders: text('excluded_providers'),
1214
createdAt: integer('created_at').notNull(),
1315
updatedAt: integer('updated_at').notNull(),
1416
});

packages/backend/src/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,8 @@ export const KeyConfigSchema = z.object({
555555
quota: z.string().optional(), // References a quota definition name
556556
allowedModels: z.array(z.string().min(1)).optional(),
557557
allowedProviders: z.array(z.string().min(1)).optional(),
558+
excludedModels: z.array(z.string().min(1)).optional(),
559+
excludedProviders: z.array(z.string().min(1)).optional(),
558560
});
559561

560562
const QuotaConfigSchema = z.object({

packages/backend/src/db/config-repository.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,13 +753,17 @@ export class ConfigRepository {
753753
for (const row of rows) {
754754
const allowedModels = parseStringArray(row.allowedModels);
755755
const allowedProviders = parseStringArray(row.allowedProviders);
756+
const excludedModels = parseStringArray(row.excludedModels);
757+
const excludedProviders = parseStringArray(row.excludedProviders);
756758

757759
result[row.name] = {
758760
secret: decrypt(row.secret),
759761
...(row.comment ? { comment: row.comment } : {}),
760762
...(row.quotaName ? { quota: row.quotaName } : {}),
761763
...(allowedModels ? { allowedModels } : {}),
762764
...(allowedProviders ? { allowedProviders } : {}),
765+
...(excludedModels ? { excludedModels } : {}),
766+
...(excludedProviders ? { excludedProviders } : {}),
763767
};
764768
}
765769

@@ -798,6 +802,8 @@ export class ConfigRepository {
798802
const row = rows[0]!;
799803
const allowedModels = parseStringArray(row.allowedModels);
800804
const allowedProviders = parseStringArray(row.allowedProviders);
805+
const excludedModels = parseStringArray(row.excludedModels);
806+
const excludedProviders = parseStringArray(row.excludedProviders);
801807

802808
return {
803809
name: row.name,
@@ -807,6 +813,8 @@ export class ConfigRepository {
807813
...(row.quotaName ? { quota: row.quotaName } : {}),
808814
...(allowedModels ? { allowedModels } : {}),
809815
...(allowedProviders ? { allowedProviders } : {}),
816+
...(excludedModels ? { excludedModels } : {}),
817+
...(excludedProviders ? { excludedProviders } : {}),
810818
},
811819
};
812820
}
@@ -833,6 +841,8 @@ export class ConfigRepository {
833841
quotaName: config.quota ?? null,
834842
allowedModels: stringifyStringArray(config.allowedModels),
835843
allowedProviders: stringifyStringArray(config.allowedProviders),
844+
excludedModels: stringifyStringArray(config.excludedModels),
845+
excludedProviders: stringifyStringArray(config.excludedProviders),
836846
updatedAt: timestamp,
837847
})
838848
.where(eq(schema.apiKeys.name, name));
@@ -847,6 +857,8 @@ export class ConfigRepository {
847857
quotaName: config.quota ?? null,
848858
allowedModels: stringifyStringArray(config.allowedModels),
849859
allowedProviders: stringifyStringArray(config.allowedProviders),
860+
excludedModels: stringifyStringArray(config.excludedModels),
861+
excludedProviders: stringifyStringArray(config.excludedProviders),
850862
createdAt: timestamp,
851863
updatedAt: timestamp,
852864
});

packages/backend/src/routes/inference/__tests__/auth.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,3 +522,85 @@ describe('Key Access Policy Propagation', () => {
522522
});
523523
});
524524
});
525+
526+
describe('Key Access Policy Exclusion Propagation', () => {
527+
let fastify: FastifyInstance;
528+
let mockUsageStorage: UsageStorageService;
529+
let capturedRequest: any;
530+
531+
beforeAll(async () => {
532+
fastify = Fastify();
533+
capturedRequest = null;
534+
535+
const mockDispatcher = {
536+
dispatch: vi.fn(async (request: any) => {
537+
capturedRequest = request;
538+
return {
539+
id: '123',
540+
model: 'gpt-4',
541+
created: 123,
542+
content: 'test content',
543+
usage: { input_tokens: 10, output_tokens: 10, total_tokens: 20 },
544+
};
545+
}),
546+
} as unknown as Dispatcher;
547+
548+
mockUsageStorage = {
549+
saveRequest: vi.fn(),
550+
saveError: vi.fn(),
551+
updatePerformanceMetrics: vi.fn(),
552+
emitStartedAsync: vi.fn(),
553+
emitUpdatedAsync: vi.fn(),
554+
} as unknown as UsageStorageService;
555+
556+
DebugManager.getInstance().setStorage(mockUsageStorage);
557+
SelectorFactory.setUsageStorage(mockUsageStorage);
558+
559+
setConfigForTesting({
560+
providers: {},
561+
models: {
562+
'gpt-4': {
563+
priority: 'selector',
564+
targets: [{ provider: 'openai', model: 'gpt-4' }],
565+
},
566+
},
567+
keys: {
568+
excluded: {
569+
secret: 'sk-excluded-key',
570+
excludedModels: ['claude-3-opus', 'gpt-5'],
571+
excludedProviders: ['azure-openai', 'google'],
572+
},
573+
},
574+
failover: {
575+
enabled: false,
576+
retryableStatusCodes: [429, 500, 502, 503, 504],
577+
retryableErrors: ['ECONNREFUSED', 'ETIMEDOUT'],
578+
},
579+
quotas: [],
580+
});
581+
582+
await registerInferenceRoutes(fastify, mockDispatcher, mockUsageStorage);
583+
await fastify.ready();
584+
});
585+
586+
it('attaches excluded models and providers policy metadata', async () => {
587+
const response = await fastify.inject({
588+
method: 'POST',
589+
url: '/v1/chat/completions',
590+
headers: {
591+
authorization: 'Bearer sk-excluded-key',
592+
'content-type': 'application/json',
593+
},
594+
payload: {
595+
model: 'gpt-4',
596+
messages: [],
597+
},
598+
});
599+
600+
expect(response.statusCode).toBe(200);
601+
expect(capturedRequest?.metadata?.plexus_metadata?.plexus_key_policy).toEqual({
602+
excludedModels: ['claude-3-opus', 'gpt-5'],
603+
excludedProviders: ['azure-openai', 'google'],
604+
});
605+
});
606+
});

packages/backend/src/routes/management.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ export async function registerManagementRoutes(
6060
keyName: p.keyName,
6161
allowedProviders: p.allowedProviders,
6262
allowedModels: p.allowedModels,
63+
excludedProviders: p.excludedProviders,
64+
excludedModels: p.excludedModels,
6365
quotaName: p.quotaName ?? null,
6466
comment: p.comment ?? null,
6567
});

packages/backend/src/routes/management/_principal.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export type Principal =
3131
keyName: string;
3232
allowedProviders: string[];
3333
allowedModels: string[];
34+
excludedProviders: string[];
35+
excludedModels: string[];
3436
quotaName?: string | null;
3537
comment?: string | null;
3638
};
@@ -94,6 +96,8 @@ export async function resolvePrincipal(request: FastifyRequest): Promise<Princip
9496
const cfg = matched.cfg as {
9597
allowedProviders?: string[];
9698
allowedModels?: string[];
99+
excludedProviders?: string[];
100+
excludedModels?: string[];
97101
quota?: string | null;
98102
comment?: string | null;
99103
};
@@ -102,6 +106,8 @@ export async function resolvePrincipal(request: FastifyRequest): Promise<Princip
102106
keyName: matched.name,
103107
allowedProviders: cfg.allowedProviders ?? [],
104108
allowedModels: cfg.allowedModels ?? [],
109+
excludedProviders: cfg.excludedProviders ?? [],
110+
excludedModels: cfg.excludedModels ?? [],
105111
quotaName: cfg.quota ?? null,
106112
comment: cfg.comment ?? null,
107113
};

packages/backend/src/routes/management/self.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ export async function registerSelfRoutes(fastify: FastifyInstance, quotaEnforcer
6767
keyName: principal.keyName,
6868
allowedProviders: principal.allowedProviders,
6969
allowedModels: principal.allowedModels,
70+
excludedProviders: principal.excludedProviders,
71+
excludedModels: principal.excludedModels,
7072
quotaName: principal.quotaName ?? null,
7173
comment: keyRow?.comment ?? principal.comment ?? null,
7274
traceEnabled: DebugManager.getInstance().isEnabledForKey(principal.keyName),

packages/backend/src/services/__tests__/dispatcher-failover.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,4 +460,104 @@ describe('Dispatcher Failover', () => {
460460

461461
expect(fetchMock).toHaveBeenCalledTimes(0);
462462
});
463+
464+
test('provider denylist filters out excluded providers', async () => {
465+
setConfigForTesting(makeConfig({ targetCount: 2 }));
466+
fetchMock.mockImplementation(async () => successChatResponse('model-1'));
467+
468+
const dispatcher = new Dispatcher();
469+
const response = await dispatcher.dispatch({
470+
...makeChatRequest(),
471+
metadata: {
472+
plexus_metadata: {
473+
plexus_key_policy: {
474+
excludedProviders: ['p1'],
475+
},
476+
},
477+
},
478+
});
479+
const meta = (response as any).plexus;
480+
481+
expect(meta?.attemptCount).toBe(1);
482+
expect(meta?.finalAttemptProvider).toBe('p2');
483+
expect(fetchMock).toHaveBeenCalledTimes(1);
484+
expect(String((fetchMock as any).mock.calls[0]?.[0])).toContain('p2.example.com');
485+
});
486+
487+
test('model denylist blocks excluded aliases with 403 before fetch', async () => {
488+
setConfigForTesting(makeConfig({ targetCount: 2 }));
489+
490+
const dispatcher = new Dispatcher();
491+
492+
await expect(
493+
dispatcher.dispatch({
494+
...makeChatRequest(),
495+
metadata: {
496+
plexus_metadata: {
497+
plexus_key_policy: {
498+
excludedModels: ['test-alias'],
499+
},
500+
},
501+
},
502+
})
503+
).rejects.toMatchObject({
504+
message: "Key is not allowed to access model 'test-alias' for chat",
505+
routingContext: {
506+
statusCode: 403,
507+
errorType: 'access_denied',
508+
},
509+
});
510+
511+
expect(fetchMock).toHaveBeenCalledTimes(0);
512+
});
513+
514+
test('excluded models take precedence over allowed models for the same entry', async () => {
515+
setConfigForTesting(makeConfig({ targetCount: 2 }));
516+
517+
const dispatcher = new Dispatcher();
518+
519+
// If a model is in both allowed AND excluded lists, excluded wins
520+
await expect(
521+
dispatcher.dispatch({
522+
...makeChatRequest(),
523+
metadata: {
524+
plexus_metadata: {
525+
plexus_key_policy: {
526+
allowedModels: ['test-alias'],
527+
excludedModels: ['test-alias'],
528+
},
529+
},
530+
},
531+
})
532+
).rejects.toMatchObject({
533+
routingContext: {
534+
statusCode: 403,
535+
errorType: 'access_denied',
536+
},
537+
});
538+
539+
expect(fetchMock).toHaveBeenCalledTimes(0);
540+
});
541+
542+
test('excluded providers filter out candidates, then allowed providers further restrict', async () => {
543+
setConfigForTesting(makeConfig({ targetCount: 2 }));
544+
fetchMock.mockImplementation(async () => successChatResponse('model-2'));
545+
546+
const dispatcher = new Dispatcher();
547+
const response = await dispatcher.dispatch({
548+
...makeChatRequest(),
549+
metadata: {
550+
plexus_metadata: {
551+
plexus_key_policy: {
552+
excludedProviders: ['p1'],
553+
allowedProviders: ['p2'],
554+
},
555+
},
556+
},
557+
});
558+
const meta = (response as any).plexus;
559+
560+
expect(meta?.attemptCount).toBe(1);
561+
expect(meta?.finalAttemptProvider).toBe('p2');
562+
});
463563
});

0 commit comments

Comments
 (0)