[codex] support downstream key site and credential exclusions (#431)

* support downstream key site and credential exclusions

* skip electron binary download in non-desktop ci

* fix migrate partial-journal test for 0020
This commit is contained in:
Cita
2026-04-05 23:43:07 +08:00
committed by GitHub
parent 492f43c5dc
commit 38721cb890
30 changed files with 1718 additions and 793 deletions
@@ -1,5 +1,19 @@
import { z } from 'zod';
const downstreamExcludedCredentialRefSchema = z.union([
z.object({
kind: z.literal('account_token'),
siteId: z.union([z.number(), z.string()]),
accountId: z.union([z.number(), z.string()]),
tokenId: z.union([z.number(), z.string()]),
}),
z.object({
kind: z.literal('default_api_key'),
siteId: z.union([z.number(), z.string()]),
accountId: z.union([z.number(), z.string()]),
}),
]);
const downstreamApiKeyPayloadSchema = z.object({
name: z.string().optional(),
key: z.string().optional(),
@@ -16,6 +30,8 @@ const downstreamApiKeyPayloadSchema = z.object({
z.string(),
z.record(z.string(), z.union([z.number(), z.string()])),
]).optional(),
excludedSiteIds: z.union([z.string(), z.array(z.union([z.number(), z.string()]))]).optional(),
excludedCredentialRefs: z.union([z.string(), z.array(downstreamExcludedCredentialRefSchema)]).optional(),
}).passthrough();
const downstreamApiKeyBatchPayloadSchema = z.object({
@@ -76,6 +92,12 @@ function formatDownstreamApiKeyPayloadError(error: z.ZodError): string {
if (firstPath === 'siteWeightMultipliers') {
return 'Invalid siteWeightMultipliers. Expected JSON object or string.';
}
if (firstPath === 'excludedSiteIds') {
return 'Invalid excludedSiteIds. Expected string or array.';
}
if (firstPath === 'excludedCredentialRefs') {
return 'Invalid excludedCredentialRefs. Expected JSON string or array.';
}
if (firstPath === 'ids') {
return 'Invalid ids. Expected number[].';
}
+1 -1
View File
@@ -2,7 +2,7 @@ CREATE TABLE IF NOT EXISTS `sites` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY
CREATE TABLE IF NOT EXISTS `accounts` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `username` TEXT, `access_token` TEXT NOT NULL, `api_token` TEXT, `balance` DOUBLE DEFAULT 0, `balance_used` DOUBLE DEFAULT 0, `quota` DOUBLE DEFAULT 0, `unit_cost` DOUBLE, `value_score` DOUBLE DEFAULT 0, `status` VARCHAR(191) DEFAULT 'active', `checkin_enabled` BOOLEAN DEFAULT true, `last_checkin_at` VARCHAR(191), `last_balance_refresh` VARCHAR(191), `extra_config` JSON, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, `oauth_provider` TEXT, `oauth_account_key` TEXT, `oauth_project_id` TEXT, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS `account_tokens` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `name` TEXT NOT NULL, `token` TEXT NOT NULL, `source` VARCHAR(191) DEFAULT 'manual', `enabled` BOOLEAN DEFAULT true, `is_default` BOOLEAN DEFAULT false, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `token_group` TEXT, `value_status` VARCHAR(191) NOT NULL DEFAULT 'ready', FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS `checkin_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `status` TEXT NOT NULL, `message` TEXT, `reward` TEXT, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS `downstream_api_keys` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `key` TEXT NOT NULL, `description` TEXT, `enabled` BOOLEAN DEFAULT true, `expires_at` VARCHAR(191), `max_cost` DOUBLE, `used_cost` DOUBLE DEFAULT 0, `max_requests` INT, `used_requests` INT DEFAULT 0, `supported_models` JSON, `allowed_route_ids` JSON, `site_weight_multipliers` JSON, `last_used_at` VARCHAR(191), `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `group_name` TEXT, `tags` TEXT);
CREATE TABLE IF NOT EXISTS `downstream_api_keys` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `key` TEXT NOT NULL, `description` TEXT, `enabled` BOOLEAN DEFAULT true, `expires_at` VARCHAR(191), `max_cost` DOUBLE, `used_cost` DOUBLE DEFAULT 0, `max_requests` INT, `used_requests` INT DEFAULT 0, `supported_models` JSON, `allowed_route_ids` JSON, `site_weight_multipliers` JSON, `last_used_at` VARCHAR(191), `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `group_name` TEXT, `tags` TEXT, `excluded_site_ids` JSON, `excluded_credential_refs` JSON);
CREATE TABLE IF NOT EXISTS `events` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `type` TEXT NOT NULL, `title` TEXT NOT NULL, `message` TEXT, `level` VARCHAR(191) NOT NULL DEFAULT 'info', `read` BOOLEAN DEFAULT false, `related_id` INT, `related_type` TEXT, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')));
CREATE TABLE IF NOT EXISTS `model_availability` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `model_name` TEXT NOT NULL, `available` BOOLEAN, `latency_ms` INT, `checked_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `is_manual` BOOLEAN DEFAULT false, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS `proxy_debug_traces` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `downstream_path` TEXT NOT NULL, `client_kind` TEXT, `session_id` TEXT, `trace_hint` TEXT, `requested_model` TEXT, `downstream_api_key_id` INT, `request_headers_json` JSON, `request_body_json` JSON, `sticky_session_key` TEXT, `sticky_hit_channel_id` INT, `selected_channel_id` INT, `selected_route_id` INT, `selected_account_id` INT, `selected_site_id` INT, `selected_site_platform` TEXT, `endpoint_candidates_json` JSON, `endpoint_runtime_state_json` JSON, `decision_summary_json` JSON, `final_status` TEXT, `final_http_status` INT, `final_upstream_path` TEXT, `final_response_headers_json` JSON, `final_response_body_json` JSON, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')));
+1 -2
View File
@@ -1,2 +1 @@
ALTER TABLE `proxy_logs` ADD COLUMN `is_stream` BOOLEAN;
ALTER TABLE `proxy_logs` ADD COLUMN `first_byte_latency_ms` INT;
-- no schema changes detected for mysql
@@ -2,7 +2,7 @@ CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTIT
CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" DOUBLE PRECISION DEFAULT 0, "balance_used" DOUBLE PRECISION DEFAULT 0, "quota" DOUBLE PRECISION DEFAULT 0, "unit_cost" DOUBLE PRECISION, "value_score" DOUBLE PRECISION DEFAULT 0, "status" TEXT DEFAULT 'active', "checkin_enabled" BOOLEAN DEFAULT true, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" JSONB, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, "oauth_provider" TEXT, "oauth_account_key" TEXT, "oauth_project_id" TEXT, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT true, "is_default" BOOLEAN DEFAULT false, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "token_group" TEXT, "value_status" TEXT NOT NULL DEFAULT 'ready', FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL, "description" TEXT, "enabled" BOOLEAN DEFAULT true, "expires_at" TEXT, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" JSONB, "allowed_route_ids" JSONB, "site_weight_multipliers" JSONB, "last_used_at" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "group_name" TEXT, "tags" TEXT);
CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL, "description" TEXT, "enabled" BOOLEAN DEFAULT true, "expires_at" TEXT, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" JSONB, "allowed_route_ids" JSONB, "site_weight_multipliers" JSONB, "last_used_at" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "group_name" TEXT, "tags" TEXT, "excluded_site_ids" JSONB, "excluded_credential_refs" JSONB);
CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT NOT NULL DEFAULT 'info', "read" BOOLEAN DEFAULT false, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'));
CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "is_manual" BOOLEAN DEFAULT false, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS "proxy_debug_traces" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "downstream_path" TEXT NOT NULL, "client_kind" TEXT, "session_id" TEXT, "trace_hint" TEXT, "requested_model" TEXT, "downstream_api_key_id" INTEGER, "request_headers_json" JSONB, "request_body_json" JSONB, "sticky_session_key" TEXT, "sticky_hit_channel_id" INTEGER, "selected_channel_id" INTEGER, "selected_route_id" INTEGER, "selected_account_id" INTEGER, "selected_site_id" INTEGER, "selected_site_platform" TEXT, "endpoint_candidates_json" JSONB, "endpoint_runtime_state_json" JSONB, "decision_summary_json" JSONB, "final_status" TEXT, "final_http_status" INTEGER, "final_upstream_path" TEXT, "final_response_headers_json" JSONB, "final_response_body_json" JSONB, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'));
+1 -2
View File
@@ -1,2 +1 @@
ALTER TABLE "proxy_logs" ADD COLUMN "is_stream" BOOLEAN;
ALTER TABLE "proxy_logs" ADD COLUMN "first_byte_latency_ms" INTEGER;
-- no schema changes detected for postgres
@@ -355,6 +355,18 @@
"notNull": false,
"defaultValue": null,
"primaryKey": false
},
"excluded_site_ids": {
"logicalType": "json",
"notNull": false,
"defaultValue": null,
"primaryKey": false
},
"excluded_credential_refs": {
"logicalType": "json",
"notNull": false,
"defaultValue": null,
"primaryKey": false
}
}
},
+3
View File
@@ -384,6 +384,9 @@ describe('sqlite migrate bootstrap', () => {
'0011_downstream_api_key_metadata',
'0012_account_token_value_status',
'0013_oauth_multi_provider',
// 0008 creates downstream_api_keys, so later table-dependent migrations
// must stay missing in this partial-journal fixture too.
'0020_downstream_api_key_exclusions',
]);
const appliedEntries = journalEntries.filter((entry) => !missingTags.has(entry.tag));
+2
View File
@@ -350,6 +350,8 @@ export const downstreamApiKeys = sqliteTable('downstream_api_keys', {
supportedModels: text('supported_models'), // JSON array<string>
allowedRouteIds: text('allowed_route_ids'), // JSON array<number>
siteWeightMultipliers: text('site_weight_multipliers'), // JSON object { [siteId]: multiplier }
excludedSiteIds: text('excluded_site_ids'), // JSON array<number>
excludedCredentialRefs: text('excluded_credential_refs'), // JSON array<DownstreamExcludedCredentialRef>
lastUsedAt: text('last_used_at'),
createdAt: text('created_at').default(sql`(datetime('now'))`),
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
+2
View File
@@ -43,6 +43,8 @@ function isJsonLikeColumn(columnName: string): boolean {
|| normalizedColumn.includes('mapping')
|| normalizedColumn.includes('headers')
|| normalizedColumn.includes('config')
|| normalizedColumn.endsWith('_site_ids')
|| normalizedColumn.includes('credential_refs')
|| normalizedColumn.includes('details')
|| normalizedColumn.includes('meta')
|| normalizedColumn.includes('models')
+193 -1
View File
@@ -224,6 +224,198 @@ describe('downstream api keys routes', () => {
});
});
it('roundtrips excluded sites and excluded credentials through create, update, and list', async () => {
const siteA = await db.insert(schema.sites).values({
name: 'site-a',
url: 'https://site-a.example.com',
status: 'active',
platform: 'new-api',
}).returning().get();
const siteB = await db.insert(schema.sites).values({
name: 'site-b',
url: 'https://site-b.example.com',
status: 'active',
platform: 'new-api',
}).returning().get();
const accountA = await db.insert(schema.accounts).values({
siteId: siteA.id,
username: 'account-a',
accessToken: 'access-a',
apiToken: 'sk-default-a',
status: 'active',
}).returning().get();
const accountB = await db.insert(schema.accounts).values({
siteId: siteB.id,
username: 'account-b',
accessToken: 'access-b',
apiToken: 'sk-default-b',
status: 'active',
}).returning().get();
const tokenA = await db.insert(schema.accountTokens).values({
accountId: accountA.id,
name: 'token-a',
token: 'sk-token-a',
enabled: true,
isDefault: true,
valueStatus: 'ready',
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.2',
displayName: 'portal-route',
enabled: true,
}).returning().get();
const createRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys',
payload: {
name: 'exclude-key',
key: 'sk-exclude-key-001',
allowedRouteIds: [route.id],
excludedSiteIds: [siteB.id],
excludedCredentialRefs: [
{ kind: 'default_api_key', siteId: siteB.id, accountId: accountB.id },
{ kind: 'account_token', siteId: siteA.id, accountId: accountA.id, tokenId: tokenA.id },
],
},
});
expect(createRes.statusCode).toBe(200);
expect(createRes.json()).toMatchObject({
success: true,
item: {
excludedSiteIds: [siteB.id],
excludedCredentialRefs: [
{ kind: 'account_token', siteId: siteA.id, accountId: accountA.id, tokenId: tokenA.id },
{ kind: 'default_api_key', siteId: siteB.id, accountId: accountB.id },
],
},
});
const keyId = createRes.json().item.id as number;
const updateRes = await app.inject({
method: 'PUT',
url: `/api/downstream-keys/${keyId}`,
payload: {
excludedSiteIds: [siteB.id, siteA.id, siteB.id],
excludedCredentialRefs: [
{ kind: 'default_api_key', siteId: siteB.id, accountId: accountB.id },
{ kind: 'account_token', siteId: siteA.id, accountId: accountA.id, tokenId: tokenA.id },
{ kind: 'account_token', siteId: siteA.id, accountId: accountA.id, tokenId: tokenA.id },
],
},
});
expect(updateRes.statusCode).toBe(200);
expect(updateRes.json()).toMatchObject({
success: true,
item: {
excludedSiteIds: [siteA.id, siteB.id],
excludedCredentialRefs: [
{ kind: 'account_token', siteId: siteA.id, accountId: accountA.id, tokenId: tokenA.id },
{ kind: 'default_api_key', siteId: siteB.id, accountId: accountB.id },
],
},
});
const listRes = await app.inject({
method: 'GET',
url: '/api/downstream-keys',
});
expect(listRes.statusCode).toBe(200);
expect(listRes.json()).toMatchObject({
success: true,
items: [
expect.objectContaining({
id: keyId,
excludedSiteIds: [siteA.id, siteB.id],
excludedCredentialRefs: [
{ kind: 'account_token', siteId: siteA.id, accountId: accountA.id, tokenId: tokenA.id },
{ kind: 'default_api_key', siteId: siteB.id, accountId: accountB.id },
],
}),
],
});
});
it('rejects unknown or mismatched exclusion references', async () => {
const siteA = await db.insert(schema.sites).values({
name: 'site-a',
url: 'https://site-a.example.com',
status: 'active',
platform: 'new-api',
}).returning().get();
const siteB = await db.insert(schema.sites).values({
name: 'site-b',
url: 'https://site-b.example.com',
status: 'active',
platform: 'new-api',
}).returning().get();
const accountA = await db.insert(schema.accounts).values({
siteId: siteA.id,
username: 'account-a',
accessToken: 'access-a',
apiToken: 'sk-default-a',
status: 'active',
}).returning().get();
const accountB = await db.insert(schema.accounts).values({
siteId: siteB.id,
username: 'account-b',
accessToken: 'access-b',
apiToken: null,
status: 'active',
}).returning().get();
const tokenA = await db.insert(schema.accountTokens).values({
accountId: accountA.id,
name: 'token-a',
token: 'sk-token-a',
enabled: true,
isDefault: true,
valueStatus: 'ready',
}).returning().get();
const unknownSiteRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys',
payload: {
name: 'bad-site',
key: 'sk-bad-site-001',
excludedSiteIds: [999999],
},
});
expect(unknownSiteRes.statusCode).toBe(400);
const mismatchedTokenRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys',
payload: {
name: 'bad-token',
key: 'sk-bad-token-001',
excludedCredentialRefs: [
{ kind: 'account_token', siteId: siteB.id, accountId: accountA.id, tokenId: tokenA.id },
],
},
});
expect(mismatchedTokenRes.statusCode).toBe(400);
const missingDefaultApiKeyRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys',
payload: {
name: 'bad-default',
key: 'sk-bad-default-001',
excludedCredentialRefs: [
{ kind: 'default_api_key', siteId: siteB.id, accountId: accountB.id },
],
},
});
expect(missingDefaultApiKeyRes.statusCode).toBe(400);
});
it('supports batch enable/disable/reset/delete operations', async () => {
const inserted = await db.insert(schema.downstreamApiKeys).values([
{
@@ -644,7 +836,7 @@ describe('downstream api keys routes', () => {
expect(updateRes.statusCode).toBe(400);
expect(updateRes.json()).toMatchObject({
success: false,
message: 'siteWeightMultipliers 包含不存在的站点: 999',
message: '策略中包含不存在的站点: 999',
});
const filteredSummaryRes = await app.inject({
+82 -2
View File
@@ -9,6 +9,7 @@ import {
toDownstreamApiKeyPolicyView,
toPersistenceJson,
} from '../../services/downstreamApiKeyService.js';
import type { DownstreamExcludedCredentialRef } from '../../services/downstreamPolicyTypes.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import {
parseDownstreamApiKeyBatchPayload,
@@ -159,6 +160,8 @@ function resolveBucketTsExpression(bucketSeconds: number) {
async function validatePolicyReferences(input: {
allowedRouteIds: number[];
siteWeightMultipliers: Record<number, number>;
excludedSiteIds: number[];
excludedCredentialRefs: DownstreamExcludedCredentialRef[];
}): Promise<string | null> {
const routeIds = input.allowedRouteIds || [];
if (routeIds.length > 0) {
@@ -173,10 +176,14 @@ async function validatePolicyReferences(input: {
}
}
const siteIds = Object.keys(input.siteWeightMultipliers || {})
const weightedSiteIds = Object.keys(input.siteWeightMultipliers || {})
.map((key) => Number(key))
.filter((value) => Number.isFinite(value) && value > 0)
.map((value) => Math.trunc(value));
const excludedSiteIds = (input.excludedSiteIds || [])
.filter((value) => Number.isFinite(value) && value > 0)
.map((value) => Math.trunc(value));
const siteIds = Array.from(new Set([...weightedSiteIds, ...excludedSiteIds]));
if (siteIds.length > 0) {
const rows = await db.select({ id: schema.sites.id })
.from(schema.sites)
@@ -185,7 +192,70 @@ async function validatePolicyReferences(input: {
const existingIds = new Set(rows.map((row) => Number(row.id)));
const missingIds = siteIds.filter((id) => !existingIds.has(id));
if (missingIds.length > 0) {
return `siteWeightMultipliers 包含不存在的站点: ${missingIds.join(', ')}`;
return `策略中包含不存在的站点: ${missingIds.join(', ')}`;
}
}
const credentialRefs = input.excludedCredentialRefs || [];
const accountTokenRefs = credentialRefs.filter((ref): ref is Extract<DownstreamExcludedCredentialRef, { kind: 'account_token' }> => ref.kind === 'account_token');
if (accountTokenRefs.length > 0) {
const tokenIds = Array.from(new Set(accountTokenRefs.map((ref) => ref.tokenId)));
const rows = await db.select({
tokenId: schema.accountTokens.id,
accountId: schema.accounts.id,
siteId: schema.accounts.siteId,
})
.from(schema.accountTokens)
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
.where(inArray(schema.accountTokens.id, tokenIds))
.all();
const tokenById = new Map<number, { tokenId: number; accountId: number; siteId: number }>(
rows.map((row) => [Number(row.tokenId), {
tokenId: Number(row.tokenId),
accountId: Number(row.accountId),
siteId: Number(row.siteId),
}]),
);
for (const ref of accountTokenRefs) {
const matched = tokenById.get(ref.tokenId);
if (!matched) {
return `excludedCredentialRefs 包含不存在的令牌: ${ref.tokenId}`;
}
if (Number(matched.accountId) !== ref.accountId || Number(matched.siteId) !== ref.siteId) {
return `excludedCredentialRefs 中的 account_token 引用与账号/站点不匹配: ${ref.tokenId}`;
}
}
}
const defaultApiKeyRefs = credentialRefs.filter((ref): ref is Extract<DownstreamExcludedCredentialRef, { kind: 'default_api_key' }> => ref.kind === 'default_api_key');
if (defaultApiKeyRefs.length > 0) {
const accountIds = Array.from(new Set(defaultApiKeyRefs.map((ref) => ref.accountId)));
const rows = await db.select({
accountId: schema.accounts.id,
siteId: schema.accounts.siteId,
apiToken: schema.accounts.apiToken,
})
.from(schema.accounts)
.where(inArray(schema.accounts.id, accountIds))
.all();
const accountById = new Map<number, { accountId: number; siteId: number; apiToken: string | null }>(
rows.map((row) => [Number(row.accountId), {
accountId: Number(row.accountId),
siteId: Number(row.siteId),
apiToken: row.apiToken,
}]),
);
for (const ref of defaultApiKeyRefs) {
const matched = accountById.get(ref.accountId);
if (!matched) {
return `excludedCredentialRefs 包含不存在的账号: ${ref.accountId}`;
}
if (Number(matched.siteId) !== ref.siteId) {
return `excludedCredentialRefs 中的 default_api_key 引用与站点不匹配: ${ref.accountId}`;
}
if (!(matched.apiToken || '').trim()) {
return `excludedCredentialRefs 中的 default_api_key 账号缺少默认 API Key: ${ref.accountId}`;
}
}
}
@@ -470,6 +540,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
const policyRefError = await validatePolicyReferences({
allowedRouteIds: normalized.allowedRouteIds,
siteWeightMultipliers: normalized.siteWeightMultipliers,
excludedSiteIds: normalized.excludedSiteIds,
excludedCredentialRefs: normalized.excludedCredentialRefs,
});
if (policyRefError) {
return reply.code(400).send({ success: false, message: policyRefError });
@@ -496,6 +568,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
supportedModels: toPersistenceJson(normalized.supportedModels),
allowedRouteIds: toPersistenceJson(normalized.allowedRouteIds),
siteWeightMultipliers: toPersistenceJson(normalized.siteWeightMultipliers),
excludedSiteIds: toPersistenceJson(normalized.excludedSiteIds),
excludedCredentialRefs: toPersistenceJson(normalized.excludedCredentialRefs),
createdAt: nowIso,
updatedAt: nowIso,
},
@@ -552,6 +626,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
supportedModels: hasOwn('supportedModels') ? body.supportedModels : existingView.supportedModels,
allowedRouteIds: hasOwn('allowedRouteIds') ? body.allowedRouteIds : existingView.allowedRouteIds,
siteWeightMultipliers: hasOwn('siteWeightMultipliers') ? body.siteWeightMultipliers : existingView.siteWeightMultipliers,
excludedSiteIds: hasOwn('excludedSiteIds') ? body.excludedSiteIds : existingView.excludedSiteIds,
excludedCredentialRefs: hasOwn('excludedCredentialRefs') ? body.excludedCredentialRefs : existingView.excludedCredentialRefs,
});
} catch (error: unknown) {
return reply.code(400).send({ success: false, message: (error as Error)?.message || '参数无效' });
@@ -569,6 +645,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
const policyRefError = await validatePolicyReferences({
allowedRouteIds: normalized.allowedRouteIds,
siteWeightMultipliers: normalized.siteWeightMultipliers,
excludedSiteIds: normalized.excludedSiteIds,
excludedCredentialRefs: normalized.excludedCredentialRefs,
});
if (policyRefError) {
return reply.code(400).send({ success: false, message: policyRefError });
@@ -589,6 +667,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
supportedModels: toPersistenceJson(normalized.supportedModels),
allowedRouteIds: toPersistenceJson(normalized.allowedRouteIds),
siteWeightMultipliers: toPersistenceJson(normalized.siteWeightMultipliers),
excludedSiteIds: toPersistenceJson(normalized.excludedSiteIds),
excludedCredentialRefs: toPersistenceJson(normalized.excludedCredentialRefs),
updatedAt: nowIso,
}).where(eq(schema.downstreamApiKeys.id, id)).run();
+16
View File
@@ -202,6 +202,10 @@ describe('backupService', () => {
supportedModels: '["gpt-4o-mini"]',
allowedRouteIds: `[${route.id}]`,
siteWeightMultipliers: `{"${site.id}":1.5}`,
excludedSiteIds: `[${site.id}]`,
excludedCredentialRefs: JSON.stringify([
{ kind: 'account_token', siteId: site.id, accountId: account.id, tokenId: accountToken.id },
]),
lastUsedAt: now,
createdAt: now,
updatedAt: now,
@@ -238,6 +242,8 @@ describe('backupService', () => {
supportedModels: '["gpt-4o-mini"]',
allowedRouteIds: `[${route.id}]`,
siteWeightMultipliers: `{"${site.id}":1.5}`,
excludedSiteIds: `[${site.id}]`,
excludedCredentialRefs: `[{"kind":"account_token","siteId":${site.id},"accountId":${account.id},"tokenId":${accountToken.id}}]`,
}),
]);
expect(exported.accounts.accounts[0]).not.toHaveProperty('balanceUsed');
@@ -307,6 +313,8 @@ describe('backupService', () => {
supportedModels: '["gpt-4o-mini"]',
allowedRouteIds: `[${route.id}]`,
siteWeightMultipliers: `{"${site.id}":1.5}`,
excludedSiteIds: `[${site.id}]`,
excludedCredentialRefs: `[{"kind":"account_token","siteId":${site.id},"accountId":${account.id},"tokenId":${accountToken.id}}]`,
lastUsedAt: now,
}),
]);
@@ -684,6 +692,10 @@ describe('backupService', () => {
supportedModels: '["gpt-4o"]',
allowedRouteIds: `[${route.id}]`,
siteWeightMultipliers: `{"${site.id}":1.25}`,
excludedSiteIds: `[${site.id}]`,
excludedCredentialRefs: JSON.stringify([
{ kind: 'account_token', siteId: site.id, accountId: account.id, tokenId: accountToken.id },
]),
lastUsedAt: exportedAt,
createdAt: exportedAt,
updatedAt: exportedAt,
@@ -828,6 +840,8 @@ describe('backupService', () => {
supportedModels: '["gpt-local"]',
allowedRouteIds: '[999]',
siteWeightMultipliers: '{"999":9}',
excludedSiteIds: '[999]',
excludedCredentialRefs: '[{"kind":"default_api_key","siteId":999,"accountId":999}]',
lastUsedAt: localRuntimeAt,
updatedAt: localRuntimeAt,
}).where(eq(schema.downstreamApiKeys.id, downstreamKey.id)).run();
@@ -991,6 +1005,8 @@ describe('backupService', () => {
supportedModels: '["gpt-4o"]',
allowedRouteIds: `[${route.id}]`,
siteWeightMultipliers: `{"${site.id}":1.25}`,
excludedSiteIds: `[${site.id}]`,
excludedCredentialRefs: `[{"kind":"account_token","siteId":${site.id},"accountId":${account.id},"tokenId":${accountToken.id}}]`,
lastUsedAt: localRuntimeAt,
}),
]);
+4
View File
@@ -97,6 +97,8 @@ type BackupDownstreamApiKeyRow = Pick<DownstreamApiKeyRow,
| 'supportedModels'
| 'allowedRouteIds'
| 'siteWeightMultipliers'
| 'excludedSiteIds'
| 'excludedCredentialRefs'
> & Partial<Pick<DownstreamApiKeyRow, 'usedCost' | 'usedRequests' | 'lastUsedAt'>>;
interface AccountsBackupSection {
@@ -1769,6 +1771,8 @@ async function importAccountsSection(section: AccountsBackupSection): Promise<vo
supportedModels: row.supportedModels ?? null,
allowedRouteIds: row.allowedRouteIds ?? null,
siteWeightMultipliers: row.siteWeightMultipliers ?? null,
excludedSiteIds: row.excludedSiteIds ?? null,
excludedCredentialRefs: row.excludedCredentialRefs ?? null,
lastUsedAt: runtimeDownstream?.lastUsedAt ?? row.lastUsedAt ?? null,
}).run();
const downstreamApiKeyId = requireInsertedRowId(
@@ -378,6 +378,8 @@ describe('databaseMigrationService', () => {
supportedModels: ['gpt-4o-mini'],
allowedRouteIds: [3],
siteWeightMultipliers: { 1: 1.5 },
excludedSiteIds: [1],
excludedCredentialRefs: [{ kind: 'default_api_key', siteId: 1, accountId: 2 }],
enabled: true,
}],
events: [],
@@ -404,6 +406,8 @@ describe('databaseMigrationService', () => {
expect(downstreamKeyStatement?.values[downstreamKeyStatement.columns.indexOf('supported_models')]).toBe('["gpt-4o-mini"]');
expect(downstreamKeyStatement?.values[downstreamKeyStatement.columns.indexOf('allowed_route_ids')]).toBe('[3]');
expect(downstreamKeyStatement?.values[downstreamKeyStatement.columns.indexOf('site_weight_multipliers')]).toBe('{"1":1.5}');
expect(downstreamKeyStatement?.values[downstreamKeyStatement.columns.indexOf('excluded_site_ids')]).toBe('[1]');
expect(downstreamKeyStatement?.values[downstreamKeyStatement.columns.indexOf('excluded_credential_refs')]).toBe('[{"kind":"default_api_key","siteId":1,"accountId":2}]');
});
it('uses schema logical types to serialize JSON columns instead of String(value)', () => {
@@ -663,7 +663,7 @@ function buildStatements(
for (const row of snapshot.accounts.downstreamApiKeys) {
statements.push({
table: 'downstream_api_keys',
columns: ['id', 'name', 'key', 'description', 'enabled', 'expires_at', 'max_cost', 'used_cost', 'max_requests', 'used_requests', 'supported_models', 'allowed_route_ids', 'site_weight_multipliers', 'last_used_at', 'created_at', 'updated_at'],
columns: ['id', 'name', 'key', 'description', 'enabled', 'expires_at', 'max_cost', 'used_cost', 'max_requests', 'used_requests', 'supported_models', 'allowed_route_ids', 'site_weight_multipliers', 'excluded_site_ids', 'excluded_credential_refs', 'last_used_at', 'created_at', 'updated_at'],
values: [
asNumber(row.id, 0),
asNullableString(row.name),
@@ -678,6 +678,8 @@ function buildStatements(
serializeColumnValue('downstream_api_keys', 'supported_models', row.supportedModels, contract),
serializeColumnValue('downstream_api_keys', 'allowed_route_ids', row.allowedRouteIds, contract),
serializeColumnValue('downstream_api_keys', 'site_weight_multipliers', row.siteWeightMultipliers, contract),
serializeColumnValue('downstream_api_keys', 'excluded_site_ids', row.excludedSiteIds, contract),
serializeColumnValue('downstream_api_keys', 'excluded_credential_refs', row.excludedCredentialRefs, contract),
asNullableString(row.lastUsedAt),
asNullableString(row.createdAt),
asNullableString(row.updatedAt),
+100 -2
View File
@@ -2,7 +2,11 @@ import { and, eq, inArray, sql } from 'drizzle-orm';
import { minimatch } from 'minimatch';
import { db, schema } from '../db/index.js';
import { config } from '../config.js';
import { EMPTY_DOWNSTREAM_ROUTING_POLICY, type DownstreamRoutingPolicy } from './downstreamPolicyTypes.js';
import {
EMPTY_DOWNSTREAM_ROUTING_POLICY,
type DownstreamExcludedCredentialRef,
type DownstreamRoutingPolicy,
} from './downstreamPolicyTypes.js';
export type DownstreamApiKeyRow = typeof schema.downstreamApiKeys.$inferSelect;
@@ -23,6 +27,8 @@ export type DownstreamApiKeyPolicyView = {
supportedModels: string[];
allowedRouteIds: number[];
siteWeightMultipliers: Record<number, number>;
excludedSiteIds: number[];
excludedCredentialRefs: DownstreamExcludedCredentialRef[];
lastUsedAt: string | null;
createdAt: string | null;
updatedAt: string | null;
@@ -196,6 +202,86 @@ export function normalizeSiteWeightMultipliersInput(input: unknown): Record<numb
return result;
}
export function normalizeExcludedSiteIdsInput(input: unknown): number[] {
const rawValues = Array.isArray(input)
? input
: (typeof input === 'string' ? input.split(/\r?\n|,/g) : []);
const siteIds: number[] = [];
for (const item of rawValues) {
const value = Number(item);
if (!Number.isFinite(value)) continue;
const normalized = Math.trunc(value);
if (normalized <= 0 || siteIds.includes(normalized)) continue;
siteIds.push(normalized);
if (siteIds.length >= 500) break;
}
return siteIds.sort((left, right) => left - right);
}
function buildExcludedCredentialRefKey(ref: DownstreamExcludedCredentialRef): string {
return ref.kind === 'account_token'
? `${ref.kind}:${ref.siteId}:${ref.accountId}:${ref.tokenId}`
: `${ref.kind}:${ref.siteId}:${ref.accountId}`;
}
function compareExcludedCredentialRefs(
left: DownstreamExcludedCredentialRef,
right: DownstreamExcludedCredentialRef,
): number {
return buildExcludedCredentialRefKey(left).localeCompare(buildExcludedCredentialRefKey(right));
}
export function normalizeExcludedCredentialRefsInput(input: unknown): DownstreamExcludedCredentialRef[] {
const raw = typeof input === 'string'
? parseJson(input)
: input;
if (!Array.isArray(raw)) {
return [];
}
const refs: DownstreamExcludedCredentialRef[] = [];
const seen = new Set<string>();
for (const item of raw) {
if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
const kind = String((item as Record<string, unknown>).kind || '').trim();
const siteId = Math.trunc(Number((item as Record<string, unknown>).siteId));
const accountId = Math.trunc(Number((item as Record<string, unknown>).accountId));
if (!Number.isFinite(siteId) || siteId <= 0 || !Number.isFinite(accountId) || accountId <= 0) {
continue;
}
let normalizedRef: DownstreamExcludedCredentialRef | null = null;
if (kind === 'account_token') {
const tokenId = Math.trunc(Number((item as Record<string, unknown>).tokenId));
if (!Number.isFinite(tokenId) || tokenId <= 0) continue;
normalizedRef = {
kind: 'account_token',
siteId,
accountId,
tokenId,
};
} else if (kind === 'default_api_key') {
normalizedRef = {
kind: 'default_api_key',
siteId,
accountId,
};
}
if (!normalizedRef) continue;
const dedupeKey = buildExcludedCredentialRefKey(normalizedRef);
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
refs.push(normalizedRef);
if (refs.length >= 1000) break;
}
return refs.sort(compareExcludedCredentialRefs);
}
export function matchesDownstreamModelPattern(model: string, pattern: string): boolean {
const normalizedPattern = (pattern || '').trim();
if (!normalizedPattern) return false;
@@ -259,6 +345,8 @@ export function toDownstreamApiKeyPolicyView(row: DownstreamApiKeyRow): Downstre
const supportedModels = normalizeSupportedModelsInput(parseJson(row.supportedModels));
const allowedRouteIds = normalizeAllowedRouteIdsInput(parseJson(row.allowedRouteIds));
const siteWeightMultipliers = normalizeSiteWeightMultipliersInput(parseJson(row.siteWeightMultipliers));
const excludedSiteIds = normalizeExcludedSiteIdsInput(parseJson(row.excludedSiteIds));
const excludedCredentialRefs = normalizeExcludedCredentialRefsInput(parseJson(row.excludedCredentialRefs));
return {
id: row.id,
@@ -277,17 +365,21 @@ export function toDownstreamApiKeyPolicyView(row: DownstreamApiKeyRow): Downstre
supportedModels,
allowedRouteIds,
siteWeightMultipliers,
excludedSiteIds,
excludedCredentialRefs,
lastUsedAt: row.lastUsedAt || null,
createdAt: row.createdAt || null,
updatedAt: row.updatedAt || null,
};
}
export function toPolicyFromView(view: Pick<DownstreamApiKeyPolicyView, 'supportedModels' | 'allowedRouteIds' | 'siteWeightMultipliers'>): DownstreamRoutingPolicy {
export function toPolicyFromView(view: Pick<DownstreamApiKeyPolicyView, 'supportedModels' | 'allowedRouteIds' | 'siteWeightMultipliers' | 'excludedSiteIds' | 'excludedCredentialRefs'>): DownstreamRoutingPolicy {
return {
supportedModels: normalizeSupportedModelsInput(view.supportedModels),
allowedRouteIds: normalizeAllowedRouteIdsInput(view.allowedRouteIds),
siteWeightMultipliers: normalizeSiteWeightMultipliersInput(view.siteWeightMultipliers),
excludedSiteIds: normalizeExcludedSiteIdsInput(view.excludedSiteIds),
excludedCredentialRefs: normalizeExcludedCredentialRefsInput(view.excludedCredentialRefs),
denyAllWhenEmpty: true,
};
}
@@ -437,6 +529,8 @@ export function normalizeDownstreamApiKeyPayload(input: {
supportedModels?: unknown;
allowedRouteIds?: unknown;
siteWeightMultipliers?: unknown;
excludedSiteIds?: unknown;
excludedCredentialRefs?: unknown;
}) {
const name = typeof input.name === 'string' ? input.name.trim() : '';
const key = typeof input.key === 'string' ? input.key.trim() : '';
@@ -464,6 +558,8 @@ export function normalizeDownstreamApiKeyPayload(input: {
const supportedModels = normalizeSupportedModelsInput(input.supportedModels);
const allowedRouteIds = normalizeAllowedRouteIdsInput(input.allowedRouteIds);
const siteWeightMultipliers = normalizeSiteWeightMultipliersInput(input.siteWeightMultipliers);
const excludedSiteIds = normalizeExcludedSiteIdsInput(input.excludedSiteIds);
const excludedCredentialRefs = normalizeExcludedCredentialRefsInput(input.excludedCredentialRefs);
return {
name,
@@ -478,6 +574,8 @@ export function normalizeDownstreamApiKeyPayload(input: {
supportedModels,
allowedRouteIds,
siteWeightMultipliers,
excludedSiteIds,
excludedCredentialRefs,
};
}
@@ -1,7 +1,26 @@
export type DownstreamAccountTokenCredentialRef = {
kind: 'account_token';
siteId: number;
accountId: number;
tokenId: number;
};
export type DownstreamDefaultApiKeyCredentialRef = {
kind: 'default_api_key';
siteId: number;
accountId: number;
};
export type DownstreamExcludedCredentialRef =
| DownstreamAccountTokenCredentialRef
| DownstreamDefaultApiKeyCredentialRef;
export interface DownstreamRoutingPolicy {
supportedModels: string[];
allowedRouteIds: number[];
siteWeightMultipliers: Record<number, number>;
excludedSiteIds: number[];
excludedCredentialRefs: DownstreamExcludedCredentialRef[];
denyAllWhenEmpty?: boolean;
}
@@ -9,4 +28,6 @@ export const EMPTY_DOWNSTREAM_ROUTING_POLICY: DownstreamRoutingPolicy = {
supportedModels: [],
allowedRouteIds: [],
siteWeightMultipliers: {},
excludedSiteIds: [],
excludedCredentialRefs: [],
};
@@ -365,4 +365,201 @@ describe('TokenRouter downstream policy', () => {
expect(claudePick?.channel.routeId).toBe(claudeGroupRoute.id);
expect(gptPick?.channel.routeId).toBe(gptExactRoute.id);
});
it('excludes candidates from excluded sites before route selection', async () => {
const site = await db.insert(schema.sites).values({
name: 'site-excluded',
url: 'https://excluded.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'user-excluded',
accessToken: 'access-excluded',
apiToken: 'sk-excluded',
status: 'active',
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-4o-mini',
enabled: true,
}).returning().get();
const channel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: null,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
const policy: any = {
allowedRouteIds: [route.id],
supportedModels: [],
siteWeightMultipliers: {},
excludedSiteIds: [site.id],
excludedCredentialRefs: [],
};
const pick = await router.selectChannel('gpt-4o-mini', policy);
const decision = await router.explainSelectionForRoute(route.id, 'gpt-4o-mini', [], policy);
const candidate = decision.candidates.find((item) => item.channelId === channel.id);
expect(pick).toBeNull();
expect(candidate?.eligible).toBe(false);
expect(candidate?.reason).toContain('站点已被下游密钥排除');
});
it('excludes explicitly bound tokens by downstream credential refs', async () => {
const site = await db.insert(schema.sites).values({
name: 'site-token',
url: 'https://token.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const blockedAccount = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'user-blocked',
accessToken: 'access-blocked',
apiToken: 'sk-blocked-default',
status: 'active',
}).returning().get();
const allowedAccount = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'user-allowed',
accessToken: 'access-allowed',
apiToken: 'sk-allowed-default',
status: 'active',
}).returning().get();
const blockedToken = await db.insert(schema.accountTokens).values({
accountId: blockedAccount.id,
name: 'blocked-token',
token: 'sk-blocked-token',
enabled: true,
isDefault: true,
valueStatus: 'ready',
}).returning().get();
const allowedToken = await db.insert(schema.accountTokens).values({
accountId: allowedAccount.id,
name: 'allowed-token',
token: 'sk-allowed-token',
enabled: true,
isDefault: true,
valueStatus: 'ready',
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'claude-sonnet-4-6',
enabled: true,
}).returning().get();
const blockedChannel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: blockedAccount.id,
tokenId: blockedToken.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const allowedChannel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: allowedAccount.id,
tokenId: allowedToken.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
const policy: any = {
allowedRouteIds: [route.id],
supportedModels: [],
siteWeightMultipliers: {},
excludedSiteIds: [],
excludedCredentialRefs: [
{ kind: 'account_token', siteId: site.id, accountId: blockedAccount.id, tokenId: blockedToken.id },
],
};
const pick = await router.selectChannel('claude-sonnet-4-6', policy);
const decision = await router.explainSelectionForRoute(route.id, 'claude-sonnet-4-6', [], policy);
const blockedCandidate = decision.candidates.find((item) => item.channelId === blockedChannel.id);
const allowedCandidate = decision.candidates.find((item) => item.channelId === allowedChannel.id);
expect(pick?.channel.id).toBe(allowedChannel.id);
expect(blockedCandidate?.eligible).toBe(false);
expect(blockedCandidate?.reason).toContain('API Key/令牌已被下游密钥排除');
expect(allowedCandidate?.eligible).toBe(true);
});
it('excludes default api key channels by downstream credential refs', async () => {
const site = await db.insert(schema.sites).values({
name: 'site-default',
url: 'https://default.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const blockedAccount = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'user-blocked',
accessToken: 'access-blocked',
apiToken: 'sk-blocked-default',
status: 'active',
}).returning().get();
const allowedAccount = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'user-allowed',
accessToken: 'access-allowed',
apiToken: 'sk-allowed-default',
status: 'active',
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5-mini',
enabled: true,
}).returning().get();
const blockedChannel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: blockedAccount.id,
tokenId: null,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const allowedChannel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: allowedAccount.id,
tokenId: null,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
const policy: any = {
allowedRouteIds: [route.id],
supportedModels: [],
siteWeightMultipliers: {},
excludedSiteIds: [],
excludedCredentialRefs: [
{ kind: 'default_api_key', siteId: site.id, accountId: blockedAccount.id },
],
};
const pick = await router.selectChannel('gpt-5-mini', policy);
const decision = await router.explainSelectionForRoute(route.id, 'gpt-5-mini', [], policy);
const blockedCandidate = decision.candidates.find((item) => item.channelId === blockedChannel.id);
expect(pick?.channel.id).toBe(allowedChannel.id);
expect(blockedCandidate?.eligible).toBe(false);
expect(blockedCandidate?.reason).toContain('API Key/令牌已被下游密钥排除');
});
});
+60
View File
@@ -1260,6 +1260,7 @@ type CandidateEligibilityOptions = {
bypassSourceModelCheck?: boolean;
excludeChannelIds?: number[];
nowIso?: string;
downstreamPolicy?: DownstreamRoutingPolicy;
};
type CostSignal = {
@@ -1819,6 +1820,7 @@ export class TokenRouter {
match,
requestedModel,
normalizedPreferredChannelId,
downstreamPolicy,
excludeChannelIds,
);
}
@@ -1936,6 +1938,7 @@ export class TokenRouter {
bypassSourceModelCheck,
excludeChannelIds,
nowIso,
downstreamPolicy,
});
const recentlyFailed = routeStrategy !== 'round_robin'
@@ -2629,6 +2632,7 @@ export class TokenRouter {
bypassSourceModelCheck,
excludeChannelIds,
nowIso,
downstreamPolicy,
}).length === 0
));
@@ -2763,6 +2767,7 @@ export class TokenRouter {
match: RouteMatch,
requestedModel: string,
preferredChannelId: number,
downstreamPolicy: DownstreamRoutingPolicy,
excludeChannelIds: number[] = [],
recordSelection = true,
): Promise<SelectedChannel | null> {
@@ -2782,6 +2787,7 @@ export class TokenRouter {
bypassSourceModelCheck,
excludeChannelIds,
nowIso,
downstreamPolicy,
}).length === 0
));
@@ -2891,6 +2897,55 @@ export class TokenRouter {
return null;
}
private resolveDownstreamExclusionReason(
candidate: RouteChannelCandidate,
downstreamPolicy?: DownstreamRoutingPolicy,
): string | null {
if (!downstreamPolicy) return null;
const excludedSiteIds = Array.isArray(downstreamPolicy.excludedSiteIds)
? downstreamPolicy.excludedSiteIds
: [];
if (excludedSiteIds.includes(candidate.site.id)) {
return '站点已被下游密钥排除';
}
const excludedCredentialRefs = Array.isArray(downstreamPolicy.excludedCredentialRefs)
? downstreamPolicy.excludedCredentialRefs
: [];
if (excludedCredentialRefs.length <= 0) {
return null;
}
for (const ref of excludedCredentialRefs) {
if (ref.kind === 'account_token') {
if (
candidate.channel.tokenId === ref.tokenId
&& candidate.token?.id === ref.tokenId
&& candidate.account.id === ref.accountId
&& candidate.site.id === ref.siteId
) {
return 'API Key/令牌已被下游密钥排除';
}
continue;
}
if (
candidate.channel.tokenId == null
&& candidate.account.id === ref.accountId
&& candidate.site.id === ref.siteId
) {
const resolvedTokenValue = this.resolveChannelTokenValue(candidate);
const accountApiToken = candidate.account.apiToken?.trim() || '';
if (resolvedTokenValue && accountApiToken && resolvedTokenValue === accountApiToken) {
return 'API Key/令牌已被下游密钥排除';
}
}
}
return null;
}
private getCandidateEligibilityReasons(
candidate: RouteChannelCandidate,
options: CandidateEligibilityOptions,
@@ -2918,6 +2973,11 @@ export class TokenRouter {
reasonParts.push(`站点状态=${candidate.site.status || 'disabled'}`);
}
const downstreamExclusionReason = this.resolveDownstreamExclusionReason(candidate, options.downstreamPolicy);
if (downstreamExclusionReason) {
reasonParts.push(downstreamExclusionReason);
}
if (excludeChannelIds.includes(candidate.channel.id)) {
reasonParts.push('当前请求已尝试');
}