forked from boos/BoosAPI
[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:
@@ -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[].';
|
||||
}
|
||||
|
||||
@@ -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 +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 +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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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'))`),
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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/令牌已被下游密钥排除');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('当前请求已尝试');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user