[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
+4
View File
@@ -21,6 +21,8 @@ concurrency:
env: env:
NODE_VERSION: 25 NODE_VERSION: 25
# Non-desktop CI jobs do not need the Electron runtime binary.
ELECTRON_SKIP_BINARY_DOWNLOAD: '1'
jobs: jobs:
changes: changes:
@@ -129,6 +131,8 @@ jobs:
cache: npm cache: npm
- name: Install dependencies - name: Install dependencies
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: ''
run: npm ci --prefer-offline --no-audit --no-fund run: npm ci --prefer-offline --no-audit --no-fund
- name: Build desktop - name: Build desktop
@@ -0,0 +1,3 @@
ALTER TABLE `downstream_api_keys` ADD `excluded_site_ids` text;
--> statement-breakpoint
ALTER TABLE `downstream_api_keys` ADD `excluded_credential_refs` text;
+7
View File
@@ -134,6 +134,13 @@
"when": 1775058998448, "when": 1775058998448,
"tag": "0019_proxy_logs_stream_timing", "tag": "0019_proxy_logs_stream_timing",
"breakpoints": true "breakpoints": true
},
{
"idx": 19,
"version": "6",
"when": 1775397600000,
"tag": "0020_downstream_api_key_exclusions",
"breakpoints": true
} }
] ]
} }
@@ -1,5 +1,19 @@
import { z } from 'zod'; 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({ const downstreamApiKeyPayloadSchema = z.object({
name: z.string().optional(), name: z.string().optional(),
key: z.string().optional(), key: z.string().optional(),
@@ -16,6 +30,8 @@ const downstreamApiKeyPayloadSchema = z.object({
z.string(), z.string(),
z.record(z.string(), z.union([z.number(), z.string()])), z.record(z.string(), z.union([z.number(), z.string()])),
]).optional(), ]).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(); }).passthrough();
const downstreamApiKeyBatchPayloadSchema = z.object({ const downstreamApiKeyBatchPayloadSchema = z.object({
@@ -76,6 +92,12 @@ function formatDownstreamApiKeyPayloadError(error: z.ZodError): string {
if (firstPath === 'siteWeightMultipliers') { if (firstPath === 'siteWeightMultipliers') {
return 'Invalid siteWeightMultipliers. Expected JSON object or string.'; 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') { if (firstPath === 'ids') {
return 'Invalid ids. Expected number[].'; 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 `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 `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 `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 `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 `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'))); 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; -- no schema changes detected for mysql
ALTER TABLE `proxy_logs` ADD COLUMN `first_byte_latency_ms` INT;
@@ -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 "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 "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 "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 "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 "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')); 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; -- no schema changes detected for postgres
ALTER TABLE "proxy_logs" ADD COLUMN "first_byte_latency_ms" INTEGER;
@@ -355,6 +355,18 @@
"notNull": false, "notNull": false,
"defaultValue": null, "defaultValue": null,
"primaryKey": false "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', '0011_downstream_api_key_metadata',
'0012_account_token_value_status', '0012_account_token_value_status',
'0013_oauth_multi_provider', '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)); 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> supportedModels: text('supported_models'), // JSON array<string>
allowedRouteIds: text('allowed_route_ids'), // JSON array<number> allowedRouteIds: text('allowed_route_ids'), // JSON array<number>
siteWeightMultipliers: text('site_weight_multipliers'), // JSON object { [siteId]: multiplier } 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'), lastUsedAt: text('last_used_at'),
createdAt: text('created_at').default(sql`(datetime('now'))`), createdAt: text('created_at').default(sql`(datetime('now'))`),
updatedAt: text('updated_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('mapping')
|| normalizedColumn.includes('headers') || normalizedColumn.includes('headers')
|| normalizedColumn.includes('config') || normalizedColumn.includes('config')
|| normalizedColumn.endsWith('_site_ids')
|| normalizedColumn.includes('credential_refs')
|| normalizedColumn.includes('details') || normalizedColumn.includes('details')
|| normalizedColumn.includes('meta') || normalizedColumn.includes('meta')
|| normalizedColumn.includes('models') || 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 () => { it('supports batch enable/disable/reset/delete operations', async () => {
const inserted = await db.insert(schema.downstreamApiKeys).values([ const inserted = await db.insert(schema.downstreamApiKeys).values([
{ {
@@ -644,7 +836,7 @@ describe('downstream api keys routes', () => {
expect(updateRes.statusCode).toBe(400); expect(updateRes.statusCode).toBe(400);
expect(updateRes.json()).toMatchObject({ expect(updateRes.json()).toMatchObject({
success: false, success: false,
message: 'siteWeightMultipliers 包含不存在的站点: 999', message: '策略中包含不存在的站点: 999',
}); });
const filteredSummaryRes = await app.inject({ const filteredSummaryRes = await app.inject({
+82 -2
View File
@@ -9,6 +9,7 @@ import {
toDownstreamApiKeyPolicyView, toDownstreamApiKeyPolicyView,
toPersistenceJson, toPersistenceJson,
} from '../../services/downstreamApiKeyService.js'; } from '../../services/downstreamApiKeyService.js';
import type { DownstreamExcludedCredentialRef } from '../../services/downstreamPolicyTypes.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { import {
parseDownstreamApiKeyBatchPayload, parseDownstreamApiKeyBatchPayload,
@@ -159,6 +160,8 @@ function resolveBucketTsExpression(bucketSeconds: number) {
async function validatePolicyReferences(input: { async function validatePolicyReferences(input: {
allowedRouteIds: number[]; allowedRouteIds: number[];
siteWeightMultipliers: Record<number, number>; siteWeightMultipliers: Record<number, number>;
excludedSiteIds: number[];
excludedCredentialRefs: DownstreamExcludedCredentialRef[];
}): Promise<string | null> { }): Promise<string | null> {
const routeIds = input.allowedRouteIds || []; const routeIds = input.allowedRouteIds || [];
if (routeIds.length > 0) { 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)) .map((key) => Number(key))
.filter((value) => Number.isFinite(value) && value > 0) .filter((value) => Number.isFinite(value) && value > 0)
.map((value) => Math.trunc(value)); .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) { if (siteIds.length > 0) {
const rows = await db.select({ id: schema.sites.id }) const rows = await db.select({ id: schema.sites.id })
.from(schema.sites) .from(schema.sites)
@@ -185,7 +192,70 @@ async function validatePolicyReferences(input: {
const existingIds = new Set(rows.map((row) => Number(row.id))); const existingIds = new Set(rows.map((row) => Number(row.id)));
const missingIds = siteIds.filter((id) => !existingIds.has(id)); const missingIds = siteIds.filter((id) => !existingIds.has(id));
if (missingIds.length > 0) { 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({ const policyRefError = await validatePolicyReferences({
allowedRouteIds: normalized.allowedRouteIds, allowedRouteIds: normalized.allowedRouteIds,
siteWeightMultipliers: normalized.siteWeightMultipliers, siteWeightMultipliers: normalized.siteWeightMultipliers,
excludedSiteIds: normalized.excludedSiteIds,
excludedCredentialRefs: normalized.excludedCredentialRefs,
}); });
if (policyRefError) { if (policyRefError) {
return reply.code(400).send({ success: false, message: policyRefError }); return reply.code(400).send({ success: false, message: policyRefError });
@@ -496,6 +568,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
supportedModels: toPersistenceJson(normalized.supportedModels), supportedModels: toPersistenceJson(normalized.supportedModels),
allowedRouteIds: toPersistenceJson(normalized.allowedRouteIds), allowedRouteIds: toPersistenceJson(normalized.allowedRouteIds),
siteWeightMultipliers: toPersistenceJson(normalized.siteWeightMultipliers), siteWeightMultipliers: toPersistenceJson(normalized.siteWeightMultipliers),
excludedSiteIds: toPersistenceJson(normalized.excludedSiteIds),
excludedCredentialRefs: toPersistenceJson(normalized.excludedCredentialRefs),
createdAt: nowIso, createdAt: nowIso,
updatedAt: nowIso, updatedAt: nowIso,
}, },
@@ -552,6 +626,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
supportedModels: hasOwn('supportedModels') ? body.supportedModels : existingView.supportedModels, supportedModels: hasOwn('supportedModels') ? body.supportedModels : existingView.supportedModels,
allowedRouteIds: hasOwn('allowedRouteIds') ? body.allowedRouteIds : existingView.allowedRouteIds, allowedRouteIds: hasOwn('allowedRouteIds') ? body.allowedRouteIds : existingView.allowedRouteIds,
siteWeightMultipliers: hasOwn('siteWeightMultipliers') ? body.siteWeightMultipliers : existingView.siteWeightMultipliers, siteWeightMultipliers: hasOwn('siteWeightMultipliers') ? body.siteWeightMultipliers : existingView.siteWeightMultipliers,
excludedSiteIds: hasOwn('excludedSiteIds') ? body.excludedSiteIds : existingView.excludedSiteIds,
excludedCredentialRefs: hasOwn('excludedCredentialRefs') ? body.excludedCredentialRefs : existingView.excludedCredentialRefs,
}); });
} catch (error: unknown) { } catch (error: unknown) {
return reply.code(400).send({ success: false, message: (error as Error)?.message || '参数无效' }); 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({ const policyRefError = await validatePolicyReferences({
allowedRouteIds: normalized.allowedRouteIds, allowedRouteIds: normalized.allowedRouteIds,
siteWeightMultipliers: normalized.siteWeightMultipliers, siteWeightMultipliers: normalized.siteWeightMultipliers,
excludedSiteIds: normalized.excludedSiteIds,
excludedCredentialRefs: normalized.excludedCredentialRefs,
}); });
if (policyRefError) { if (policyRefError) {
return reply.code(400).send({ success: false, message: policyRefError }); return reply.code(400).send({ success: false, message: policyRefError });
@@ -589,6 +667,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
supportedModels: toPersistenceJson(normalized.supportedModels), supportedModels: toPersistenceJson(normalized.supportedModels),
allowedRouteIds: toPersistenceJson(normalized.allowedRouteIds), allowedRouteIds: toPersistenceJson(normalized.allowedRouteIds),
siteWeightMultipliers: toPersistenceJson(normalized.siteWeightMultipliers), siteWeightMultipliers: toPersistenceJson(normalized.siteWeightMultipliers),
excludedSiteIds: toPersistenceJson(normalized.excludedSiteIds),
excludedCredentialRefs: toPersistenceJson(normalized.excludedCredentialRefs),
updatedAt: nowIso, updatedAt: nowIso,
}).where(eq(schema.downstreamApiKeys.id, id)).run(); }).where(eq(schema.downstreamApiKeys.id, id)).run();
+16
View File
@@ -202,6 +202,10 @@ describe('backupService', () => {
supportedModels: '["gpt-4o-mini"]', supportedModels: '["gpt-4o-mini"]',
allowedRouteIds: `[${route.id}]`, allowedRouteIds: `[${route.id}]`,
siteWeightMultipliers: `{"${site.id}":1.5}`, 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, lastUsedAt: now,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -238,6 +242,8 @@ describe('backupService', () => {
supportedModels: '["gpt-4o-mini"]', supportedModels: '["gpt-4o-mini"]',
allowedRouteIds: `[${route.id}]`, allowedRouteIds: `[${route.id}]`,
siteWeightMultipliers: `{"${site.id}":1.5}`, 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'); expect(exported.accounts.accounts[0]).not.toHaveProperty('balanceUsed');
@@ -307,6 +313,8 @@ describe('backupService', () => {
supportedModels: '["gpt-4o-mini"]', supportedModels: '["gpt-4o-mini"]',
allowedRouteIds: `[${route.id}]`, allowedRouteIds: `[${route.id}]`,
siteWeightMultipliers: `{"${site.id}":1.5}`, siteWeightMultipliers: `{"${site.id}":1.5}`,
excludedSiteIds: `[${site.id}]`,
excludedCredentialRefs: `[{"kind":"account_token","siteId":${site.id},"accountId":${account.id},"tokenId":${accountToken.id}}]`,
lastUsedAt: now, lastUsedAt: now,
}), }),
]); ]);
@@ -684,6 +692,10 @@ describe('backupService', () => {
supportedModels: '["gpt-4o"]', supportedModels: '["gpt-4o"]',
allowedRouteIds: `[${route.id}]`, allowedRouteIds: `[${route.id}]`,
siteWeightMultipliers: `{"${site.id}":1.25}`, 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, lastUsedAt: exportedAt,
createdAt: exportedAt, createdAt: exportedAt,
updatedAt: exportedAt, updatedAt: exportedAt,
@@ -828,6 +840,8 @@ describe('backupService', () => {
supportedModels: '["gpt-local"]', supportedModels: '["gpt-local"]',
allowedRouteIds: '[999]', allowedRouteIds: '[999]',
siteWeightMultipliers: '{"999":9}', siteWeightMultipliers: '{"999":9}',
excludedSiteIds: '[999]',
excludedCredentialRefs: '[{"kind":"default_api_key","siteId":999,"accountId":999}]',
lastUsedAt: localRuntimeAt, lastUsedAt: localRuntimeAt,
updatedAt: localRuntimeAt, updatedAt: localRuntimeAt,
}).where(eq(schema.downstreamApiKeys.id, downstreamKey.id)).run(); }).where(eq(schema.downstreamApiKeys.id, downstreamKey.id)).run();
@@ -991,6 +1005,8 @@ describe('backupService', () => {
supportedModels: '["gpt-4o"]', supportedModels: '["gpt-4o"]',
allowedRouteIds: `[${route.id}]`, allowedRouteIds: `[${route.id}]`,
siteWeightMultipliers: `{"${site.id}":1.25}`, siteWeightMultipliers: `{"${site.id}":1.25}`,
excludedSiteIds: `[${site.id}]`,
excludedCredentialRefs: `[{"kind":"account_token","siteId":${site.id},"accountId":${account.id},"tokenId":${accountToken.id}}]`,
lastUsedAt: localRuntimeAt, lastUsedAt: localRuntimeAt,
}), }),
]); ]);
+4
View File
@@ -97,6 +97,8 @@ type BackupDownstreamApiKeyRow = Pick<DownstreamApiKeyRow,
| 'supportedModels' | 'supportedModels'
| 'allowedRouteIds' | 'allowedRouteIds'
| 'siteWeightMultipliers' | 'siteWeightMultipliers'
| 'excludedSiteIds'
| 'excludedCredentialRefs'
> & Partial<Pick<DownstreamApiKeyRow, 'usedCost' | 'usedRequests' | 'lastUsedAt'>>; > & Partial<Pick<DownstreamApiKeyRow, 'usedCost' | 'usedRequests' | 'lastUsedAt'>>;
interface AccountsBackupSection { interface AccountsBackupSection {
@@ -1769,6 +1771,8 @@ async function importAccountsSection(section: AccountsBackupSection): Promise<vo
supportedModels: row.supportedModels ?? null, supportedModels: row.supportedModels ?? null,
allowedRouteIds: row.allowedRouteIds ?? null, allowedRouteIds: row.allowedRouteIds ?? null,
siteWeightMultipliers: row.siteWeightMultipliers ?? null, siteWeightMultipliers: row.siteWeightMultipliers ?? null,
excludedSiteIds: row.excludedSiteIds ?? null,
excludedCredentialRefs: row.excludedCredentialRefs ?? null,
lastUsedAt: runtimeDownstream?.lastUsedAt ?? row.lastUsedAt ?? null, lastUsedAt: runtimeDownstream?.lastUsedAt ?? row.lastUsedAt ?? null,
}).run(); }).run();
const downstreamApiKeyId = requireInsertedRowId( const downstreamApiKeyId = requireInsertedRowId(
@@ -378,6 +378,8 @@ describe('databaseMigrationService', () => {
supportedModels: ['gpt-4o-mini'], supportedModels: ['gpt-4o-mini'],
allowedRouteIds: [3], allowedRouteIds: [3],
siteWeightMultipliers: { 1: 1.5 }, siteWeightMultipliers: { 1: 1.5 },
excludedSiteIds: [1],
excludedCredentialRefs: [{ kind: 'default_api_key', siteId: 1, accountId: 2 }],
enabled: true, enabled: true,
}], }],
events: [], 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('supported_models')]).toBe('["gpt-4o-mini"]');
expect(downstreamKeyStatement?.values[downstreamKeyStatement.columns.indexOf('allowed_route_ids')]).toBe('[3]'); 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('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)', () => { 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) { for (const row of snapshot.accounts.downstreamApiKeys) {
statements.push({ statements.push({
table: 'downstream_api_keys', 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: [ values: [
asNumber(row.id, 0), asNumber(row.id, 0),
asNullableString(row.name), asNullableString(row.name),
@@ -678,6 +678,8 @@ function buildStatements(
serializeColumnValue('downstream_api_keys', 'supported_models', row.supportedModels, contract), serializeColumnValue('downstream_api_keys', 'supported_models', row.supportedModels, contract),
serializeColumnValue('downstream_api_keys', 'allowed_route_ids', row.allowedRouteIds, 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', '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.lastUsedAt),
asNullableString(row.createdAt), asNullableString(row.createdAt),
asNullableString(row.updatedAt), asNullableString(row.updatedAt),
+100 -2
View File
@@ -2,7 +2,11 @@ import { and, eq, inArray, sql } from 'drizzle-orm';
import { minimatch } from 'minimatch'; import { minimatch } from 'minimatch';
import { db, schema } from '../db/index.js'; import { db, schema } from '../db/index.js';
import { config } from '../config.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; export type DownstreamApiKeyRow = typeof schema.downstreamApiKeys.$inferSelect;
@@ -23,6 +27,8 @@ export type DownstreamApiKeyPolicyView = {
supportedModels: string[]; supportedModels: string[];
allowedRouteIds: number[]; allowedRouteIds: number[];
siteWeightMultipliers: Record<number, number>; siteWeightMultipliers: Record<number, number>;
excludedSiteIds: number[];
excludedCredentialRefs: DownstreamExcludedCredentialRef[];
lastUsedAt: string | null; lastUsedAt: string | null;
createdAt: string | null; createdAt: string | null;
updatedAt: string | null; updatedAt: string | null;
@@ -196,6 +202,86 @@ export function normalizeSiteWeightMultipliersInput(input: unknown): Record<numb
return result; 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 { export function matchesDownstreamModelPattern(model: string, pattern: string): boolean {
const normalizedPattern = (pattern || '').trim(); const normalizedPattern = (pattern || '').trim();
if (!normalizedPattern) return false; if (!normalizedPattern) return false;
@@ -259,6 +345,8 @@ export function toDownstreamApiKeyPolicyView(row: DownstreamApiKeyRow): Downstre
const supportedModels = normalizeSupportedModelsInput(parseJson(row.supportedModels)); const supportedModels = normalizeSupportedModelsInput(parseJson(row.supportedModels));
const allowedRouteIds = normalizeAllowedRouteIdsInput(parseJson(row.allowedRouteIds)); const allowedRouteIds = normalizeAllowedRouteIdsInput(parseJson(row.allowedRouteIds));
const siteWeightMultipliers = normalizeSiteWeightMultipliersInput(parseJson(row.siteWeightMultipliers)); const siteWeightMultipliers = normalizeSiteWeightMultipliersInput(parseJson(row.siteWeightMultipliers));
const excludedSiteIds = normalizeExcludedSiteIdsInput(parseJson(row.excludedSiteIds));
const excludedCredentialRefs = normalizeExcludedCredentialRefsInput(parseJson(row.excludedCredentialRefs));
return { return {
id: row.id, id: row.id,
@@ -277,17 +365,21 @@ export function toDownstreamApiKeyPolicyView(row: DownstreamApiKeyRow): Downstre
supportedModels, supportedModels,
allowedRouteIds, allowedRouteIds,
siteWeightMultipliers, siteWeightMultipliers,
excludedSiteIds,
excludedCredentialRefs,
lastUsedAt: row.lastUsedAt || null, lastUsedAt: row.lastUsedAt || null,
createdAt: row.createdAt || null, createdAt: row.createdAt || null,
updatedAt: row.updatedAt || 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 { return {
supportedModels: normalizeSupportedModelsInput(view.supportedModels), supportedModels: normalizeSupportedModelsInput(view.supportedModels),
allowedRouteIds: normalizeAllowedRouteIdsInput(view.allowedRouteIds), allowedRouteIds: normalizeAllowedRouteIdsInput(view.allowedRouteIds),
siteWeightMultipliers: normalizeSiteWeightMultipliersInput(view.siteWeightMultipliers), siteWeightMultipliers: normalizeSiteWeightMultipliersInput(view.siteWeightMultipliers),
excludedSiteIds: normalizeExcludedSiteIdsInput(view.excludedSiteIds),
excludedCredentialRefs: normalizeExcludedCredentialRefsInput(view.excludedCredentialRefs),
denyAllWhenEmpty: true, denyAllWhenEmpty: true,
}; };
} }
@@ -437,6 +529,8 @@ export function normalizeDownstreamApiKeyPayload(input: {
supportedModels?: unknown; supportedModels?: unknown;
allowedRouteIds?: unknown; allowedRouteIds?: unknown;
siteWeightMultipliers?: unknown; siteWeightMultipliers?: unknown;
excludedSiteIds?: unknown;
excludedCredentialRefs?: unknown;
}) { }) {
const name = typeof input.name === 'string' ? input.name.trim() : ''; const name = typeof input.name === 'string' ? input.name.trim() : '';
const key = typeof input.key === 'string' ? input.key.trim() : ''; const key = typeof input.key === 'string' ? input.key.trim() : '';
@@ -464,6 +558,8 @@ export function normalizeDownstreamApiKeyPayload(input: {
const supportedModels = normalizeSupportedModelsInput(input.supportedModels); const supportedModels = normalizeSupportedModelsInput(input.supportedModels);
const allowedRouteIds = normalizeAllowedRouteIdsInput(input.allowedRouteIds); const allowedRouteIds = normalizeAllowedRouteIdsInput(input.allowedRouteIds);
const siteWeightMultipliers = normalizeSiteWeightMultipliersInput(input.siteWeightMultipliers); const siteWeightMultipliers = normalizeSiteWeightMultipliersInput(input.siteWeightMultipliers);
const excludedSiteIds = normalizeExcludedSiteIdsInput(input.excludedSiteIds);
const excludedCredentialRefs = normalizeExcludedCredentialRefsInput(input.excludedCredentialRefs);
return { return {
name, name,
@@ -478,6 +574,8 @@ export function normalizeDownstreamApiKeyPayload(input: {
supportedModels, supportedModels,
allowedRouteIds, allowedRouteIds,
siteWeightMultipliers, 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 { export interface DownstreamRoutingPolicy {
supportedModels: string[]; supportedModels: string[];
allowedRouteIds: number[]; allowedRouteIds: number[];
siteWeightMultipliers: Record<number, number>; siteWeightMultipliers: Record<number, number>;
excludedSiteIds: number[];
excludedCredentialRefs: DownstreamExcludedCredentialRef[];
denyAllWhenEmpty?: boolean; denyAllWhenEmpty?: boolean;
} }
@@ -9,4 +28,6 @@ export const EMPTY_DOWNSTREAM_ROUTING_POLICY: DownstreamRoutingPolicy = {
supportedModels: [], supportedModels: [],
allowedRouteIds: [], allowedRouteIds: [],
siteWeightMultipliers: {}, siteWeightMultipliers: {},
excludedSiteIds: [],
excludedCredentialRefs: [],
}; };
@@ -365,4 +365,201 @@ describe('TokenRouter downstream policy', () => {
expect(claudePick?.channel.routeId).toBe(claudeGroupRoute.id); expect(claudePick?.channel.routeId).toBe(claudeGroupRoute.id);
expect(gptPick?.channel.routeId).toBe(gptExactRoute.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; bypassSourceModelCheck?: boolean;
excludeChannelIds?: number[]; excludeChannelIds?: number[];
nowIso?: string; nowIso?: string;
downstreamPolicy?: DownstreamRoutingPolicy;
}; };
type CostSignal = { type CostSignal = {
@@ -1819,6 +1820,7 @@ export class TokenRouter {
match, match,
requestedModel, requestedModel,
normalizedPreferredChannelId, normalizedPreferredChannelId,
downstreamPolicy,
excludeChannelIds, excludeChannelIds,
); );
} }
@@ -1936,6 +1938,7 @@ export class TokenRouter {
bypassSourceModelCheck, bypassSourceModelCheck,
excludeChannelIds, excludeChannelIds,
nowIso, nowIso,
downstreamPolicy,
}); });
const recentlyFailed = routeStrategy !== 'round_robin' const recentlyFailed = routeStrategy !== 'round_robin'
@@ -2629,6 +2632,7 @@ export class TokenRouter {
bypassSourceModelCheck, bypassSourceModelCheck,
excludeChannelIds, excludeChannelIds,
nowIso, nowIso,
downstreamPolicy,
}).length === 0 }).length === 0
)); ));
@@ -2763,6 +2767,7 @@ export class TokenRouter {
match: RouteMatch, match: RouteMatch,
requestedModel: string, requestedModel: string,
preferredChannelId: number, preferredChannelId: number,
downstreamPolicy: DownstreamRoutingPolicy,
excludeChannelIds: number[] = [], excludeChannelIds: number[] = [],
recordSelection = true, recordSelection = true,
): Promise<SelectedChannel | null> { ): Promise<SelectedChannel | null> {
@@ -2782,6 +2787,7 @@ export class TokenRouter {
bypassSourceModelCheck, bypassSourceModelCheck,
excludeChannelIds, excludeChannelIds,
nowIso, nowIso,
downstreamPolicy,
}).length === 0 }).length === 0
)); ));
@@ -2891,6 +2897,55 @@ export class TokenRouter {
return null; 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( private getCandidateEligibilityReasons(
candidate: RouteChannelCandidate, candidate: RouteChannelCandidate,
options: CandidateEligibilityOptions, options: CandidateEligibilityOptions,
@@ -2918,6 +2973,11 @@ export class TokenRouter {
reasonParts.push(`站点状态=${candidate.site.status || 'disabled'}`); reasonParts.push(`站点状态=${candidate.site.status || 'disabled'}`);
} }
const downstreamExclusionReason = this.resolveDownstreamExclusionReason(candidate, options.downstreamPolicy);
if (downstreamExclusionReason) {
reasonParts.push(downstreamExclusionReason);
}
if (excludeChannelIds.includes(candidate.channel.id)) { if (excludeChannelIds.includes(candidate.channel.id)) {
reasonParts.push('当前请求已尝试'); reasonParts.push('当前请求已尝试');
} }
+35 -1
View File
@@ -660,6 +660,32 @@ export type OAuthConnectionsResponse = {
offset: number; offset: number;
}; };
export type OAuthQuotaBatchRefreshResponse = {
success: boolean;
refreshed: number;
failed: number;
items: Array<{
accountId: number;
success: boolean;
quota?: OAuthQuotaInfo;
error?: string;
}>;
};
export type OAuthImportResponse = {
success: boolean;
imported: number;
skipped: number;
failed: number;
items: Array<{
name: string;
status: 'imported' | 'skipped' | 'failed';
accountId?: number;
provider?: string;
message?: string;
}>;
};
export const api = { export const api = {
// Sites // Sites
getSites: () => request('/api/sites'), getSites: () => request('/api/sites'),
@@ -673,7 +699,7 @@ export const api = {
getSiteAvailableModels: (siteId: number) => request(`/api/sites/${siteId}/available-models`), getSiteAvailableModels: (siteId: number) => request(`/api/sites/${siteId}/available-models`),
// Accounts // Accounts
getAccounts: () => request('/api/accounts'), getAccounts: (params?: { includeOauth?: boolean }) => request(`/api/accounts${buildQueryString(params)}`),
addAccount: (data: any) => request('/api/accounts', { method: 'POST', body: JSON.stringify(data) }), addAccount: (data: any) => request('/api/accounts', { method: 'POST', body: JSON.stringify(data) }),
loginAccount: (data: { siteId: number; username: string; password: string }) => request('/api/accounts/login', { method: 'POST', body: JSON.stringify(data) }), loginAccount: (data: { siteId: number; username: string; password: string }) => request('/api/accounts/login', { method: 'POST', body: JSON.stringify(data) }),
verifyToken: (data: { siteId: number; accessToken: string; platformUserId?: number; credentialMode?: 'auto' | 'session' | 'apikey' }) => request('/api/accounts/verify-token', { method: 'POST', body: JSON.stringify(data) }), verifyToken: (data: { siteId: number; accessToken: string; platformUserId?: number; credentialMode?: 'auto' | 'session' | 'apikey' }) => request('/api/accounts/verify-token', { method: 'POST', body: JSON.stringify(data) }),
@@ -798,6 +824,10 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({}), body: JSON.stringify({}),
}) as Promise<{ success: true; quota: OAuthQuotaInfo }>, }) as Promise<{ success: true; quota: OAuthQuotaInfo }>,
refreshOAuthConnectionQuotaBatch: (accountIds: number[]) => request('/api/oauth/connections/quota/refresh-batch', {
method: 'POST',
body: JSON.stringify({ accountIds }),
}) as Promise<OAuthQuotaBatchRefreshResponse>,
rebindOAuthConnection: (accountId: number, data?: { proxyUrl?: string | null }) => request(`/api/oauth/connections/${accountId}/rebind`, { rebindOAuthConnection: (accountId: number, data?: { proxyUrl?: string | null }) => request(`/api/oauth/connections/${accountId}/rebind`, {
method: 'POST', method: 'POST',
body: JSON.stringify(data || {}), body: JSON.stringify(data || {}),
@@ -805,6 +835,10 @@ export const api = {
deleteOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}`, { deleteOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}`, {
method: 'DELETE', method: 'DELETE',
}) as Promise<{ success: true }>, }) as Promise<{ success: true }>,
importOAuthConnections: (data: Record<string, unknown>) => request('/api/oauth/import', {
method: 'POST',
body: JSON.stringify({ data }),
}) as Promise<OAuthImportResponse>,
// Events // Events
getEvents: (params?: string) => request(`/api/events${params ? '?' + params : ''}`), getEvents: (params?: string) => request(`/api/events${params ? '?' + params : ''}`),
+127
View File
@@ -9,6 +9,8 @@ const { apiMock } = vi.hoisted(() => ({
getDownstreamApiKeysSummary: vi.fn(), getDownstreamApiKeysSummary: vi.fn(),
getDownstreamApiKeys: vi.fn(), getDownstreamApiKeys: vi.fn(),
getRoutesLite: vi.fn(), getRoutesLite: vi.fn(),
getAccounts: vi.fn(),
getAccountTokens: vi.fn(),
getDownstreamApiKeyOverview: vi.fn(), getDownstreamApiKeyOverview: vi.fn(),
getDownstreamApiKeyTrend: vi.fn(), getDownstreamApiKeyTrend: vi.fn(),
createDownstreamApiKey: vi.fn(), createDownstreamApiKey: vi.fn(),
@@ -135,6 +137,50 @@ beforeEach(() => {
{ id: 11, modelPattern: 'claude-*', displayName: '默认群组', enabled: true }, { id: 11, modelPattern: 'claude-*', displayName: '默认群组', enabled: true },
{ id: 12, modelPattern: 'gpt-4.1-mini', displayName: 'GPT 4.1 Mini', enabled: true }, { id: 12, modelPattern: 'gpt-4.1-mini', displayName: 'GPT 4.1 Mini', enabled: true },
]); ]);
apiMock.getAccounts.mockResolvedValue([
{
id: 101,
username: '站点A账号',
apiToken: 'sk-default-a',
status: 'active',
credentialMode: 'session',
site: {
id: 201,
name: '站点A',
status: 'active',
},
},
{
id: 102,
username: '站点B账号',
apiToken: 'sk-default-b',
status: 'active',
credentialMode: 'session',
site: {
id: 202,
name: '站点B',
status: 'active',
},
},
]);
apiMock.getAccountTokens.mockResolvedValue([
{
id: 301,
accountId: 101,
name: 'token-a',
tokenGroup: 'group-a',
enabled: true,
valueStatus: 'ready',
account: {
id: 101,
username: '站点A账号',
},
site: {
id: 201,
name: '站点A',
},
},
]);
apiMock.getDownstreamApiKeyOverview.mockResolvedValue({ apiMock.getDownstreamApiKeyOverview.mockResolvedValue({
success: true, success: true,
item: buildSummaryItem(), item: buildSummaryItem(),
@@ -776,4 +822,85 @@ describe('DownstreamKeys page', () => {
root?.unmount(); root?.unmount();
} }
}); });
it('lazy loads exclusion sources and submits excluded sites and credentials', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
expect(apiMock.getAccounts).not.toHaveBeenCalled();
expect(apiMock.getAccountTokens).not.toHaveBeenCalled();
const createBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('新增下游密钥'))[0];
await act(async () => {
createBtn.props.onClick();
});
await flushMicrotasks();
expect(apiMock.getAccounts).toHaveBeenCalledTimes(1);
expect(apiMock.getAccountTokens).toHaveBeenCalledTimes(1);
const advancedBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('高级配置'))[0];
await act(async () => {
advancedBtn.props.onClick();
});
await flushMicrotasks();
const text = collectText(root!.root);
expect(text).toContain('排除站点');
expect(text).toContain('排除 API Key/令牌');
expect(text).toContain('默认 API Key');
expect(text).toContain('group-a');
const inputs = root!.root.findAllByType('input');
const nameInput = inputs.find((node) => node.props.placeholder === '例如:项目 A / 移动端');
const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...');
await act(async () => {
nameInput!.props.onChange({ target: { value: 'excluded-key' } });
keyInput!.props.onChange({ target: { value: 'sk-excluded-key-0405' } });
});
await flushMicrotasks();
const siteLabel = root!.root.findAll((node) => node.type === 'label' && collectText(node).includes('站点B'))[0];
const tokenLabel = root!.root.findAll((node) => node.type === 'label' && collectText(node).includes('token-a'))[0];
const defaultApiKeyLabel = root!.root.findAll((node) => node.type === 'label' && collectText(node).includes('默认 API Key'))[0];
const siteCheckbox = siteLabel.findByType('input');
const tokenCheckbox = tokenLabel.findByType('input');
const defaultApiKeyCheckbox = defaultApiKeyLabel.findByType('input');
await act(async () => {
siteCheckbox.props.onChange({ target: { checked: true } });
tokenCheckbox.props.onChange({ target: { checked: true } });
defaultApiKeyCheckbox.props.onChange({ target: { checked: true } });
});
await flushMicrotasks();
const saveBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('创建密钥'))[0];
await act(async () => {
saveBtn.props.onClick();
});
await flushMicrotasks();
expect(apiMock.createDownstreamApiKey).toHaveBeenCalledWith(expect.objectContaining({
name: 'excluded-key',
key: 'sk-excluded-key-0405',
excludedSiteIds: [202],
excludedCredentialRefs: [
{ kind: 'account_token', siteId: 201, accountId: 101, tokenId: 301 },
{ kind: 'default_api_key', siteId: 201, accountId: 101 },
],
}));
} finally {
root?.unmount();
}
});
}); });
+145 -431
View File
@@ -9,6 +9,13 @@ import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js'; import ModernSelect from '../components/ModernSelect.js';
import { useIsMobile } from '../components/useIsMobile.js'; import { useIsMobile } from '../components/useIsMobile.js';
import { tr } from '../i18n.js'; import { tr } from '../i18n.js';
import DownstreamKeyEditorModal, {
TagInput,
type DownstreamCredentialOption,
type DownstreamExcludedCredentialRef,
type DownstreamKeyEditorForm,
type DownstreamSiteOption,
} from './downstream-keys/DownstreamKeyEditorModal.js';
import DownstreamKeyDrawer from './downstream-keys/DownstreamKeyDrawer.js'; import DownstreamKeyDrawer from './downstream-keys/DownstreamKeyDrawer.js';
import { import {
formatCompactTokens, formatCompactTokens,
@@ -20,9 +27,7 @@ import {
type Range, type Range,
type SummaryItem, type SummaryItem,
} from './downstream-keys/shared.js'; } from './downstream-keys/shared.js';
import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
const PROXY_TOKEN_PREFIX = 'sk-';
type Status = 'all' | 'enabled' | 'disabled'; type Status = 'all' | 'enabled' | 'disabled';
type DownstreamApiKeyItem = { type DownstreamApiKeyItem = {
@@ -42,6 +47,8 @@ type DownstreamApiKeyItem = {
supportedModels: string[]; supportedModels: string[];
allowedRouteIds: number[]; allowedRouteIds: number[];
siteWeightMultipliers: Record<number, number>; siteWeightMultipliers: Record<number, number>;
excludedSiteIds: number[];
excludedCredentialRefs: DownstreamExcludedCredentialRef[];
lastUsedAt: string | null; lastUsedAt: string | null;
}; };
@@ -56,21 +63,6 @@ type RouteSelectorItem = {
enabled: boolean; enabled: boolean;
}; };
type EditorForm = {
name: string;
key: string;
description: string;
groupName: string;
tags: string[];
maxCost: string;
maxRequests: string;
expiresAt: string;
enabled: boolean;
selectedModels: string[];
selectedGroupRouteIds: number[];
siteWeightMultipliersText: string;
};
type DeleteConfirmState = type DeleteConfirmState =
| null | null
| { mode: 'single'; item: ManagedItem } | { mode: 'single'; item: ManagedItem }
@@ -85,7 +77,7 @@ type BatchMetadataForm = {
tags: string[]; tags: string[];
}; };
type DefaultRouteSelections = Pick<EditorForm, 'selectedModels' | 'selectedGroupRouteIds'>; type DefaultRouteSelections = Pick<DownstreamKeyEditorForm, 'selectedModels' | 'selectedGroupRouteIds'>;
function toDateTimeLocal(isoString: string | null | undefined): string { function toDateTimeLocal(isoString: string | null | undefined): string {
if (!isoString) return ''; if (!isoString) return '';
const ts = Date.parse(isoString); const ts = Date.parse(isoString);
@@ -139,6 +131,41 @@ function uniqIds(values: number[]): number[] {
return [...new Set(values.map((value) => Number(value)).filter((value) => Number.isFinite(value) && value > 0).map((value) => Math.trunc(value)))]; return [...new Set(values.map((value) => Number(value)).filter((value) => Number.isFinite(value) && value > 0).map((value) => Math.trunc(value)))];
} }
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 normalizeExcludedSiteIds(values: number[]): number[] {
return uniqIds(values).sort((left, right) => left - right);
}
function normalizeExcludedCredentialRefs(values: DownstreamExcludedCredentialRef[]): DownstreamExcludedCredentialRef[] {
const deduped = new Map<string, DownstreamExcludedCredentialRef>();
for (const value of values) {
if (!value || !Number.isFinite(value.siteId) || !Number.isFinite(value.accountId)) continue;
if (value.kind === 'account_token') {
if (!Number.isFinite(value.tokenId)) continue;
const normalized: DownstreamExcludedCredentialRef = {
kind: 'account_token',
siteId: Math.trunc(value.siteId),
accountId: Math.trunc(value.accountId),
tokenId: Math.trunc(value.tokenId),
};
deduped.set(buildExcludedCredentialRefKey(normalized), normalized);
continue;
}
const normalized: DownstreamExcludedCredentialRef = {
kind: 'default_api_key',
siteId: Math.trunc(value.siteId),
accountId: Math.trunc(value.accountId),
};
deduped.set(buildExcludedCredentialRefKey(normalized), normalized);
}
return Array.from(deduped.values()).sort((left, right) => buildExcludedCredentialRefKey(left).localeCompare(buildExcludedCredentialRefKey(right)));
}
function buildDefaultRouteSelections(routeOptions: RouteSelectorItem[]): DefaultRouteSelections { function buildDefaultRouteSelections(routeOptions: RouteSelectorItem[]): DefaultRouteSelections {
return { return {
selectedModels: uniqStrings( selectedModels: uniqStrings(
@@ -299,7 +326,7 @@ function buildEditorForm(
item?: ManagedItem | DownstreamApiKeyItem | null, item?: ManagedItem | DownstreamApiKeyItem | null,
routeOptions: RouteSelectorItem[] = [], routeOptions: RouteSelectorItem[] = [],
selectAllByDefault = false, selectAllByDefault = false,
): EditorForm { ): DownstreamKeyEditorForm {
const defaultSelections = selectAllByDefault const defaultSelections = selectAllByDefault
? buildDefaultRouteSelections(routeOptions) ? buildDefaultRouteSelections(routeOptions)
: { selectedModels: [], selectedGroupRouteIds: [] }; : { selectedModels: [], selectedGroupRouteIds: [] };
@@ -323,6 +350,8 @@ function buildEditorForm(
selectedModels: uniqStrings(selectedModels), selectedModels: uniqStrings(selectedModels),
selectedGroupRouteIds: uniqIds(selectedGroupRouteIds), selectedGroupRouteIds: uniqIds(selectedGroupRouteIds),
siteWeightMultipliersText: JSON.stringify(item?.siteWeightMultipliers || {}, null, 2), siteWeightMultipliersText: JSON.stringify(item?.siteWeightMultipliers || {}, null, 2),
excludedSiteIds: normalizeExcludedSiteIds(Array.isArray(item?.excludedSiteIds) ? item.excludedSiteIds : []),
excludedCredentialRefs: normalizeExcludedCredentialRefs(Array.isArray(item?.excludedCredentialRefs) ? item.excludedCredentialRefs : []),
}; };
} }
@@ -356,90 +385,6 @@ function summarizeTags(tags: string[]): string {
return `${tags[0]} +${tags.length - 1}`; return `${tags[0]} +${tags.length - 1}`;
} }
function TagInput({
tags,
onChange,
suggestions = [],
placeholder,
}: {
tags: string[];
onChange: (tags: string[]) => void;
suggestions?: string[];
placeholder?: string;
}) {
const [draft, setDraft] = useState('');
useEffect(() => {
setDraft('');
}, [tags.length]);
const commitDraft = () => {
const nextTags = normalizeTags([...tags, ...parseTagText(draft)]);
if (nextTags.length !== tags.length) {
onChange(nextTags);
}
setDraft('');
};
const removeTag = (target: string) => {
onChange(tags.filter((tag) => tag !== target));
};
const suggestionPool = suggestions.filter((tag) => !tags.some((current) => current.toLowerCase() === tag.toLowerCase())).slice(0, 12);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'var(--color-bg)', padding: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{tags.map((tag) => (
<button
key={tag}
type="button"
onClick={() => removeTag(tag)}
style={{ ...tagChipStyle('accent'), cursor: 'pointer' }}
title={`移除 ${tag}`}
>
<span>{tag}</span>
<span aria-hidden="true">×</span>
</button>
))}
</div>
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commitDraft}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
commitDraft();
} else if (e.key === 'Backspace' && !draft && tags.length > 0) {
e.preventDefault();
onChange(tags.slice(0, -1));
}
}}
placeholder={placeholder || '输入标签后按回车或逗号'}
style={{ width: '100%', border: 'none', outline: 'none', background: 'transparent', color: 'var(--color-text-primary)', padding: 0, fontSize: 13, lineHeight: 1.45 }}
/>
</div>
{suggestionPool.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{suggestionPool.map((tag) => (
<button
key={tag}
type="button"
className="btn btn-ghost"
style={{ ...tagChipStyle(), cursor: 'pointer' }}
onClick={() => onChange(normalizeTags([...tags, tag]))}
>
{tag}
</button>
))}
</div>
) : null}
</div>
);
}
function SummaryMetric({ function SummaryMetric({
label, label,
value, value,
@@ -508,332 +453,6 @@ function InlineToggle({
); );
} }
function EditorModal({
open,
editingItem,
form,
onChange,
onClose,
onSave,
saving,
routeOptions,
groupSuggestions,
tagSuggestions,
}: {
open: boolean;
editingItem: ManagedItem | null;
form: EditorForm;
onChange: (updater: (prev: EditorForm) => EditorForm) => void;
onClose: () => void;
onSave: () => void;
saving: boolean;
routeOptions: RouteSelectorItem[];
groupSuggestions: string[];
tagSuggestions: string[];
}) {
const [modelSearch, setModelSearch] = useState('');
const [groupSearch, setGroupSearch] = useState('');
const [advancedOpen, setAdvancedOpen] = useState(false);
useEffect(() => {
if (!open) {
setModelSearch('');
setGroupSearch('');
setAdvancedOpen(false);
}
}, [open]);
const exactModels = useMemo(
() => uniqStrings(routeOptions.filter((item) => isExactModelPattern(item.modelPattern)).map((item) => item.modelPattern)).sort((a, b) => a.localeCompare(b)),
[routeOptions],
);
const groupRouteOptions = useMemo(
() => routeOptions.filter(isGroupRouteOption),
[routeOptions],
);
const validGroupRouteIdSet = useMemo(
() => new Set(groupRouteOptions.map((route) => route.id)),
[groupRouteOptions],
);
const normalizedSelectedGroupRouteIds = useMemo(
() => uniqIds(form.selectedGroupRouteIds.filter((id) => validGroupRouteIdSet.has(id))),
[form.selectedGroupRouteIds, validGroupRouteIdSet],
);
const filteredModels = useMemo(() => {
const keyword = modelSearch.trim().toLowerCase();
if (!keyword) return exactModels;
return exactModels.filter((model) => model.toLowerCase().includes(keyword));
}, [exactModels, modelSearch]);
const filteredGroups = useMemo(() => {
const keyword = groupSearch.trim().toLowerCase();
if (!keyword) return groupRouteOptions;
return groupRouteOptions.filter((route) => {
const title = routeTitle(route).toLowerCase();
return title.includes(keyword) || route.modelPattern.toLowerCase().includes(keyword);
});
}, [groupRouteOptions, groupSearch]);
const selectedModelCount = form.selectedModels.length;
const selectedGroupCount = normalizedSelectedGroupRouteIds.length;
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '10px 12px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
fontSize: 13,
lineHeight: 1.45,
};
return (
<CenteredModal
open={open}
onClose={onClose}
title={editingItem ? '编辑下游密钥' : '新增下游密钥'}
maxWidth={860}
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
footer={(
<>
<button onClick={onClose} className="btn btn-ghost" disabled={saving}>取消</button>
<button onClick={onSave} className="btn btn-primary" disabled={saving}>
{saving
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</>
: (editingItem ? '保存修改' : '创建密钥')}
</button>
</>
)}
>
<div className="info-tip" style={{ marginBottom: 0 }}>
支持为每个下游密钥独立配置分组、标签、额度与有效期。高级限制项可按需展开。
</div>
<div className="downstream-key-modal-grid" style={{ gridTemplateColumns: '1fr' }}>
<div className="downstream-key-modal-field downstream-key-modal-field-full">
<div className="downstream-key-modal-label">名称</div>
<input value={form.name} onChange={(e) => onChange((prev) => ({ ...prev, name: e.target.value }))} placeholder="例如:项目 A / 移动端" style={inputStyle} />
</div>
<div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">下游密钥</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch', minWidth: 0 }}>
<input
value={form.key}
onChange={(e) => onChange((prev) => ({ ...prev, key: e.target.value }))}
placeholder="sk-..."
style={{ ...inputStyle, flex: 1, minWidth: 0, fontFamily: 'var(--font-mono)' }}
/>
<button
type="button"
className="btn btn-ghost"
style={{ flexShrink: 0, whiteSpace: 'nowrap', alignSelf: 'stretch' }}
onClick={() => onChange((prev) => ({ ...prev, key: generateDownstreamSkKey(PROXY_TOKEN_PREFIX) }))}
>
随机
</button>
</div>
</div>
<div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">主分组</div>
<input
value={form.groupName}
onChange={(e) => onChange((prev) => ({ ...prev, groupName: e.target.value }))}
placeholder="例如:VIP / 内部项目 / A组"
list="downstream-group-suggestions"
style={inputStyle}
/>
</div>
<div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">请求额度</div>
<input value={form.maxRequests} onChange={(e) => onChange((prev) => ({ ...prev, maxRequests: e.target.value }))} placeholder="留空表示不限" style={inputStyle} />
</div>
<div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">成本额度</div>
<input value={form.maxCost} onChange={(e) => onChange((prev) => ({ ...prev, maxCost: e.target.value }))} placeholder="留空表示不限" style={inputStyle} />
</div>
<div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">过期时间</div>
<input type="datetime-local" value={form.expiresAt} onChange={(e) => onChange((prev) => ({ ...prev, expiresAt: e.target.value }))} style={inputStyle} />
</div>
<label
className="downstream-key-modal-toggle"
>
<input type="checkbox" checked={form.enabled} onChange={(e) => onChange((prev) => ({ ...prev, enabled: e.target.checked }))} />
<div>
<div className="downstream-key-modal-toggle-title">创建后立即启用</div>
<div className="downstream-key-modal-help">关闭后该密钥将无法继续分发请求</div>
</div>
</label>
</div>
<div className="downstream-key-modal-field downstream-key-modal-field-full">
<div className="downstream-key-modal-label">备注说明</div>
<textarea
value={form.description}
onChange={(e) => onChange((prev) => ({ ...prev, description: e.target.value }))}
placeholder="填写业务场景、负责人或限制说明"
style={{ ...inputStyle, minHeight: 84, resize: 'vertical' }}
/>
</div>
<div className="downstream-key-modal-field downstream-key-modal-field-full">
<div className="downstream-key-modal-label">标签</div>
<TagInput
tags={form.tags}
onChange={(tags) => onChange((prev) => ({ ...prev, tags }))}
suggestions={tagSuggestions}
placeholder="输入标签后按回车或逗号,例如:移动端、VIP、项目A"
/>
<div className="downstream-key-modal-help">标签用于搜索、筛选和辅助归类,不影响路由与权限。</div>
</div>
<div className="downstream-key-advanced">
<button type="button" className={`downstream-key-advanced-toggle ${advancedOpen ? 'is-open' : ''}`.trim()} onClick={() => setAdvancedOpen((value) => !value)}>
<span>高级配置</span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{advancedOpen ? '收起' : '展开'}</span>
</button>
{advancedOpen ? (
<div className="downstream-key-advanced-content">
<div className="downstream-key-modal-field downstream-key-modal-field-full">
<div className="downstream-key-modal-label">站点倍率 JSON</div>
<textarea
value={form.siteWeightMultipliersText}
onChange={(e) => onChange((prev) => ({ ...prev, siteWeightMultipliersText: e.target.value }))}
placeholder={'例如:{\n "1": 1.2,\n "7": 0.8\n}'}
style={{ ...inputStyle, minHeight: 96, resize: 'vertical', fontFamily: 'var(--font-mono)' }}
/>
<div className="downstream-key-modal-help">用于对特定站点做分发倍率微调;留空或 `{}` 表示走默认倍率。</div>
</div>
<div className="downstream-key-advanced-grid" style={{ gridTemplateColumns: '1fr' }}>
<div className="downstream-key-advanced-panel">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div>
<div className="downstream-key-modal-section-title">模型白名单</div>
<div className="downstream-key-modal-help">只展示精确模型;未勾选时默认不允许任何精确模型,可点“全选”一次性放开。</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => onChange((prev) => ({ ...prev, selectedModels: exactModels }))}
>
全选
</button>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => onChange((prev) => ({ ...prev, selectedModels: [] }))}
>
清空
</button>
</div>
</div>
<div className="downstream-key-modal-meta">已选 {selectedModelCount} 个模型</div>
<div className="toolbar-search" style={{ maxWidth: '100%' }}>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input value={modelSearch} onChange={(e) => setModelSearch(e.target.value)} placeholder="搜索模型" />
</div>
<div style={{ maxHeight: 280, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
{filteredModels.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无匹配模型</div>
) : filteredModels.map((model) => {
const checked = form.selectedModels.includes(model);
return (
<label key={model} style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', padding: '8px 10px', borderRadius: 10, border: '1px solid var(--color-border-light)', background: checked ? 'color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card))' : 'var(--color-bg-card)' }}>
<input
type="checkbox"
checked={checked}
onChange={() => onChange((prev) => ({
...prev,
selectedModels: checked ? prev.selectedModels.filter((item) => item !== model) : [...prev.selectedModels, model],
}))}
/>
<code style={{ color: 'var(--color-text-primary)', fontSize: 12 }}>{model}</code>
</label>
);
})}
</div>
</div>
<div className="downstream-key-advanced-panel">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div>
<div className="downstream-key-modal-section-title">群组范围</div>
<div className="downstream-key-modal-help">限制可访问的群组路由;未勾选时默认不允许任何群组,可点“全选”一次性放开。</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => onChange((prev) => ({ ...prev, selectedGroupRouteIds: groupRouteOptions.map((route) => route.id) }))}
>
全选
</button>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => onChange((prev) => ({ ...prev, selectedGroupRouteIds: [] }))}
>
清空
</button>
</div>
</div>
<div className="downstream-key-modal-meta">已选 {selectedGroupCount} 个群组</div>
<div className="toolbar-search" style={{ maxWidth: '100%' }}>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input value={groupSearch} onChange={(e) => setGroupSearch(e.target.value)} placeholder="搜索群组或模型模式" />
</div>
<div style={{ maxHeight: 280, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
{filteredGroups.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无匹配群组</div>
) : filteredGroups.map((route) => {
const checked = normalizedSelectedGroupRouteIds.includes(route.id);
return (
<label key={route.id} style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer', padding: '8px 10px', borderRadius: 10, border: '1px solid var(--color-border-light)', background: checked ? 'color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card))' : 'var(--color-bg-card)' }}>
<input
type="checkbox"
checked={checked}
onChange={() => onChange((prev) => ({
...prev,
selectedGroupRouteIds: checked
? prev.selectedGroupRouteIds.filter((item) => item !== route.id)
: uniqIds([...prev.selectedGroupRouteIds.filter((item) => validGroupRouteIdSet.has(item)), route.id]),
}))}
style={{ marginTop: 2 }}
/>
<div style={{ minWidth: 0 }}>
<div style={{ color: 'var(--color-text-primary)', fontSize: 13, fontWeight: 600 }}>
{routeTitle(route)}
{!route.enabled ? <span style={{ marginLeft: 8, fontSize: 11, color: 'var(--color-danger)' }}>已禁用</span> : null}
</div>
<code style={{ display: 'block', marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)' }}>{route.modelPattern}</code>
</div>
</label>
);
})}
</div>
</div>
</div>
</div>
) : null}
</div>
<datalist id="downstream-group-suggestions">
{groupSuggestions.map((group) => <option key={group} value={group} />)}
</datalist>
</CenteredModal>
);
}
export default function DownstreamKeys() { export default function DownstreamKeys() {
const toast = useToast(); const toast = useToast();
const [range, setRange] = useState<Range>('24h'); const [range, setRange] = useState<Range>('24h');
@@ -852,8 +471,12 @@ export default function DownstreamKeys() {
const [rowLoading, setRowLoading] = useState<Record<string, boolean>>({}); const [rowLoading, setRowLoading] = useState<Record<string, boolean>>({});
const [editorOpen, setEditorOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null); const [editingId, setEditingId] = useState<number | null>(null);
const [editorForm, setEditorForm] = useState<EditorForm>(() => buildEditorForm()); const [editorForm, setEditorForm] = useState<DownstreamKeyEditorForm>(() => buildEditorForm());
const [createDefaultsPending, setCreateDefaultsPending] = useState(false); const [createDefaultsPending, setCreateDefaultsPending] = useState(false);
const [exclusionSourceLoading, setExclusionSourceLoading] = useState(false);
const [exclusionSourceLoaded, setExclusionSourceLoaded] = useState(false);
const [exclusionSiteOptions, setExclusionSiteOptions] = useState<DownstreamSiteOption[]>([]);
const [exclusionCredentialOptions, setExclusionCredentialOptions] = useState<DownstreamCredentialOption[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [selectedId, setSelectedId] = useState<number | null>(null); const [selectedId, setSelectedId] = useState<number | null>(null);
const [selectedIds, setSelectedIds] = useState<number[]>([]); const [selectedIds, setSelectedIds] = useState<number[]>([]);
@@ -891,10 +514,94 @@ export default function DownstreamKeys() {
} }
}; };
const loadExclusionSources = async () => {
if (exclusionSourceLoading || exclusionSourceLoaded) return;
setExclusionSourceLoading(true);
try {
const [accountsRes, tokensRes] = await Promise.all([
api.getAccounts(),
api.getAccountTokens(),
]);
const accountRows = Array.isArray(accountsRes) ? accountsRes : [];
const tokenRows = Array.isArray(tokensRes) ? tokensRes : [];
const siteMap = new Map<number, { siteId: number; siteName: string; accountIds: Set<number> }>();
for (const account of accountRows) {
const siteId = Number(account?.site?.id);
const accountId = Number(account?.id);
if (!Number.isFinite(siteId) || siteId <= 0 || !Number.isFinite(accountId) || accountId <= 0) continue;
const siteName = String(account?.site?.name || `站点 ${siteId}`).trim() || `站点 ${siteId}`;
if (!siteMap.has(siteId)) {
siteMap.set(siteId, { siteId, siteName, accountIds: new Set<number>() });
}
siteMap.get(siteId)!.accountIds.add(accountId);
}
const siteOptions = Array.from(siteMap.values())
.map((item) => ({
siteId: item.siteId,
siteName: item.siteName,
accountCount: item.accountIds.size,
}))
.sort((left, right) => left.siteName.localeCompare(right.siteName));
const credentialOptions: DownstreamCredentialOption[] = [];
for (const account of accountRows) {
const siteId = Number(account?.site?.id);
const accountId = Number(account?.id);
const apiToken = String(account?.apiToken || '').trim();
if (!Number.isFinite(siteId) || siteId <= 0 || !Number.isFinite(accountId) || accountId <= 0 || !apiToken) continue;
credentialOptions.push({
key: `default_api_key:${siteId}:${accountId}`,
ref: { kind: 'default_api_key', siteId: Math.trunc(siteId), accountId: Math.trunc(accountId) },
siteName: String(account?.site?.name || `站点 ${siteId}`).trim() || `站点 ${siteId}`,
accountName: String(account?.username || `账号 ${accountId}`).trim() || `账号 ${accountId}`,
label: '默认 API Key',
detail: `使用账号默认 API Key (${apiToken.slice(0, 6)}...)`,
});
}
for (const token of tokenRows) {
const siteId = Number(token?.site?.id);
const accountId = Number(token?.account?.id ?? token?.accountId);
const tokenId = Number(token?.id);
if (!Number.isFinite(siteId) || siteId <= 0 || !Number.isFinite(accountId) || accountId <= 0 || !Number.isFinite(tokenId) || tokenId <= 0) continue;
credentialOptions.push({
key: `account_token:${siteId}:${accountId}:${tokenId}`,
ref: { kind: 'account_token', siteId: Math.trunc(siteId), accountId: Math.trunc(accountId), tokenId: Math.trunc(tokenId) },
siteName: String(token?.site?.name || `站点 ${siteId}`).trim() || `站点 ${siteId}`,
accountName: String(token?.account?.username || `账号 ${accountId}`).trim() || `账号 ${accountId}`,
label: String(token?.name || `token-${tokenId}`).trim() || `token-${tokenId}`,
detail: String(token?.tokenGroup || 'default').trim() || 'default',
});
}
setExclusionSiteOptions(siteOptions);
setExclusionCredentialOptions(
credentialOptions.sort((left, right) => (
`${left.siteName}:${left.accountName}:${left.label}:${left.detail}`.localeCompare(
`${right.siteName}:${right.accountName}:${right.label}:${right.detail}`,
)
)),
);
setExclusionSourceLoaded(true);
} catch (err: any) {
toast.error(err?.message || '加载可排除站点与令牌失败');
} finally {
setExclusionSourceLoading(false);
}
};
useEffect(() => { useEffect(() => {
void load(); void load();
}, [range]); }, [range]);
useEffect(() => {
if (!editorOpen) return;
void loadExclusionSources();
}, [editorOpen]);
const rawItemMap = useMemo(() => new Map(rawItems.map((item) => [item.id, item])), [rawItems]); const rawItemMap = useMemo(() => new Map(rawItems.map((item) => [item.id, item])), [rawItems]);
const routeMap = useMemo(() => new Map(routeOptions.map((item) => [item.id, item])), [routeOptions]); const routeMap = useMemo(() => new Map(routeOptions.map((item) => [item.id, item])), [routeOptions]);
@@ -917,6 +624,8 @@ export default function DownstreamKeys() {
supportedModels: raw?.supportedModels ?? item.supportedModels, supportedModels: raw?.supportedModels ?? item.supportedModels,
allowedRouteIds: raw?.allowedRouteIds ?? item.allowedRouteIds, allowedRouteIds: raw?.allowedRouteIds ?? item.allowedRouteIds,
siteWeightMultipliers: raw?.siteWeightMultipliers ?? item.siteWeightMultipliers, siteWeightMultipliers: raw?.siteWeightMultipliers ?? item.siteWeightMultipliers,
excludedSiteIds: raw?.excludedSiteIds ?? item.excludedSiteIds,
excludedCredentialRefs: raw?.excludedCredentialRefs ?? item.excludedCredentialRefs,
lastUsedAt: raw?.lastUsedAt ?? item.lastUsedAt, lastUsedAt: raw?.lastUsedAt ?? item.lastUsedAt,
}; };
}) })
@@ -1125,6 +834,8 @@ export default function DownstreamKeys() {
supportedModels: uniqStrings(editorForm.selectedModels), supportedModels: uniqStrings(editorForm.selectedModels),
allowedRouteIds: uniqIds(editorForm.selectedGroupRouteIds).filter((id) => routeMap.has(id) && isGroupRouteOption(routeMap.get(id)!)), allowedRouteIds: uniqIds(editorForm.selectedGroupRouteIds).filter((id) => routeMap.has(id) && isGroupRouteOption(routeMap.get(id)!)),
siteWeightMultipliers, siteWeightMultipliers,
excludedSiteIds: normalizeExcludedSiteIds(editorForm.excludedSiteIds),
excludedCredentialRefs: normalizeExcludedCredentialRefs(editorForm.excludedCredentialRefs),
}; };
if (editingId) { if (editingId) {
await api.updateDownstreamApiKey(editingId, payload); await api.updateDownstreamApiKey(editingId, payload);
@@ -1555,7 +1266,7 @@ export default function DownstreamKeys() {
)} )}
</div> </div>
<EditorModal <DownstreamKeyEditorModal
open={editorOpen} open={editorOpen}
editingItem={editingItem} editingItem={editingItem}
form={editorForm} form={editorForm}
@@ -1566,6 +1277,9 @@ export default function DownstreamKeys() {
routeOptions={routeOptions} routeOptions={routeOptions}
groupSuggestions={groupSuggestions} groupSuggestions={groupSuggestions}
tagSuggestions={tagSuggestions} tagSuggestions={tagSuggestions}
exclusionSourceLoading={exclusionSourceLoading}
siteOptions={exclusionSiteOptions}
credentialOptions={exclusionCredentialOptions}
/> />
<CenteredModal <CenteredModal
+1 -343
View File
@@ -6,24 +6,17 @@ import ChangeKeyModal from '../components/ChangeKeyModal.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js'; import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import ModernSelect from '../components/ModernSelect.js'; import ModernSelect from '../components/ModernSelect.js';
import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js'; import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js';
import DownstreamApiKeyModal from './settings/DownstreamApiKeyModal.js';
import FactoryResetModal from './settings/FactoryResetModal.js'; import FactoryResetModal from './settings/FactoryResetModal.js';
import ModelAvailabilityProbeConfirmModal from './settings/ModelAvailabilityProbeConfirmModal.js'; import ModelAvailabilityProbeConfirmModal from './settings/ModelAvailabilityProbeConfirmModal.js';
import RouteSelectorModal from './settings/RouteSelectorModal.js';
import UpdateCenterSection from './settings/UpdateCenterSection.js'; import UpdateCenterSection from './settings/UpdateCenterSection.js';
import { import {
applyRoutingProfilePreset, applyRoutingProfilePreset,
resolveRoutingProfilePreset, resolveRoutingProfilePreset,
type RoutingWeights, type RoutingWeights,
} from './helpers/routingProfiles.js'; } from './helpers/routingProfiles.js';
import { fuzzyMatch } from './helpers/fuzzySearch.js';
import { clearAuthSession } from '../authSession.js'; import { clearAuthSession } from '../authSession.js';
import { clearAppInstallationState } from '../appLocalState.js'; import { clearAppInstallationState } from '../appLocalState.js';
import { tr } from '../i18n.js'; import { tr } from '../i18n.js';
import {
isExactModelPattern,
resolveRouteTitle,
} from './token-routes/utils.js';
import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js'; import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
const PROXY_TOKEN_PREFIX = 'sk-'; const PROXY_TOKEN_PREFIX = 'sk-';
@@ -87,42 +80,6 @@ type SystemProxyTestState =
| { kind: 'error'; text: string } | { kind: 'error'; text: string }
| null; | null;
type DownstreamApiKeyItem = {
id: number;
name: string;
key: string;
keyMasked: string;
description: string | null;
enabled: boolean;
expiresAt: string | null;
maxCost: number | null;
usedCost: number;
maxRequests: number | null;
usedRequests: number;
supportedModels: string[];
allowedRouteIds: number[];
lastUsedAt: string | null;
};
type DownstreamCreateForm = {
name: string;
key: string;
description: string;
maxCost: string;
maxRequests: string;
expiresAt: string;
selectedModels: string[];
selectedGroupRouteIds: number[];
};
type RouteSelectorItem = {
id: number;
modelPattern: string;
displayName?: string | null;
displayIcon?: string | null;
enabled: boolean;
};
type DatabaseMigrationSummary = { type DatabaseMigrationSummary = {
dialect: DbDialect; dialect: DbDialect;
connection: string; connection: string;
@@ -300,15 +257,6 @@ export default function Settings() {
const [migrationSummary, setMigrationSummary] = useState<DatabaseMigrationSummary | null>(null); const [migrationSummary, setMigrationSummary] = useState<DatabaseMigrationSummary | null>(null);
const [runtimeDatabaseState, setRuntimeDatabaseState] = useState<RuntimeDatabaseState | null>(null); const [runtimeDatabaseState, setRuntimeDatabaseState] = useState<RuntimeDatabaseState | null>(null);
const [showChangeKey, setShowChangeKey] = useState(false); const [showChangeKey, setShowChangeKey] = useState(false);
const [downstreamKeys, setDownstreamKeys] = useState<DownstreamApiKeyItem[]>([]);
const [downstreamLoading, setDownstreamLoading] = useState(false);
const [downstreamSaving, setDownstreamSaving] = useState(false);
const [downstreamOps, setDownstreamOps] = useState<Record<number, boolean>>({});
const [editingDownstreamId, setEditingDownstreamId] = useState<number | null>(null);
const [downstreamModalOpen, setDownstreamModalOpen] = useState(false);
const downstreamModalPresence = useAnimatedVisibility(downstreamModalOpen, 220);
const [selectorOpen, setSelectorOpen] = useState(false);
const selectorModalPresence = useAnimatedVisibility(selectorOpen, 220);
const [modelAvailabilityProbeConfirmOpen, setModelAvailabilityProbeConfirmOpen] = useState(false); const [modelAvailabilityProbeConfirmOpen, setModelAvailabilityProbeConfirmOpen] = useState(false);
const modelAvailabilityProbeConfirmPresence = useAnimatedVisibility(modelAvailabilityProbeConfirmOpen, 220); const modelAvailabilityProbeConfirmPresence = useAnimatedVisibility(modelAvailabilityProbeConfirmOpen, 220);
const [modelAvailabilityProbeConfirmationInput, setModelAvailabilityProbeConfirmationInput] = useState(''); const [modelAvailabilityProbeConfirmationInput, setModelAvailabilityProbeConfirmationInput] = useState('');
@@ -317,20 +265,6 @@ export default function Settings() {
const factoryResetPresence = useAnimatedVisibility(factoryResetOpen, 220); const factoryResetPresence = useAnimatedVisibility(factoryResetOpen, 220);
const [factoryResetting, setFactoryResetting] = useState(false); const [factoryResetting, setFactoryResetting] = useState(false);
const [factoryResetSecondsLeft, setFactoryResetSecondsLeft] = useState(FACTORY_RESET_CONFIRM_SECONDS); const [factoryResetSecondsLeft, setFactoryResetSecondsLeft] = useState(FACTORY_RESET_CONFIRM_SECONDS);
const [selectorLoading, setSelectorLoading] = useState(false);
const [selectorRoutes, setSelectorRoutes] = useState<RouteSelectorItem[]>([]);
const [selectorModelSearch, setSelectorModelSearch] = useState('');
const [selectorGroupSearch, setSelectorGroupSearch] = useState('');
const [downstreamCreate, setDownstreamCreate] = useState<DownstreamCreateForm>({
name: '',
key: '',
description: '',
maxCost: '',
maxRequests: '',
expiresAt: '',
selectedModels: [],
selectedGroupRouteIds: [],
});
const toast = useToast(); const toast = useToast();
const activeRoutingProfile = useMemo( const activeRoutingProfile = useMemo(
@@ -338,35 +272,6 @@ export default function Settings() {
[runtime.routingWeights], [runtime.routingWeights],
); );
const exactModelOptions = useMemo(() => (
selectorRoutes
.filter((route) => isExactModelPattern(route.modelPattern))
.map((route) => route.modelPattern.trim())
.filter((item, index, arr) => item.length > 0 && arr.indexOf(item) === index)
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
), [selectorRoutes]);
const groupRouteOptions = useMemo(() => (
selectorRoutes
.filter((route) => !isExactModelPattern(route.modelPattern))
.sort((a, b) => resolveRouteTitle(a).localeCompare(resolveRouteTitle(b), undefined, { sensitivity: 'base' }))
), [selectorRoutes]);
const filteredExactModelOptions = useMemo(() => {
const query = selectorModelSearch.trim();
if (!query) return exactModelOptions;
return exactModelOptions.filter((modelName) => fuzzyMatch(modelName, query));
}, [exactModelOptions, selectorModelSearch]);
const filteredGroupRouteOptions = useMemo(() => {
const query = selectorGroupSearch.trim();
if (!query) return groupRouteOptions;
return groupRouteOptions.filter((route) => {
const matchText = `${resolveRouteTitle(route)} ${route.modelPattern}`;
return fuzzyMatch(matchText, query);
});
}, [groupRouteOptions, selectorGroupSearch]);
const generatedConnectionString = useMemo(() => ( const generatedConnectionString = useMemo(() => (
buildShorthandConnectionString(migrationDialect, shorthandConnection) buildShorthandConnectionString(migrationDialect, shorthandConnection)
), [migrationDialect, shorthandConnection]); ), [migrationDialect, shorthandConnection]);
@@ -560,58 +465,12 @@ export default function Settings() {
? '已启用' ? '已启用'
: '已关闭'; : '已关闭';
const toDateTimeLocal = (isoString: string | null | undefined): string => {
if (!isoString) return '';
const ts = Date.parse(isoString);
if (!Number.isFinite(ts)) return '';
const date = new Date(ts);
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
const hh = String(date.getHours()).padStart(2, '0');
const mi = String(date.getMinutes()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}T${hh}:${mi}`;
};
const loadDownstreamKeys = async () => {
setDownstreamLoading(true);
try {
const res = await api.getDownstreamApiKeys();
const items = Array.isArray(res?.items) ? res.items : [];
setDownstreamKeys(items);
} catch (err: any) {
toast.error(err?.message || '加载下游 API Key 失败');
} finally {
setDownstreamLoading(false);
}
};
const loadRouteSelectorRoutes = async () => {
setSelectorLoading(true);
try {
const rows = await api.getRoutesLite();
setSelectorRoutes((Array.isArray(rows) ? rows : []).map((row: any) => ({
id: row.id,
modelPattern: row.modelPattern,
displayName: row.displayName,
displayIcon: row.displayIcon,
enabled: !!row.enabled,
})));
} catch (err: any) {
toast.error(err?.message || '加载路由列表失败');
} finally {
setSelectorLoading(false);
}
};
const loadSettings = async () => { const loadSettings = async () => {
setLoading(true); setLoading(true);
try { try {
const [authInfo, runtimeInfo, downstreamInfo, routeRows, runtimeDatabaseInfo] = await Promise.all([ const [authInfo, runtimeInfo, runtimeDatabaseInfo] = await Promise.all([
api.getAuthInfo(), api.getAuthInfo(),
api.getRuntimeSettings(), api.getRuntimeSettings(),
api.getDownstreamApiKeys(),
api.getRoutesLite(),
api.getRuntimeDatabaseConfig(), api.getRuntimeDatabaseConfig(),
]); ]);
setMaskedToken(authInfo.masked || '****'); setMaskedToken(authInfo.masked || '****');
@@ -677,14 +536,6 @@ export default function Settings() {
? runtimeInfo.adminIpAllowlist.join('\n') ? runtimeInfo.adminIpAllowlist.join('\n')
: '', : '',
); );
setDownstreamKeys(Array.isArray(downstreamInfo?.items) ? downstreamInfo.items : []);
setSelectorRoutes((Array.isArray(routeRows) ? routeRows : []).map((row: any) => ({
id: row.id,
modelPattern: row.modelPattern,
displayName: row.displayName,
displayIcon: row.displayIcon,
enabled: !!row.enabled,
})));
if (runtimeDatabaseInfo?.active?.dialect) { if (runtimeDatabaseInfo?.active?.dialect) {
const preferredDialect = (runtimeDatabaseInfo?.saved?.dialect || runtimeDatabaseInfo.active.dialect) as DbDialect; const preferredDialect = (runtimeDatabaseInfo?.saved?.dialect || runtimeDatabaseInfo.active.dialect) as DbDialect;
setMigrationDialect(preferredDialect); setMigrationDialect(preferredDialect);
@@ -932,163 +783,6 @@ export default function Settings() {
} }
}; };
const resetDownstreamForm = () => {
setEditingDownstreamId(null);
setDownstreamCreate({
name: '',
key: '',
description: '',
maxCost: '',
maxRequests: '',
expiresAt: '',
selectedModels: [],
selectedGroupRouteIds: [],
});
};
const openCreateDownstreamModal = () => {
resetDownstreamForm();
setDownstreamModalOpen(true);
};
const closeDownstreamModal = () => {
setDownstreamModalOpen(false);
resetDownstreamForm();
};
const closeSelectorModal = () => {
setSelectorOpen(false);
setSelectorModelSearch('');
setSelectorGroupSearch('');
};
const beginEditDownstream = (item: DownstreamApiKeyItem) => {
setEditingDownstreamId(item.id);
setDownstreamCreate({
name: item.name || '',
key: item.key || '',
description: item.description || '',
maxCost: item.maxCost === null || item.maxCost === undefined ? '' : String(item.maxCost),
maxRequests: item.maxRequests === null || item.maxRequests === undefined ? '' : String(item.maxRequests),
expiresAt: toDateTimeLocal(item.expiresAt),
selectedModels: Array.isArray(item.supportedModels)
? [...new Set(item.supportedModels.map((model) => String(model).trim()).filter((model) => model.length > 0))]
: [],
selectedGroupRouteIds: Array.isArray(item.allowedRouteIds)
? [...new Set(item.allowedRouteIds.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0).map((id) => Math.trunc(id)))]
: [],
});
setDownstreamModalOpen(true);
};
const saveDownstreamKey = async () => {
const name = downstreamCreate.name.trim();
const rawKey = downstreamCreate.key.trim();
if (!name) {
toast.info('Please enter a name');
return;
}
if (!rawKey) {
toast.info('请填写 API Key');
return;
}
if (!rawKey.startsWith(PROXY_TOKEN_PREFIX)) {
toast.info('API Key must start with sk-');
return;
}
setDownstreamSaving(true);
try {
const payload = {
name,
key: rawKey,
description: downstreamCreate.description.trim(),
expiresAt: downstreamCreate.expiresAt ? new Date(downstreamCreate.expiresAt).toISOString() : null,
maxCost: downstreamCreate.maxCost.trim() ? Number(downstreamCreate.maxCost.trim()) : null,
maxRequests: downstreamCreate.maxRequests.trim() ? Number(downstreamCreate.maxRequests.trim()) : null,
supportedModels: downstreamCreate.selectedModels,
allowedRouteIds: downstreamCreate.selectedGroupRouteIds,
};
if (editingDownstreamId) {
await api.updateDownstreamApiKey(editingDownstreamId, payload);
toast.success('Downstream API Key updated');
} else {
await api.createDownstreamApiKey(payload);
toast.success('Downstream API Key created');
}
setDownstreamModalOpen(false);
resetDownstreamForm();
await loadDownstreamKeys();
} catch (err: any) {
toast.error(err?.message || '保存下游 API Key 失败');
} finally {
setDownstreamSaving(false);
}
};
const toggleModelSelection = (modelName: string) => {
setDownstreamCreate((prev) => {
const exists = prev.selectedModels.includes(modelName);
return {
...prev,
selectedModels: exists
? prev.selectedModels.filter((item) => item !== modelName)
: [...prev.selectedModels, modelName],
};
});
};
const toggleGroupRouteSelection = (routeId: number) => {
setDownstreamCreate((prev) => {
const exists = prev.selectedGroupRouteIds.includes(routeId);
return {
...prev,
selectedGroupRouteIds: exists
? prev.selectedGroupRouteIds.filter((item) => item !== routeId)
: [...prev.selectedGroupRouteIds, routeId],
};
});
};
const runDownstreamOp = async (id: number, action: () => Promise<void>) => {
setDownstreamOps((prev) => ({ ...prev, [id]: true }));
try {
await action();
} finally {
setDownstreamOps((prev) => ({ ...prev, [id]: false }));
}
};
const toggleDownstreamEnabled = async (item: DownstreamApiKeyItem) => {
await runDownstreamOp(item.id, async () => {
await api.updateDownstreamApiKey(item.id, { enabled: !item.enabled });
await loadDownstreamKeys();
toast.success(item.enabled ? 'Disabled' : 'Enabled');
});
};
const resetDownstreamUsage = async (item: DownstreamApiKeyItem) => {
await runDownstreamOp(item.id, async () => {
await api.resetDownstreamApiKeyUsage(item.id);
await loadDownstreamKeys();
toast.success('Usage reset');
});
};
const deleteDownstreamKey = async (item: DownstreamApiKeyItem) => {
if (!window.confirm('Confirm delete API Key?')) return;
await runDownstreamOp(item.id, async () => {
await api.deleteDownstreamApiKey(item.id);
if (editingDownstreamId === item.id) {
setDownstreamModalOpen(false);
resetDownstreamForm();
}
await loadDownstreamKeys();
toast.success('Deleted');
});
};
const saveRouting = async () => { const saveRouting = async () => {
setSavingRouting(true); setSavingRouting(true);
try { try {
@@ -2419,22 +2113,6 @@ export default function Settings() {
</div> </div>
</div> </div>
</div> </div>
<DownstreamApiKeyModal
presence={downstreamModalPresence}
editingDownstreamId={editingDownstreamId}
downstreamCreate={downstreamCreate}
downstreamSaving={downstreamSaving}
inputStyle={inputStyle}
onChange={(updater) => setDownstreamCreate((prev) => updater(prev))}
onOpenSelector={async () => {
if (selectorRoutes.length === 0) await loadRouteSelectorRoutes();
setSelectorModelSearch('');
setSelectorGroupSearch('');
setSelectorOpen(true);
}}
onClose={closeDownstreamModal}
onSave={saveDownstreamKey}
/>
<FactoryResetModal <FactoryResetModal
presence={factoryResetPresence} presence={factoryResetPresence}
factoryResetting={factoryResetting} factoryResetting={factoryResetting}
@@ -2452,26 +2130,6 @@ export default function Settings() {
onClose={closeModelAvailabilityProbeConfirmModal} onClose={closeModelAvailabilityProbeConfirmModal}
onConfirm={handleConfirmModelAvailabilityProbe} onConfirm={handleConfirmModelAvailabilityProbe}
/> />
<RouteSelectorModal
presence={selectorModalPresence}
loading={selectorLoading}
exactModelOptions={exactModelOptions}
filteredExactModelOptions={filteredExactModelOptions}
groupRouteOptions={groupRouteOptions}
filteredGroupRouteOptions={filteredGroupRouteOptions}
selectorModelSearch={selectorModelSearch}
selectorGroupSearch={selectorGroupSearch}
onSelectorModelSearchChange={setSelectorModelSearch}
onSelectorGroupSearchChange={setSelectorGroupSearch}
selection={{
selectedModels: downstreamCreate.selectedModels,
selectedGroupRouteIds: downstreamCreate.selectedGroupRouteIds,
}}
onToggleModelSelection={toggleModelSelection}
onToggleGroupRouteSelection={toggleGroupRouteSelection}
onClose={closeSelectorModal}
inputStyle={inputStyle}
/>
</div> </div>
); );
} }
@@ -0,0 +1,658 @@
import React, { useEffect, useMemo, useState } from 'react';
import CenteredModal from '../../components/CenteredModal.js';
import { generateDownstreamSkKey } from '../helpers/generateDownstreamSkKey.js';
const PROXY_TOKEN_PREFIX = 'sk-';
export type DownstreamExcludedCredentialRef =
| {
kind: 'account_token';
siteId: number;
accountId: number;
tokenId: number;
}
| {
kind: 'default_api_key';
siteId: number;
accountId: number;
};
export type DownstreamKeyEditorForm = {
name: string;
key: string;
description: string;
groupName: string;
tags: string[];
maxCost: string;
maxRequests: string;
expiresAt: string;
enabled: boolean;
selectedModels: string[];
selectedGroupRouteIds: number[];
siteWeightMultipliersText: string;
excludedSiteIds: number[];
excludedCredentialRefs: DownstreamExcludedCredentialRef[];
};
export type DownstreamSiteOption = {
siteId: number;
siteName: string;
accountCount: number;
};
export type DownstreamCredentialOption = {
key: string;
ref: DownstreamExcludedCredentialRef;
siteName: string;
accountName: string;
label: string;
detail: string;
};
type RouteSelectorItem = {
id: number;
modelPattern: string;
displayName?: string | null;
enabled: boolean;
};
function parseTagText(value: string): string[] {
return value
.split(/[\r\n,,]+/g)
.map((item) => item.trim())
.filter(Boolean);
}
function normalizeTags(values: string[]): string[] {
const result: string[] = [];
const seen = new Set<string>();
for (const raw of values) {
const value = String(raw || '').trim();
if (!value) continue;
const normalized = value.slice(0, 32);
const dedupeKey = normalized.toLowerCase();
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
result.push(normalized);
if (result.length >= 20) break;
}
return result;
}
function uniqStrings(values: string[]): string[] {
return [...new Set(values.map((value) => String(value || '').trim()).filter(Boolean))];
}
function uniqIds(values: number[]): number[] {
return [...new Set(values.map((value) => Number(value)).filter((value) => Number.isFinite(value) && value > 0).map((value) => Math.trunc(value)))];
}
function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim();
if (!normalized) return false;
if (normalized.toLowerCase().startsWith('re:')) return false;
return !/[\*\?]/.test(normalized);
}
function isGroupRouteOption(route: RouteSelectorItem): boolean {
return !isExactModelPattern(route.modelPattern);
}
function routeTitle(route: RouteSelectorItem): string {
const displayName = (route.displayName || '').trim();
return displayName || route.modelPattern;
}
function tagChipStyle(kind: 'normal' | 'accent' = 'normal'): React.CSSProperties {
return {
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '2px 8px',
borderRadius: 999,
fontSize: 11,
border: '1px solid var(--color-border-light)',
color: kind === 'accent' ? 'var(--color-primary)' : 'var(--color-text-secondary)',
background: kind === 'accent'
? 'color-mix(in srgb, var(--color-primary) 10%, transparent)'
: 'var(--color-bg-card)',
};
}
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 normalizeExcludedSiteIds(values: number[]): number[] {
return uniqIds(values).sort((left, right) => left - right);
}
function normalizeExcludedCredentialRefs(values: DownstreamExcludedCredentialRef[]): DownstreamExcludedCredentialRef[] {
const deduped = new Map<string, DownstreamExcludedCredentialRef>();
for (const value of values) {
if (!value || !Number.isFinite(value.siteId) || !Number.isFinite(value.accountId)) continue;
if (value.kind === 'account_token') {
if (!Number.isFinite(value.tokenId)) continue;
const normalized: DownstreamExcludedCredentialRef = {
kind: 'account_token',
siteId: Math.trunc(value.siteId),
accountId: Math.trunc(value.accountId),
tokenId: Math.trunc(value.tokenId),
};
deduped.set(buildExcludedCredentialRefKey(normalized), normalized);
continue;
}
const normalized: DownstreamExcludedCredentialRef = {
kind: 'default_api_key',
siteId: Math.trunc(value.siteId),
accountId: Math.trunc(value.accountId),
};
deduped.set(buildExcludedCredentialRefKey(normalized), normalized);
}
return Array.from(deduped.values()).sort((left, right) => buildExcludedCredentialRefKey(left).localeCompare(buildExcludedCredentialRefKey(right)));
}
export function TagInput({
tags,
onChange,
suggestions = [],
placeholder,
}: {
tags: string[];
onChange: (tags: string[]) => void;
suggestions?: string[];
placeholder?: string;
}) {
const [draft, setDraft] = useState('');
useEffect(() => {
setDraft('');
}, [tags.length]);
const commitDraft = () => {
const nextTags = normalizeTags([...tags, ...parseTagText(draft)]);
if (nextTags.length !== tags.length) {
onChange(nextTags);
}
setDraft('');
};
const removeTag = (target: string) => {
onChange(tags.filter((tag) => tag !== target));
};
const suggestionPool = suggestions.filter((tag) => !tags.some((current) => current.toLowerCase() === tag.toLowerCase())).slice(0, 12);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'var(--color-bg)', padding: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{tags.map((tag) => (
<button
key={tag}
type="button"
onClick={() => removeTag(tag)}
style={{ ...tagChipStyle('accent'), cursor: 'pointer' }}
title={`移除 ${tag}`}
>
<span>{tag}</span>
<span aria-hidden="true">×</span>
</button>
))}
</div>
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commitDraft}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
commitDraft();
} else if (e.key === 'Backspace' && !draft && tags.length > 0) {
e.preventDefault();
onChange(tags.slice(0, -1));
}
}}
placeholder={placeholder || '输入标签后按回车或逗号'}
style={{ width: '100%', border: 'none', outline: 'none', background: 'transparent', color: 'var(--color-text-primary)', padding: 0, fontSize: 13, lineHeight: 1.45 }}
/>
</div>
{suggestionPool.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{suggestionPool.map((tag) => (
<button
key={tag}
type="button"
className="btn btn-ghost"
style={{ ...tagChipStyle(), cursor: 'pointer' }}
onClick={() => onChange(normalizeTags([...tags, tag]))}
>
{tag}
</button>
))}
</div>
) : null}
</div>
);
}
export default function DownstreamKeyEditorModal({
open,
editingItem,
form,
onChange,
onClose,
onSave,
saving,
routeOptions,
groupSuggestions,
tagSuggestions,
exclusionSourceLoading,
siteOptions,
credentialOptions,
}: {
open: boolean;
editingItem: { id: number } | null;
form: DownstreamKeyEditorForm;
onChange: (updater: (prev: DownstreamKeyEditorForm) => DownstreamKeyEditorForm) => void;
onClose: () => void;
onSave: () => void;
saving: boolean;
routeOptions: RouteSelectorItem[];
groupSuggestions: string[];
tagSuggestions: string[];
exclusionSourceLoading: boolean;
siteOptions: DownstreamSiteOption[];
credentialOptions: DownstreamCredentialOption[];
}) {
const [modelSearch, setModelSearch] = useState('');
const [groupSearch, setGroupSearch] = useState('');
const [siteSearch, setSiteSearch] = useState('');
const [credentialSearch, setCredentialSearch] = useState('');
const [advancedOpen, setAdvancedOpen] = useState(false);
useEffect(() => {
if (!open) {
setModelSearch('');
setGroupSearch('');
setSiteSearch('');
setCredentialSearch('');
setAdvancedOpen(false);
}
}, [open]);
const exactModels = useMemo(
() => uniqStrings(routeOptions.filter((item) => isExactModelPattern(item.modelPattern)).map((item) => item.modelPattern)).sort((a, b) => a.localeCompare(b)),
[routeOptions],
);
const groupRouteOptions = useMemo(
() => routeOptions.filter(isGroupRouteOption),
[routeOptions],
);
const validGroupRouteIdSet = useMemo(
() => new Set(groupRouteOptions.map((route) => route.id)),
[groupRouteOptions],
);
const normalizedSelectedGroupRouteIds = useMemo(
() => uniqIds(form.selectedGroupRouteIds.filter((id) => validGroupRouteIdSet.has(id))),
[form.selectedGroupRouteIds, validGroupRouteIdSet],
);
const filteredModels = useMemo(() => {
const keyword = modelSearch.trim().toLowerCase();
if (!keyword) return exactModels;
return exactModels.filter((model) => model.toLowerCase().includes(keyword));
}, [exactModels, modelSearch]);
const filteredGroups = useMemo(() => {
const keyword = groupSearch.trim().toLowerCase();
if (!keyword) return groupRouteOptions;
return groupRouteOptions.filter((route) => {
const title = routeTitle(route).toLowerCase();
return title.includes(keyword) || route.modelPattern.toLowerCase().includes(keyword);
});
}, [groupRouteOptions, groupSearch]);
const filteredSites = useMemo(() => {
const keyword = siteSearch.trim().toLowerCase();
if (!keyword) return siteOptions;
return siteOptions.filter((site) => site.siteName.toLowerCase().includes(keyword));
}, [siteOptions, siteSearch]);
const filteredCredentials = useMemo(() => {
const keyword = credentialSearch.trim().toLowerCase();
if (!keyword) return credentialOptions;
return credentialOptions.filter((item) => (
item.siteName.toLowerCase().includes(keyword)
|| item.accountName.toLowerCase().includes(keyword)
|| item.label.toLowerCase().includes(keyword)
|| item.detail.toLowerCase().includes(keyword)
));
}, [credentialOptions, credentialSearch]);
const selectedModelCount = form.selectedModels.length;
const selectedGroupCount = normalizedSelectedGroupRouteIds.length;
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '10px 12px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
fontSize: 13,
lineHeight: 1.45,
};
return (
<CenteredModal
open={open}
onClose={onClose}
title={editingItem ? '编辑下游密钥' : '新增下游密钥'}
maxWidth={860}
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
footer={(
<>
<button onClick={onClose} className="btn btn-ghost" disabled={saving}>取消</button>
<button onClick={onSave} className="btn btn-primary" disabled={saving}>
{saving
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</>
: (editingItem ? '保存修改' : '创建密钥')}
</button>
</>
)}
>
<div className="info-tip" style={{ marginBottom: 0 }}>
支持为每个下游密钥独立配置分组、标签、额度与有效期。高级限制项可按需展开。
</div>
<div className="downstream-key-modal-grid" style={{ gridTemplateColumns: '1fr' }}>
<div className="downstream-key-modal-field downstream-key-modal-field-full">
<div className="downstream-key-modal-label">名称</div>
<input value={form.name} onChange={(e) => onChange((prev) => ({ ...prev, name: e.target.value }))} placeholder="例如:项目 A / 移动端" style={inputStyle} />
</div>
<div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">下游密钥</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch', minWidth: 0 }}>
<input
value={form.key}
onChange={(e) => onChange((prev) => ({ ...prev, key: e.target.value }))}
placeholder="sk-..."
style={{ ...inputStyle, flex: 1, minWidth: 0, fontFamily: 'var(--font-mono)' }}
/>
<button
type="button"
className="btn btn-ghost"
style={{ flexShrink: 0, whiteSpace: 'nowrap', alignSelf: 'stretch' }}
onClick={() => onChange((prev) => ({ ...prev, key: generateDownstreamSkKey(PROXY_TOKEN_PREFIX) }))}
>
随机
</button>
</div>
</div>
<div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">主分组</div>
<input
value={form.groupName}
onChange={(e) => onChange((prev) => ({ ...prev, groupName: e.target.value }))}
placeholder="例如:VIP / 内部项目 / A组"
list="downstream-group-suggestions"
style={inputStyle}
/>
</div>
<div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">请求额度</div>
<input value={form.maxRequests} onChange={(e) => onChange((prev) => ({ ...prev, maxRequests: e.target.value }))} placeholder="留空表示不限" style={inputStyle} />
</div>
<div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">成本额度</div>
<input value={form.maxCost} onChange={(e) => onChange((prev) => ({ ...prev, maxCost: e.target.value }))} placeholder="留空表示不限" style={inputStyle} />
</div>
<div className="downstream-key-modal-field">
<div className="downstream-key-modal-label">过期时间</div>
<input type="datetime-local" value={form.expiresAt} onChange={(e) => onChange((prev) => ({ ...prev, expiresAt: e.target.value }))} style={inputStyle} />
</div>
<label className="downstream-key-modal-toggle">
<input type="checkbox" checked={form.enabled} onChange={(e) => onChange((prev) => ({ ...prev, enabled: e.target.checked }))} />
<div>
<div className="downstream-key-modal-toggle-title">创建后立即启用</div>
<div className="downstream-key-modal-help">关闭后该密钥将无法继续分发请求</div>
</div>
</label>
</div>
<div className="downstream-key-modal-field downstream-key-modal-field-full">
<div className="downstream-key-modal-label">备注说明</div>
<textarea
value={form.description}
onChange={(e) => onChange((prev) => ({ ...prev, description: e.target.value }))}
placeholder="填写业务场景、负责人或限制说明"
style={{ ...inputStyle, minHeight: 84, resize: 'vertical' }}
/>
</div>
<div className="downstream-key-modal-field downstream-key-modal-field-full">
<div className="downstream-key-modal-label">标签</div>
<TagInput
tags={form.tags}
onChange={(tags) => onChange((prev) => ({ ...prev, tags }))}
suggestions={tagSuggestions}
placeholder="输入标签后按回车或逗号,例如:移动端、VIP、项目A"
/>
<div className="downstream-key-modal-help">标签用于搜索、筛选和辅助归类,不影响路由与权限。</div>
</div>
<div className="downstream-key-advanced">
<button type="button" className={`downstream-key-advanced-toggle ${advancedOpen ? 'is-open' : ''}`.trim()} onClick={() => setAdvancedOpen((value) => !value)}>
<span>高级配置</span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{advancedOpen ? '收起' : '展开'}</span>
</button>
{advancedOpen ? (
<div className="downstream-key-advanced-content">
<div className="downstream-key-modal-field downstream-key-modal-field-full">
<div className="downstream-key-modal-label">站点倍率 JSON</div>
<textarea
value={form.siteWeightMultipliersText}
onChange={(e) => onChange((prev) => ({ ...prev, siteWeightMultipliersText: e.target.value }))}
placeholder={'例如:{\n "1": 1.2,\n "7": 0.8\n}'}
style={{ ...inputStyle, minHeight: 96, resize: 'vertical', fontFamily: 'var(--font-mono)' }}
/>
<div className="downstream-key-modal-help">用于对特定站点做分发倍率微调;留空或 `{}` 表示走默认倍率。</div>
</div>
<div className="downstream-key-advanced-grid" style={{ gridTemplateColumns: '1fr' }}>
<div className="downstream-key-advanced-panel">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div>
<div className="downstream-key-modal-section-title">模型白名单</div>
<div className="downstream-key-modal-help">只展示精确模型;未勾选时默认不允许任何精确模型,可点“全选”一次性放开。</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, selectedModels: exactModels }))}>全选</button>
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, selectedModels: [] }))}>清空</button>
</div>
</div>
<div className="downstream-key-modal-meta">已选 {selectedModelCount} 个模型</div>
<div className="toolbar-search" style={{ maxWidth: '100%' }}>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input value={modelSearch} onChange={(e) => setModelSearch(e.target.value)} placeholder="搜索模型" />
</div>
<div style={{ maxHeight: 280, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
{filteredModels.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无匹配模型</div>
) : filteredModels.map((model) => {
const checked = form.selectedModels.includes(model);
return (
<label key={model} style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', padding: '8px 10px', borderRadius: 10, border: '1px solid var(--color-border-light)', background: checked ? 'color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card))' : 'var(--color-bg-card)' }}>
<input
type="checkbox"
checked={checked}
onChange={() => onChange((prev) => ({
...prev,
selectedModels: checked ? prev.selectedModels.filter((item) => item !== model) : [...prev.selectedModels, model],
}))}
/>
<code style={{ color: 'var(--color-text-primary)', fontSize: 12 }}>{model}</code>
</label>
);
})}
</div>
</div>
<div className="downstream-key-advanced-panel">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div>
<div className="downstream-key-modal-section-title">群组范围</div>
<div className="downstream-key-modal-help">限制可访问的群组路由;未勾选时默认不允许任何群组,可点“全选”一次性放开。</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, selectedGroupRouteIds: groupRouteOptions.map((route) => route.id) }))}>全选</button>
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, selectedGroupRouteIds: [] }))}>清空</button>
</div>
</div>
<div className="downstream-key-modal-meta">已选 {selectedGroupCount} 个群组</div>
<div className="toolbar-search" style={{ maxWidth: '100%' }}>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input value={groupSearch} onChange={(e) => setGroupSearch(e.target.value)} placeholder="搜索群组或模型模式" />
</div>
<div style={{ maxHeight: 280, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
{filteredGroups.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无匹配群组</div>
) : filteredGroups.map((route) => {
const checked = normalizedSelectedGroupRouteIds.includes(route.id);
return (
<label key={route.id} style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer', padding: '8px 10px', borderRadius: 10, border: '1px solid var(--color-border-light)', background: checked ? 'color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card))' : 'var(--color-bg-card)' }}>
<input
type="checkbox"
checked={checked}
onChange={() => onChange((prev) => ({
...prev,
selectedGroupRouteIds: checked
? prev.selectedGroupRouteIds.filter((item) => item !== route.id)
: uniqIds([...prev.selectedGroupRouteIds.filter((item) => validGroupRouteIdSet.has(item)), route.id]),
}))}
style={{ marginTop: 2 }}
/>
<div style={{ minWidth: 0 }}>
<div style={{ color: 'var(--color-text-primary)', fontSize: 13, fontWeight: 600 }}>
{routeTitle(route)}
{!route.enabled ? <span style={{ marginLeft: 8, fontSize: 11, color: 'var(--color-danger)' }}>已禁用</span> : null}
</div>
<code style={{ display: 'block', marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)' }}>{route.modelPattern}</code>
</div>
</label>
);
})}
</div>
</div>
<div className="downstream-key-advanced-panel">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div>
<div className="downstream-key-modal-section-title">排除站点</div>
<div className="downstream-key-modal-help">命中的站点会直接跳过,不参与当前下游密钥的通道路由。</div>
</div>
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, excludedSiteIds: [] }))}>清空</button>
</div>
<div className="downstream-key-modal-meta">已排除 {form.excludedSiteIds.length} 个站点</div>
<div className="toolbar-search" style={{ maxWidth: '100%' }}>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input value={siteSearch} onChange={(e) => setSiteSearch(e.target.value)} placeholder="搜索站点" />
</div>
<div style={{ maxHeight: 240, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
{exclusionSourceLoading ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>加载站点与令牌中...</div>
) : filteredSites.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无可排除站点</div>
) : filteredSites.map((site) => {
const checked = form.excludedSiteIds.includes(site.siteId);
return (
<label key={site.siteId} style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', padding: '8px 10px', borderRadius: 10, border: '1px solid var(--color-border-light)', background: checked ? 'color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card))' : 'var(--color-bg-card)' }}>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange((prev) => ({
...prev,
excludedSiteIds: normalizeExcludedSiteIds(
e.target.checked
? [...prev.excludedSiteIds, site.siteId]
: prev.excludedSiteIds.filter((item) => item !== site.siteId),
),
}))}
/>
<div style={{ minWidth: 0 }}>
<div style={{ color: 'var(--color-text-primary)', fontSize: 13, fontWeight: 600 }}>{site.siteName}</div>
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)' }}>{site.accountCount} 个账号</div>
</div>
</label>
);
})}
</div>
</div>
<div className="downstream-key-advanced-panel">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div>
<div className="downstream-key-modal-section-title">排除 API Key/令牌</div>
<div className="downstream-key-modal-help">支持排除显式令牌,以及 `tokenId` 为空时实际使用的默认 API Key。</div>
</div>
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, excludedCredentialRefs: [] }))}>清空</button>
</div>
<div className="downstream-key-modal-meta">已排除 {form.excludedCredentialRefs.length} 个凭证</div>
<div className="toolbar-search" style={{ maxWidth: '100%' }}>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input value={credentialSearch} onChange={(e) => setCredentialSearch(e.target.value)} placeholder="搜索站点 / 账号 / 令牌" />
</div>
<div style={{ maxHeight: 280, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
{exclusionSourceLoading ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>加载站点与令牌中...</div>
) : filteredCredentials.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无可排除 API Key/令牌</div>
) : filteredCredentials.map((item) => {
const checked = form.excludedCredentialRefs.some((ref) => buildExcludedCredentialRefKey(ref) === buildExcludedCredentialRefKey(item.ref));
return (
<label key={item.key} style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer', padding: '8px 10px', borderRadius: 10, border: '1px solid var(--color-border-light)', background: checked ? 'color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card))' : 'var(--color-bg-card)' }}>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange((prev) => ({
...prev,
excludedCredentialRefs: normalizeExcludedCredentialRefs(
e.target.checked
? [...prev.excludedCredentialRefs, item.ref]
: prev.excludedCredentialRefs.filter((ref) => buildExcludedCredentialRefKey(ref) !== buildExcludedCredentialRefKey(item.ref)),
),
}))}
style={{ marginTop: 2 }}
/>
<div style={{ minWidth: 0 }}>
<div style={{ color: 'var(--color-text-primary)', fontSize: 13, fontWeight: 600 }}>{item.label}</div>
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)' }}>
{item.siteName} / {item.accountName}
</div>
<div style={{ marginTop: 2, fontSize: 11, color: 'var(--color-text-muted)' }}>{item.detail}</div>
</div>
</label>
);
})}
</div>
</div>
</div>
</div>
) : null}
</div>
<datalist id="downstream-group-suggestions">
{groupSuggestions.map((group) => <option key={group} value={group} />)}
</datalist>
</CenteredModal>
);
}
+5
View File
@@ -18,6 +18,11 @@ export type SummaryItem = {
supportedModels: string[]; supportedModels: string[];
allowedRouteIds: number[]; allowedRouteIds: number[];
siteWeightMultipliers: Record<number, number>; siteWeightMultipliers: Record<number, number>;
excludedSiteIds: number[];
excludedCredentialRefs: Array<
| { kind: 'account_token'; siteId: number; accountId: number; tokenId: number }
| { kind: 'default_api_key'; siteId: number; accountId: number }
>;
lastUsedAt: string | null; lastUsedAt: string | null;
createdAt: string | null; createdAt: string | null;
updatedAt: string | null; updatedAt: string | null;
@@ -3,10 +3,11 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
describe('Settings downstream modal extraction', () => { describe('Settings downstream modal extraction', () => {
it('delegates the downstream API key portal to a dedicated settings component', () => { it('no longer keeps a dedicated downstream API key editor inside Settings', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Settings.tsx'), 'utf8').replace(/\r\n/g, '\n'); const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Settings.tsx'), 'utf8').replace(/\r\n/g, '\n');
expect(source).toContain("import DownstreamApiKeyModal from './settings/DownstreamApiKeyModal.js'"); expect(source).not.toContain("import DownstreamApiKeyModal from './settings/DownstreamApiKeyModal.js'");
expect(source).not.toContain('{downstreamModalPresence.shouldRender && (() => {'); expect(source).not.toContain('downstreamModalOpen');
expect(source).not.toContain('downstreamCreate');
}); });
}); });
@@ -3,10 +3,11 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
describe('Settings route selector modal extraction', () => { describe('Settings route selector modal extraction', () => {
it('delegates the downstream route selector portal to a dedicated settings component', () => { it('no longer keeps a downstream route selector inside Settings', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Settings.tsx'), 'utf8').replace(/\r\n/g, '\n'); const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Settings.tsx'), 'utf8').replace(/\r\n/g, '\n');
expect(source).toContain("import RouteSelectorModal from './settings/RouteSelectorModal.js'"); expect(source).not.toContain("import RouteSelectorModal from './settings/RouteSelectorModal.js'");
expect(source).not.toContain('{selectorModalPresence.shouldRender && (() => {'); expect(source).not.toContain('selectorOpen');
expect(source).not.toContain('selectorRoutes');
}); });
}); });