From 5403a946c6b3f43bd24b6b6f84c262cb32285853 Mon Sep 17 00:00:00 2001 From: cita Date: Fri, 20 Mar 2026 00:22:17 +0800 Subject: [PATCH 1/2] default deny empty downstream key exposure --- src/server/routes/proxy/downstreamPolicy.ts | 12 +- src/server/routes/proxy/models.test.ts | 117 ++++++++++++++++++ .../services/downstreamApiKeyService.test.ts | 11 ++ .../services/downstreamApiKeyService.ts | 2 +- src/server/services/downstreamPolicyTypes.ts | 6 + .../tokenRouter.downstream-policy.test.ts | 40 ++++++ src/server/services/tokenRouter.ts | 21 ++-- src/web/pages/DownstreamKeys.test.tsx | 74 +++++++++++ src/web/pages/DownstreamKeys.tsx | 50 ++++++-- 9 files changed, 311 insertions(+), 22 deletions(-) diff --git a/src/server/routes/proxy/downstreamPolicy.ts b/src/server/routes/proxy/downstreamPolicy.ts index 8d763fd..090e1ab 100644 --- a/src/server/routes/proxy/downstreamPolicy.ts +++ b/src/server/routes/proxy/downstreamPolicy.ts @@ -1,10 +1,17 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; import { getProxyAuthContext } from '../../middleware/auth.js'; import { isModelAllowedByPolicyOrAllowedRoutes, recordManagedKeyCostUsage } from '../../services/downstreamApiKeyService.js'; -import { EMPTY_DOWNSTREAM_ROUTING_POLICY, type DownstreamRoutingPolicy } from '../../services/downstreamPolicyTypes.js'; +import { + EMPTY_DOWNSTREAM_ROUTING_POLICY, + GLOBAL_UNRESTRICTED_DOWNSTREAM_ROUTING_POLICY, + type DownstreamRoutingPolicy, +} from '../../services/downstreamPolicyTypes.js'; export function getDownstreamRoutingPolicy(request: FastifyRequest): DownstreamRoutingPolicy { - return getProxyAuthContext(request)?.policy || EMPTY_DOWNSTREAM_ROUTING_POLICY; + const authContext = getProxyAuthContext(request); + if (!authContext) return EMPTY_DOWNSTREAM_ROUTING_POLICY; + if (authContext.keyId === null) return GLOBAL_UNRESTRICTED_DOWNSTREAM_ROUTING_POLICY; + return authContext.policy; } export async function ensureModelAllowedForDownstreamKey( @@ -14,6 +21,7 @@ export async function ensureModelAllowedForDownstreamKey( ): Promise { const authContext = getProxyAuthContext(request); if (!authContext) return true; + if (authContext.keyId === null) return true; if (await isModelAllowedByPolicyOrAllowedRoutes(requestedModel, authContext.policy)) { return true; diff --git a/src/server/routes/proxy/models.test.ts b/src/server/routes/proxy/models.test.ts index bdeea62..a6d3a5c 100644 --- a/src/server/routes/proxy/models.test.ts +++ b/src/server/routes/proxy/models.test.ts @@ -8,6 +8,7 @@ type DbModule = typeof import('../../db/index.js'); type ProxyRouterModule = typeof import('./router.js'); type TokenRouterModule = typeof import('../../services/tokenRouter.js'); type TokensRoutesModule = typeof import('../api/tokens.js'); +type ConfigModule = typeof import('../../config.js'); describe('/v1/models route', () => { let db: DbModule['db']; @@ -15,6 +16,7 @@ describe('/v1/models route', () => { let proxyRoutes: ProxyRouterModule['proxyRoutes']; let tokensRoutes: TokensRoutesModule['tokensRoutes']; let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache']; + let config: ConfigModule['config']; let app: FastifyInstance; let dataDir = ''; @@ -27,12 +29,15 @@ describe('/v1/models route', () => { const proxyRouterModule = await import('./router.js'); const tokenRouterModule = await import('../../services/tokenRouter.js'); const tokensRoutesModule = await import('../api/tokens.js'); + const configModule = await import('../../config.js'); db = dbModule.db; schema = dbModule.schema; proxyRoutes = proxyRouterModule.proxyRoutes; tokensRoutes = tokensRoutesModule.tokensRoutes; invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache; + config = configModule.config; + config.proxyToken = 'sk-global-proxy-token'; app = Fastify(); await app.register(tokensRoutes); @@ -108,6 +113,7 @@ describe('/v1/models route', () => { name: 'managed-key', key: 'sk-managed-models', enabled: true, + supportedModels: JSON.stringify(['routable-model']), }).run(); const response = await app.inject({ @@ -129,6 +135,64 @@ describe('/v1/models route', () => { expect(ids).not.toContain('orphan-model'); }); + it('keeps global proxy token unrestricted when no managed key matches', async () => { + const site = await db.insert(schema.sites).values({ + name: 'global-site', + url: 'https://global.example.com', + platform: 'openai', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + accessToken: 'global-access-token', + status: 'active', + }).returning().get(); + + const token = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'default', + token: 'global-api-token', + enabled: true, + isDefault: true, + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'global-routable-model', + available: true, + }).run(); + + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'global-routable-model', + enabled: true, + }).returning().get(); + + await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: account.id, + tokenId: token.id, + sourceModel: 'global-routable-model', + enabled: true, + }).run(); + + const response = await app.inject({ + method: 'GET', + url: '/v1/models', + headers: { + authorization: 'Bearer sk-global-proxy-token', + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + object: 'list'; + data: Array<{ id: string }>; + }; + + expect(body.data.map((item) => item.id)).toContain('global-routable-model'); + }); + it('returns only whitelist models for managed key with supportedModels policy', async () => { const site = await db.insert(schema.sites).values({ name: 'test-site', @@ -290,6 +354,58 @@ describe('/v1/models route', () => { expect(ids).not.toContain('claude-sonnet-4-5'); }); + it('returns no models for managed key with empty model and group selections', async () => { + const site = await db.insert(schema.sites).values({ + name: 'deny-all-site', + url: 'https://deny-all.example.com', + platform: 'openai', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + accessToken: 'deny-all-access-token', + status: 'active', + }).returning().get(); + + await db.insert(schema.modelAvailability).values([ + { + accountId: account.id, + modelName: 'gpt-4o-mini', + available: true, + }, + { + accountId: account.id, + modelName: 'claude-opus-4-6', + available: true, + }, + ]).run(); + + await db.insert(schema.downstreamApiKeys).values({ + name: 'managed-key-deny-all', + key: 'sk-managed-deny-all', + enabled: true, + supportedModels: JSON.stringify([]), + allowedRouteIds: JSON.stringify([]), + }).run(); + + const response = await app.inject({ + method: 'GET', + url: '/v1/models', + headers: { + authorization: 'Bearer sk-managed-deny-all', + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + object: 'list'; + data: Array<{ id: string }>; + }; + + expect(body.data).toEqual([]); + }); + it('returns only explicit-group public name while hiding source exact routes', async () => { const site = await db.insert(schema.sites).values({ name: 'explicit-group-site', @@ -461,6 +577,7 @@ describe('/v1/models route', () => { name: 'search-key', key: 'sk-search-key', enabled: true, + supportedModels: JSON.stringify(['gpt-4.1']), }).run(); const response = await app.inject({ diff --git a/src/server/services/downstreamApiKeyService.test.ts b/src/server/services/downstreamApiKeyService.test.ts index 7582b99..843f20f 100644 --- a/src/server/services/downstreamApiKeyService.test.ts +++ b/src/server/services/downstreamApiKeyService.test.ts @@ -136,6 +136,17 @@ describe('downstreamApiKeyService', () => { expect(await service.isModelAllowedByPolicyOrAllowedRoutes('gemini-2.0-flash', policy)).toBe(false); }); + it('denies all models when both supportedModels and allowedRouteIds are empty', async () => { + const policy = { + supportedModels: [], + allowedRouteIds: [], + siteWeightMultipliers: {}, + }; + + expect(await service.isModelAllowedByPolicyOrAllowedRoutes('gpt-4o-mini', policy)).toBe(false); + expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-6', policy)).toBe(false); + }); + it('authorizes by selected group model pattern only, not arbitrary internal models', async () => { const virtualModelGroup = await db.insert(schema.tokenRoutes).values({ modelPattern: 'claude-opus-4-6', diff --git a/src/server/services/downstreamApiKeyService.ts b/src/server/services/downstreamApiKeyService.ts index 7cfc24f..e7a99d3 100644 --- a/src/server/services/downstreamApiKeyService.ts +++ b/src/server/services/downstreamApiKeyService.ts @@ -234,7 +234,7 @@ export async function isModelAllowedByPolicyOrAllowedRoutes(model: string, polic const hasPatternRules = patterns.length > 0; const hasRouteRules = allowedRouteIds.length > 0; - if (!hasPatternRules && !hasRouteRules) return true; + if (!hasPatternRules && !hasRouteRules) return false; if (hasPatternRules && patterns.some((pattern) => matchesDownstreamModelPattern(model, pattern))) { return true; diff --git a/src/server/services/downstreamPolicyTypes.ts b/src/server/services/downstreamPolicyTypes.ts index 7fc8232..808031b 100644 --- a/src/server/services/downstreamPolicyTypes.ts +++ b/src/server/services/downstreamPolicyTypes.ts @@ -9,3 +9,9 @@ export const EMPTY_DOWNSTREAM_ROUTING_POLICY: DownstreamRoutingPolicy = { allowedRouteIds: [], siteWeightMultipliers: {}, }; + +export const GLOBAL_UNRESTRICTED_DOWNSTREAM_ROUTING_POLICY: DownstreamRoutingPolicy = { + supportedModels: ['*'], + allowedRouteIds: [], + siteWeightMultipliers: {}, +}; diff --git a/src/server/services/tokenRouter.downstream-policy.test.ts b/src/server/services/tokenRouter.downstream-policy.test.ts index f8a3c97..4d174f9 100644 --- a/src/server/services/tokenRouter.downstream-policy.test.ts +++ b/src/server/services/tokenRouter.downstream-policy.test.ts @@ -102,6 +102,46 @@ describe('TokenRouter downstream policy', () => { expect(blockedPick).toBeNull(); }); + it('rejects route selection when both supportedModels and allowedRouteIds are empty', async () => { + const site = await db.insert(schema.sites).values({ + name: 'site-deny-all', + url: 'https://deny-all.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'user-deny-all', + accessToken: 'access-deny-all', + apiToken: 'sk-deny-all', + status: 'active', + }).returning().get(); + + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-4o-mini', + enabled: true, + }).returning().get(); + + await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: account.id, + tokenId: null, + priority: 0, + weight: 10, + enabled: true, + }).run(); + + const router = new TokenRouter(); + const deniedPick = await router.selectChannel('gpt-4o-mini', { + allowedRouteIds: [], + supportedModels: [], + siteWeightMultipliers: {}, + }); + + expect(deniedPick).toBeNull(); + }); + it('applies site weight multipliers to probability explanation', async () => { const siteHigh = await db.insert(schema.sites).values({ name: 'high-site', diff --git a/src/server/services/tokenRouter.ts b/src/server/services/tokenRouter.ts index 526b506..e0ba772 100644 --- a/src/server/services/tokenRouter.ts +++ b/src/server/services/tokenRouter.ts @@ -531,15 +531,18 @@ function channelSupportsRequestedModel(channelSourceModel: string | null | undef return false; } -function isModelAllowedByDownstreamPolicy(requestedModel: string, policy: DownstreamRoutingPolicy): boolean { - const supportedPatterns = Array.isArray(policy.supportedModels) - ? policy.supportedModels - : []; - const matchedSupportedPattern = supportedPatterns.some((pattern) => matchesModelPattern(requestedModel, pattern)); - if (matchedSupportedPattern) return true; - if (policy.allowedRouteIds.length > 0) return true; - return supportedPatterns.length === 0; -} +function isModelAllowedByDownstreamPolicy(requestedModel: string, policy: DownstreamRoutingPolicy): boolean { + const supportedPatterns = Array.isArray(policy.supportedModels) + ? policy.supportedModels + : []; + const hasSupportedPatterns = supportedPatterns.length > 0; + const hasAllowedRoutes = policy.allowedRouteIds.length > 0; + if (!hasSupportedPatterns && !hasAllowedRoutes) return false; + const matchedSupportedPattern = supportedPatterns.some((pattern) => matchesModelPattern(requestedModel, pattern)); + if (matchedSupportedPattern) return true; + if (hasAllowedRoutes) return true; + return false; +} function resolveMappedModel(requestedModel: string, modelMapping?: string | null): string { if (!modelMapping) return requestedModel; diff --git a/src/web/pages/DownstreamKeys.test.tsx b/src/web/pages/DownstreamKeys.test.tsx index 2af5761..d22d5df 100644 --- a/src/web/pages/DownstreamKeys.test.tsx +++ b/src/web/pages/DownstreamKeys.test.tsx @@ -404,6 +404,80 @@ describe('DownstreamKeys page', () => { } }); + it('lets operators explicitly select all exact models and all group routes before saving', async () => { + apiMock.getRoutesLite.mockResolvedValue([ + { id: 11, modelPattern: 'claude-*', displayName: '默认群组', enabled: true }, + { id: 12, modelPattern: 'gpt-4.1-mini', displayName: 'GPT 4.1 Mini', enabled: true }, + { id: 13, modelPattern: 're:^gemini-2\\..*$', displayName: 'Gemini 全家桶', enabled: true }, + { id: 14, modelPattern: 'claude-opus-4-6', displayName: 'Claude Opus 4.6', enabled: true }, + ]); + + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const createBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('新增下游密钥'))[0]; + await act(async () => { + createBtn.props.onClick(); + }); + await flushMicrotasks(); + + const advancedBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('高级配置'))[0]; + await act(async () => { + advancedBtn.props.onClick(); + }); + await flushMicrotasks(); + + const panels = root!.root.findAll((node) => node.props.className === 'downstream-key-advanced-panel'); + const modelPanel = panels.find((node) => collectText(node).includes('模型白名单')); + const groupPanel = panels.find((node) => collectText(node).includes('群组范围')); + expect(modelPanel).toBeTruthy(); + expect(groupPanel).toBeTruthy(); + + const modelSelectAllBtn = modelPanel!.findAll((node) => node.type === 'button' && collectText(node).includes('全选'))[0]; + const groupSelectAllBtn = groupPanel!.findAll((node) => node.type === 'button' && collectText(node).includes('全选'))[0]; + + await act(async () => { + modelSelectAllBtn.props.onClick(); + groupSelectAllBtn.props.onClick(); + }); + await flushMicrotasks(); + + const inputs = root!.root.findAllByType('input'); + const nameInput = inputs.find((node) => node.props.placeholder === '例如:项目 A / 移动端'); + const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...'); + await act(async () => { + nameInput!.props.onChange({ target: { value: 'select-all-key' } }); + keyInput!.props.onChange({ target: { value: 'sk-select-all-key-0319' } }); + }); + await flushMicrotasks(); + + const saveBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('创建密钥'))[0]; + await act(async () => { + saveBtn.props.onClick(); + }); + await flushMicrotasks(); + + expect(apiMock.createDownstreamApiKey).toHaveBeenCalledWith(expect.objectContaining({ + name: 'select-all-key', + key: 'sk-select-all-key-0319', + supportedModels: ['claude-opus-4-6', 'gpt-4.1-mini'], + allowedRouteIds: [11, 13], + })); + } finally { + root?.unmount(); + } + }); + it('uses backend batch api for selected rows', async () => { let root: ReturnType | null = null; try { diff --git a/src/web/pages/DownstreamKeys.tsx b/src/web/pages/DownstreamKeys.tsx index 80b3981..133be75 100644 --- a/src/web/pages/DownstreamKeys.tsx +++ b/src/web/pages/DownstreamKeys.tsx @@ -277,13 +277,13 @@ function buildEditorForm(item?: ManagedItem | DownstreamApiKeyItem | null): Edit } function summarizeModelLimit(models: string[]): string { - if (!Array.isArray(models) || models.length === 0) return '全部模型'; + if (!Array.isArray(models) || models.length === 0) return '未授权模型'; if (models.length === 1) return models[0]; return `${models[0]} +${models.length - 1}`; } function summarizeRouteLimit(routeIds: number[], routeMap: Map): string { - if (!Array.isArray(routeIds) || routeIds.length === 0) return '全部群组'; + if (!Array.isArray(routeIds) || routeIds.length === 0) return '未授权群组'; const names = routeIds .map((id) => routeMap.get(id)) .filter(Boolean) @@ -956,11 +956,26 @@ function EditorModal({
模型白名单
-
只展示精确模型,未勾选则视为全部模型可用
+
只展示精确模型;未勾选时默认不允许任何精确模型,可点“全选”一次性放开。
+
+
+ +
-
已选 {selectedModelCount} 个模型
@@ -995,11 +1010,26 @@ function EditorModal({
群组范围
-
限制可访问的群组路由,未勾选则视为全部群组可用
+
限制可访问的群组路由;未勾选时默认不允许任何群组,可点“全选”一次性放开。
+
+
+ +
-
已选 {selectedGroupCount} 个群组
From 77d6a415d139b0f55a42e8c9d86fc9ddf87974b0 Mon Sep 17 00:00:00 2001 From: cita Date: Fri, 20 Mar 2026 00:39:39 +0800 Subject: [PATCH 2/2] fix managed downstream deny-all semantics --- src/server/proxy-core/surfaces/geminiSurface.ts | 4 ++-- src/server/routes/proxy/downstreamPolicy.ts | 8 +------- src/server/services/downstreamApiKeyService.test.ts | 1 + src/server/services/downstreamApiKeyService.ts | 3 ++- src/server/services/downstreamPolicyTypes.ts | 7 +------ src/server/services/tokenRouter.downstream-policy.test.ts | 1 + src/server/services/tokenRouter.ts | 2 +- 7 files changed, 9 insertions(+), 17 deletions(-) diff --git a/src/server/proxy-core/surfaces/geminiSurface.ts b/src/server/proxy-core/surfaces/geminiSurface.ts index fdac3d3..39e1c4e 100644 --- a/src/server/proxy-core/surfaces/geminiSurface.ts +++ b/src/server/proxy-core/surfaces/geminiSurface.ts @@ -114,10 +114,10 @@ function resolveUpstreamPath(apiVersion: string, modelActionPath: string): strin return `/${normalizedVersion}/${normalizedAction}`; } -function hasDownstreamModelRestrictions(policy: { supportedModels?: unknown; allowedRouteIds?: unknown }): boolean { +function hasDownstreamModelRestrictions(policy: { supportedModels?: unknown; allowedRouteIds?: unknown; denyAllWhenEmpty?: unknown }): boolean { const supportedModels = Array.isArray(policy.supportedModels) ? policy.supportedModels : []; const allowedRouteIds = Array.isArray(policy.allowedRouteIds) ? policy.allowedRouteIds : []; - return supportedModels.length > 0 || allowedRouteIds.length > 0; + return supportedModels.length > 0 || allowedRouteIds.length > 0 || policy.denyAllWhenEmpty === true; } function extractGeminiListedModelName(item: unknown): string { diff --git a/src/server/routes/proxy/downstreamPolicy.ts b/src/server/routes/proxy/downstreamPolicy.ts index 090e1ab..4863810 100644 --- a/src/server/routes/proxy/downstreamPolicy.ts +++ b/src/server/routes/proxy/downstreamPolicy.ts @@ -1,16 +1,11 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; import { getProxyAuthContext } from '../../middleware/auth.js'; import { isModelAllowedByPolicyOrAllowedRoutes, recordManagedKeyCostUsage } from '../../services/downstreamApiKeyService.js'; -import { - EMPTY_DOWNSTREAM_ROUTING_POLICY, - GLOBAL_UNRESTRICTED_DOWNSTREAM_ROUTING_POLICY, - type DownstreamRoutingPolicy, -} from '../../services/downstreamPolicyTypes.js'; +import { EMPTY_DOWNSTREAM_ROUTING_POLICY, type DownstreamRoutingPolicy } from '../../services/downstreamPolicyTypes.js'; export function getDownstreamRoutingPolicy(request: FastifyRequest): DownstreamRoutingPolicy { const authContext = getProxyAuthContext(request); if (!authContext) return EMPTY_DOWNSTREAM_ROUTING_POLICY; - if (authContext.keyId === null) return GLOBAL_UNRESTRICTED_DOWNSTREAM_ROUTING_POLICY; return authContext.policy; } @@ -21,7 +16,6 @@ export async function ensureModelAllowedForDownstreamKey( ): Promise { const authContext = getProxyAuthContext(request); if (!authContext) return true; - if (authContext.keyId === null) return true; if (await isModelAllowedByPolicyOrAllowedRoutes(requestedModel, authContext.policy)) { return true; diff --git a/src/server/services/downstreamApiKeyService.test.ts b/src/server/services/downstreamApiKeyService.test.ts index 843f20f..faa08e8 100644 --- a/src/server/services/downstreamApiKeyService.test.ts +++ b/src/server/services/downstreamApiKeyService.test.ts @@ -141,6 +141,7 @@ describe('downstreamApiKeyService', () => { supportedModels: [], allowedRouteIds: [], siteWeightMultipliers: {}, + denyAllWhenEmpty: true, }; expect(await service.isModelAllowedByPolicyOrAllowedRoutes('gpt-4o-mini', policy)).toBe(false); diff --git a/src/server/services/downstreamApiKeyService.ts b/src/server/services/downstreamApiKeyService.ts index e7a99d3..9db2e41 100644 --- a/src/server/services/downstreamApiKeyService.ts +++ b/src/server/services/downstreamApiKeyService.ts @@ -234,7 +234,7 @@ export async function isModelAllowedByPolicyOrAllowedRoutes(model: string, polic const hasPatternRules = patterns.length > 0; const hasRouteRules = allowedRouteIds.length > 0; - if (!hasPatternRules && !hasRouteRules) return false; + if (!hasPatternRules && !hasRouteRules) return policy.denyAllWhenEmpty === true ? false : true; if (hasPatternRules && patterns.some((pattern) => matchesDownstreamModelPattern(model, pattern))) { return true; @@ -278,6 +278,7 @@ export function toPolicyFromView(view: Pick; + denyAllWhenEmpty?: boolean; } export const EMPTY_DOWNSTREAM_ROUTING_POLICY: DownstreamRoutingPolicy = { @@ -9,9 +10,3 @@ export const EMPTY_DOWNSTREAM_ROUTING_POLICY: DownstreamRoutingPolicy = { allowedRouteIds: [], siteWeightMultipliers: {}, }; - -export const GLOBAL_UNRESTRICTED_DOWNSTREAM_ROUTING_POLICY: DownstreamRoutingPolicy = { - supportedModels: ['*'], - allowedRouteIds: [], - siteWeightMultipliers: {}, -}; diff --git a/src/server/services/tokenRouter.downstream-policy.test.ts b/src/server/services/tokenRouter.downstream-policy.test.ts index 4d174f9..c394a5f 100644 --- a/src/server/services/tokenRouter.downstream-policy.test.ts +++ b/src/server/services/tokenRouter.downstream-policy.test.ts @@ -137,6 +137,7 @@ describe('TokenRouter downstream policy', () => { allowedRouteIds: [], supportedModels: [], siteWeightMultipliers: {}, + denyAllWhenEmpty: true, }); expect(deniedPick).toBeNull(); diff --git a/src/server/services/tokenRouter.ts b/src/server/services/tokenRouter.ts index e0ba772..4b3c5fc 100644 --- a/src/server/services/tokenRouter.ts +++ b/src/server/services/tokenRouter.ts @@ -537,7 +537,7 @@ function isModelAllowedByDownstreamPolicy(requestedModel: string, policy: Downst : []; const hasSupportedPatterns = supportedPatterns.length > 0; const hasAllowedRoutes = policy.allowedRouteIds.length > 0; - if (!hasSupportedPatterns && !hasAllowedRoutes) return false; + if (!hasSupportedPatterns && !hasAllowedRoutes) return policy.denyAllWhenEmpty === true ? false : true; const matchedSupportedPattern = supportedPatterns.some((pattern) => matchesModelPattern(requestedModel, pattern)); if (matchedSupportedPattern) return true; if (hasAllowedRoutes) return true;