diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc40919..0d4b3b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,8 @@ concurrency: env: NODE_VERSION: 25 + # Non-desktop CI jobs do not need the Electron runtime binary. + ELECTRON_SKIP_BINARY_DOWNLOAD: '1' jobs: changes: @@ -129,6 +131,8 @@ jobs: cache: npm - name: Install dependencies + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: '' run: npm ci --prefer-offline --no-audit --no-fund - name: Build desktop diff --git a/drizzle/0020_downstream_api_key_exclusions.sql b/drizzle/0020_downstream_api_key_exclusions.sql new file mode 100644 index 0000000..972f3ca --- /dev/null +++ b/drizzle/0020_downstream_api_key_exclusions.sql @@ -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; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 9251ee6..c8b2e0d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1775058998448, "tag": "0019_proxy_logs_stream_timing", "breakpoints": true + }, + { + "idx": 19, + "version": "6", + "when": 1775397600000, + "tag": "0020_downstream_api_key_exclusions", + "breakpoints": true } ] } diff --git a/src/server/contracts/downstreamApiKeyRoutePayloads.ts b/src/server/contracts/downstreamApiKeyRoutePayloads.ts index b3b6a59..eda83be 100644 --- a/src/server/contracts/downstreamApiKeyRoutePayloads.ts +++ b/src/server/contracts/downstreamApiKeyRoutePayloads.ts @@ -1,5 +1,19 @@ import { z } from 'zod'; +const downstreamExcludedCredentialRefSchema = z.union([ + z.object({ + kind: z.literal('account_token'), + siteId: z.union([z.number(), z.string()]), + accountId: z.union([z.number(), z.string()]), + tokenId: z.union([z.number(), z.string()]), + }), + z.object({ + kind: z.literal('default_api_key'), + siteId: z.union([z.number(), z.string()]), + accountId: z.union([z.number(), z.string()]), + }), +]); + const downstreamApiKeyPayloadSchema = z.object({ name: z.string().optional(), key: z.string().optional(), @@ -16,6 +30,8 @@ const downstreamApiKeyPayloadSchema = z.object({ z.string(), z.record(z.string(), z.union([z.number(), z.string()])), ]).optional(), + excludedSiteIds: z.union([z.string(), z.array(z.union([z.number(), z.string()]))]).optional(), + excludedCredentialRefs: z.union([z.string(), z.array(downstreamExcludedCredentialRefSchema)]).optional(), }).passthrough(); const downstreamApiKeyBatchPayloadSchema = z.object({ @@ -76,6 +92,12 @@ function formatDownstreamApiKeyPayloadError(error: z.ZodError): string { if (firstPath === 'siteWeightMultipliers') { return 'Invalid siteWeightMultipliers. Expected JSON object or string.'; } + if (firstPath === 'excludedSiteIds') { + return 'Invalid excludedSiteIds. Expected string or array.'; + } + if (firstPath === 'excludedCredentialRefs') { + return 'Invalid excludedCredentialRefs. Expected JSON string or array.'; + } if (firstPath === 'ids') { return 'Invalid ids. Expected number[].'; } diff --git a/src/server/db/generated/mysql.bootstrap.sql b/src/server/db/generated/mysql.bootstrap.sql index 713c150..e87849c 100644 --- a/src/server/db/generated/mysql.bootstrap.sql +++ b/src/server/db/generated/mysql.bootstrap.sql @@ -2,7 +2,7 @@ CREATE TABLE IF NOT EXISTS `sites` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY CREATE TABLE IF NOT EXISTS `accounts` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `username` TEXT, `access_token` TEXT NOT NULL, `api_token` TEXT, `balance` DOUBLE DEFAULT 0, `balance_used` DOUBLE DEFAULT 0, `quota` DOUBLE DEFAULT 0, `unit_cost` DOUBLE, `value_score` DOUBLE DEFAULT 0, `status` VARCHAR(191) DEFAULT 'active', `checkin_enabled` BOOLEAN DEFAULT true, `last_checkin_at` VARCHAR(191), `last_balance_refresh` VARCHAR(191), `extra_config` JSON, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, `oauth_provider` TEXT, `oauth_account_key` TEXT, `oauth_project_id` TEXT, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS `account_tokens` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `name` TEXT NOT NULL, `token` TEXT NOT NULL, `source` VARCHAR(191) DEFAULT 'manual', `enabled` BOOLEAN DEFAULT true, `is_default` BOOLEAN DEFAULT false, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `token_group` TEXT, `value_status` VARCHAR(191) NOT NULL DEFAULT 'ready', FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS `checkin_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `status` TEXT NOT NULL, `message` TEXT, `reward` TEXT, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE); -CREATE TABLE IF NOT EXISTS `downstream_api_keys` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `key` TEXT NOT NULL, `description` TEXT, `enabled` BOOLEAN DEFAULT true, `expires_at` VARCHAR(191), `max_cost` DOUBLE, `used_cost` DOUBLE DEFAULT 0, `max_requests` INT, `used_requests` INT DEFAULT 0, `supported_models` JSON, `allowed_route_ids` JSON, `site_weight_multipliers` JSON, `last_used_at` VARCHAR(191), `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `group_name` TEXT, `tags` TEXT); +CREATE TABLE IF NOT EXISTS `downstream_api_keys` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `key` TEXT NOT NULL, `description` TEXT, `enabled` BOOLEAN DEFAULT true, `expires_at` VARCHAR(191), `max_cost` DOUBLE, `used_cost` DOUBLE DEFAULT 0, `max_requests` INT, `used_requests` INT DEFAULT 0, `supported_models` JSON, `allowed_route_ids` JSON, `site_weight_multipliers` JSON, `last_used_at` VARCHAR(191), `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `group_name` TEXT, `tags` TEXT, `excluded_site_ids` JSON, `excluded_credential_refs` JSON); CREATE TABLE IF NOT EXISTS `events` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `type` TEXT NOT NULL, `title` TEXT NOT NULL, `message` TEXT, `level` VARCHAR(191) NOT NULL DEFAULT 'info', `read` BOOLEAN DEFAULT false, `related_id` INT, `related_type` TEXT, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s'))); CREATE TABLE IF NOT EXISTS `model_availability` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `model_name` TEXT NOT NULL, `available` BOOLEAN, `latency_ms` INT, `checked_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `is_manual` BOOLEAN DEFAULT false, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS `proxy_debug_traces` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `downstream_path` TEXT NOT NULL, `client_kind` TEXT, `session_id` TEXT, `trace_hint` TEXT, `requested_model` TEXT, `downstream_api_key_id` INT, `request_headers_json` JSON, `request_body_json` JSON, `sticky_session_key` TEXT, `sticky_hit_channel_id` INT, `selected_channel_id` INT, `selected_route_id` INT, `selected_account_id` INT, `selected_site_id` INT, `selected_site_platform` TEXT, `endpoint_candidates_json` JSON, `endpoint_runtime_state_json` JSON, `decision_summary_json` JSON, `final_status` TEXT, `final_http_status` INT, `final_upstream_path` TEXT, `final_response_headers_json` JSON, `final_response_body_json` JSON, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s'))); diff --git a/src/server/db/generated/mysql.upgrade.sql b/src/server/db/generated/mysql.upgrade.sql index 2565723..39c1018 100644 --- a/src/server/db/generated/mysql.upgrade.sql +++ b/src/server/db/generated/mysql.upgrade.sql @@ -1,2 +1 @@ -ALTER TABLE `proxy_logs` ADD COLUMN `is_stream` BOOLEAN; -ALTER TABLE `proxy_logs` ADD COLUMN `first_byte_latency_ms` INT; +-- no schema changes detected for mysql diff --git a/src/server/db/generated/postgres.bootstrap.sql b/src/server/db/generated/postgres.bootstrap.sql index e558ca5..ed6659a 100644 --- a/src/server/db/generated/postgres.bootstrap.sql +++ b/src/server/db/generated/postgres.bootstrap.sql @@ -2,7 +2,7 @@ CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTIT CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" DOUBLE PRECISION DEFAULT 0, "balance_used" DOUBLE PRECISION DEFAULT 0, "quota" DOUBLE PRECISION DEFAULT 0, "unit_cost" DOUBLE PRECISION, "value_score" DOUBLE PRECISION DEFAULT 0, "status" TEXT DEFAULT 'active', "checkin_enabled" BOOLEAN DEFAULT true, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" JSONB, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, "oauth_provider" TEXT, "oauth_account_key" TEXT, "oauth_project_id" TEXT, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT true, "is_default" BOOLEAN DEFAULT false, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "token_group" TEXT, "value_status" TEXT NOT NULL DEFAULT 'ready', FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE); -CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL, "description" TEXT, "enabled" BOOLEAN DEFAULT true, "expires_at" TEXT, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" JSONB, "allowed_route_ids" JSONB, "site_weight_multipliers" JSONB, "last_used_at" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "group_name" TEXT, "tags" TEXT); +CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL, "description" TEXT, "enabled" BOOLEAN DEFAULT true, "expires_at" TEXT, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" JSONB, "allowed_route_ids" JSONB, "site_weight_multipliers" JSONB, "last_used_at" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "group_name" TEXT, "tags" TEXT, "excluded_site_ids" JSONB, "excluded_credential_refs" JSONB); CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT NOT NULL DEFAULT 'info', "read" BOOLEAN DEFAULT false, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS')); CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "is_manual" BOOLEAN DEFAULT false, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS "proxy_debug_traces" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "downstream_path" TEXT NOT NULL, "client_kind" TEXT, "session_id" TEXT, "trace_hint" TEXT, "requested_model" TEXT, "downstream_api_key_id" INTEGER, "request_headers_json" JSONB, "request_body_json" JSONB, "sticky_session_key" TEXT, "sticky_hit_channel_id" INTEGER, "selected_channel_id" INTEGER, "selected_route_id" INTEGER, "selected_account_id" INTEGER, "selected_site_id" INTEGER, "selected_site_platform" TEXT, "endpoint_candidates_json" JSONB, "endpoint_runtime_state_json" JSONB, "decision_summary_json" JSONB, "final_status" TEXT, "final_http_status" INTEGER, "final_upstream_path" TEXT, "final_response_headers_json" JSONB, "final_response_body_json" JSONB, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS')); diff --git a/src/server/db/generated/postgres.upgrade.sql b/src/server/db/generated/postgres.upgrade.sql index 79e2716..bf33dfa 100644 --- a/src/server/db/generated/postgres.upgrade.sql +++ b/src/server/db/generated/postgres.upgrade.sql @@ -1,2 +1 @@ -ALTER TABLE "proxy_logs" ADD COLUMN "is_stream" BOOLEAN; -ALTER TABLE "proxy_logs" ADD COLUMN "first_byte_latency_ms" INTEGER; +-- no schema changes detected for postgres diff --git a/src/server/db/generated/schemaContract.json b/src/server/db/generated/schemaContract.json index 782535a..5db036a 100644 --- a/src/server/db/generated/schemaContract.json +++ b/src/server/db/generated/schemaContract.json @@ -355,6 +355,18 @@ "notNull": false, "defaultValue": null, "primaryKey": false + }, + "excluded_site_ids": { + "logicalType": "json", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "excluded_credential_refs": { + "logicalType": "json", + "notNull": false, + "defaultValue": null, + "primaryKey": false } } }, diff --git a/src/server/db/migrate.test.ts b/src/server/db/migrate.test.ts index d8ee848..6bcc642 100644 --- a/src/server/db/migrate.test.ts +++ b/src/server/db/migrate.test.ts @@ -384,6 +384,9 @@ describe('sqlite migrate bootstrap', () => { '0011_downstream_api_key_metadata', '0012_account_token_value_status', '0013_oauth_multi_provider', + // 0008 creates downstream_api_keys, so later table-dependent migrations + // must stay missing in this partial-journal fixture too. + '0020_downstream_api_key_exclusions', ]); const appliedEntries = journalEntries.filter((entry) => !missingTags.has(entry.tag)); diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 21a9f05..b34f6e1 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -350,6 +350,8 @@ export const downstreamApiKeys = sqliteTable('downstream_api_keys', { supportedModels: text('supported_models'), // JSON array allowedRouteIds: text('allowed_route_ids'), // JSON array siteWeightMultipliers: text('site_weight_multipliers'), // JSON object { [siteId]: multiplier } + excludedSiteIds: text('excluded_site_ids'), // JSON array + excludedCredentialRefs: text('excluded_credential_refs'), // JSON array lastUsedAt: text('last_used_at'), createdAt: text('created_at').default(sql`(datetime('now'))`), updatedAt: text('updated_at').default(sql`(datetime('now'))`), diff --git a/src/server/db/schemaMetadata.ts b/src/server/db/schemaMetadata.ts index c630ea8..203e901 100644 --- a/src/server/db/schemaMetadata.ts +++ b/src/server/db/schemaMetadata.ts @@ -43,6 +43,8 @@ function isJsonLikeColumn(columnName: string): boolean { || normalizedColumn.includes('mapping') || normalizedColumn.includes('headers') || normalizedColumn.includes('config') + || normalizedColumn.endsWith('_site_ids') + || normalizedColumn.includes('credential_refs') || normalizedColumn.includes('details') || normalizedColumn.includes('meta') || normalizedColumn.includes('models') diff --git a/src/server/routes/api/downstreamApiKeys.test.ts b/src/server/routes/api/downstreamApiKeys.test.ts index 1b2e31c..f3fef57 100644 --- a/src/server/routes/api/downstreamApiKeys.test.ts +++ b/src/server/routes/api/downstreamApiKeys.test.ts @@ -224,6 +224,198 @@ describe('downstream api keys routes', () => { }); }); + it('roundtrips excluded sites and excluded credentials through create, update, and list', async () => { + const siteA = await db.insert(schema.sites).values({ + name: 'site-a', + url: 'https://site-a.example.com', + status: 'active', + platform: 'new-api', + }).returning().get(); + const siteB = await db.insert(schema.sites).values({ + name: 'site-b', + url: 'https://site-b.example.com', + status: 'active', + platform: 'new-api', + }).returning().get(); + + const accountA = await db.insert(schema.accounts).values({ + siteId: siteA.id, + username: 'account-a', + accessToken: 'access-a', + apiToken: 'sk-default-a', + status: 'active', + }).returning().get(); + const accountB = await db.insert(schema.accounts).values({ + siteId: siteB.id, + username: 'account-b', + accessToken: 'access-b', + apiToken: 'sk-default-b', + status: 'active', + }).returning().get(); + + const tokenA = await db.insert(schema.accountTokens).values({ + accountId: accountA.id, + name: 'token-a', + token: 'sk-token-a', + enabled: true, + isDefault: true, + valueStatus: 'ready', + }).returning().get(); + + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-5.2', + displayName: 'portal-route', + enabled: true, + }).returning().get(); + + const createRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys', + payload: { + name: 'exclude-key', + key: 'sk-exclude-key-001', + allowedRouteIds: [route.id], + excludedSiteIds: [siteB.id], + excludedCredentialRefs: [ + { kind: 'default_api_key', siteId: siteB.id, accountId: accountB.id }, + { kind: 'account_token', siteId: siteA.id, accountId: accountA.id, tokenId: tokenA.id }, + ], + }, + }); + + expect(createRes.statusCode).toBe(200); + expect(createRes.json()).toMatchObject({ + success: true, + item: { + excludedSiteIds: [siteB.id], + excludedCredentialRefs: [ + { kind: 'account_token', siteId: siteA.id, accountId: accountA.id, tokenId: tokenA.id }, + { kind: 'default_api_key', siteId: siteB.id, accountId: accountB.id }, + ], + }, + }); + + const keyId = createRes.json().item.id as number; + const updateRes = await app.inject({ + method: 'PUT', + url: `/api/downstream-keys/${keyId}`, + payload: { + excludedSiteIds: [siteB.id, siteA.id, siteB.id], + excludedCredentialRefs: [ + { kind: 'default_api_key', siteId: siteB.id, accountId: accountB.id }, + { kind: 'account_token', siteId: siteA.id, accountId: accountA.id, tokenId: tokenA.id }, + { kind: 'account_token', siteId: siteA.id, accountId: accountA.id, tokenId: tokenA.id }, + ], + }, + }); + + expect(updateRes.statusCode).toBe(200); + expect(updateRes.json()).toMatchObject({ + success: true, + item: { + excludedSiteIds: [siteA.id, siteB.id], + excludedCredentialRefs: [ + { kind: 'account_token', siteId: siteA.id, accountId: accountA.id, tokenId: tokenA.id }, + { kind: 'default_api_key', siteId: siteB.id, accountId: accountB.id }, + ], + }, + }); + + const listRes = await app.inject({ + method: 'GET', + url: '/api/downstream-keys', + }); + + expect(listRes.statusCode).toBe(200); + expect(listRes.json()).toMatchObject({ + success: true, + items: [ + expect.objectContaining({ + id: keyId, + excludedSiteIds: [siteA.id, siteB.id], + excludedCredentialRefs: [ + { kind: 'account_token', siteId: siteA.id, accountId: accountA.id, tokenId: tokenA.id }, + { kind: 'default_api_key', siteId: siteB.id, accountId: accountB.id }, + ], + }), + ], + }); + }); + + it('rejects unknown or mismatched exclusion references', async () => { + const siteA = await db.insert(schema.sites).values({ + name: 'site-a', + url: 'https://site-a.example.com', + status: 'active', + platform: 'new-api', + }).returning().get(); + const siteB = await db.insert(schema.sites).values({ + name: 'site-b', + url: 'https://site-b.example.com', + status: 'active', + platform: 'new-api', + }).returning().get(); + const accountA = await db.insert(schema.accounts).values({ + siteId: siteA.id, + username: 'account-a', + accessToken: 'access-a', + apiToken: 'sk-default-a', + status: 'active', + }).returning().get(); + const accountB = await db.insert(schema.accounts).values({ + siteId: siteB.id, + username: 'account-b', + accessToken: 'access-b', + apiToken: null, + status: 'active', + }).returning().get(); + const tokenA = await db.insert(schema.accountTokens).values({ + accountId: accountA.id, + name: 'token-a', + token: 'sk-token-a', + enabled: true, + isDefault: true, + valueStatus: 'ready', + }).returning().get(); + + const unknownSiteRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys', + payload: { + name: 'bad-site', + key: 'sk-bad-site-001', + excludedSiteIds: [999999], + }, + }); + expect(unknownSiteRes.statusCode).toBe(400); + + const mismatchedTokenRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys', + payload: { + name: 'bad-token', + key: 'sk-bad-token-001', + excludedCredentialRefs: [ + { kind: 'account_token', siteId: siteB.id, accountId: accountA.id, tokenId: tokenA.id }, + ], + }, + }); + expect(mismatchedTokenRes.statusCode).toBe(400); + + const missingDefaultApiKeyRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys', + payload: { + name: 'bad-default', + key: 'sk-bad-default-001', + excludedCredentialRefs: [ + { kind: 'default_api_key', siteId: siteB.id, accountId: accountB.id }, + ], + }, + }); + expect(missingDefaultApiKeyRes.statusCode).toBe(400); + }); + it('supports batch enable/disable/reset/delete operations', async () => { const inserted = await db.insert(schema.downstreamApiKeys).values([ { @@ -644,7 +836,7 @@ describe('downstream api keys routes', () => { expect(updateRes.statusCode).toBe(400); expect(updateRes.json()).toMatchObject({ success: false, - message: 'siteWeightMultipliers 包含不存在的站点: 999', + message: '策略中包含不存在的站点: 999', }); const filteredSummaryRes = await app.inject({ diff --git a/src/server/routes/api/downstreamApiKeys.ts b/src/server/routes/api/downstreamApiKeys.ts index b018fe9..53b1d3b 100644 --- a/src/server/routes/api/downstreamApiKeys.ts +++ b/src/server/routes/api/downstreamApiKeys.ts @@ -9,6 +9,7 @@ import { toDownstreamApiKeyPolicyView, toPersistenceJson, } from '../../services/downstreamApiKeyService.js'; +import type { DownstreamExcludedCredentialRef } from '../../services/downstreamPolicyTypes.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { parseDownstreamApiKeyBatchPayload, @@ -159,6 +160,8 @@ function resolveBucketTsExpression(bucketSeconds: number) { async function validatePolicyReferences(input: { allowedRouteIds: number[]; siteWeightMultipliers: Record; + excludedSiteIds: number[]; + excludedCredentialRefs: DownstreamExcludedCredentialRef[]; }): Promise { const routeIds = input.allowedRouteIds || []; if (routeIds.length > 0) { @@ -173,10 +176,14 @@ async function validatePolicyReferences(input: { } } - const siteIds = Object.keys(input.siteWeightMultipliers || {}) + const weightedSiteIds = Object.keys(input.siteWeightMultipliers || {}) .map((key) => Number(key)) .filter((value) => Number.isFinite(value) && value > 0) .map((value) => Math.trunc(value)); + const excludedSiteIds = (input.excludedSiteIds || []) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)); + const siteIds = Array.from(new Set([...weightedSiteIds, ...excludedSiteIds])); if (siteIds.length > 0) { const rows = await db.select({ id: schema.sites.id }) .from(schema.sites) @@ -185,7 +192,70 @@ async function validatePolicyReferences(input: { const existingIds = new Set(rows.map((row) => Number(row.id))); const missingIds = siteIds.filter((id) => !existingIds.has(id)); if (missingIds.length > 0) { - return `siteWeightMultipliers 包含不存在的站点: ${missingIds.join(', ')}`; + return `策略中包含不存在的站点: ${missingIds.join(', ')}`; + } + } + + const credentialRefs = input.excludedCredentialRefs || []; + const accountTokenRefs = credentialRefs.filter((ref): ref is Extract => 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( + 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 => 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( + rows.map((row) => [Number(row.accountId), { + accountId: Number(row.accountId), + siteId: Number(row.siteId), + apiToken: row.apiToken, + }]), + ); + for (const ref of defaultApiKeyRefs) { + const matched = accountById.get(ref.accountId); + if (!matched) { + return `excludedCredentialRefs 包含不存在的账号: ${ref.accountId}`; + } + if (Number(matched.siteId) !== ref.siteId) { + return `excludedCredentialRefs 中的 default_api_key 引用与站点不匹配: ${ref.accountId}`; + } + if (!(matched.apiToken || '').trim()) { + return `excludedCredentialRefs 中的 default_api_key 账号缺少默认 API Key: ${ref.accountId}`; + } } } @@ -470,6 +540,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { const policyRefError = await validatePolicyReferences({ allowedRouteIds: normalized.allowedRouteIds, siteWeightMultipliers: normalized.siteWeightMultipliers, + excludedSiteIds: normalized.excludedSiteIds, + excludedCredentialRefs: normalized.excludedCredentialRefs, }); if (policyRefError) { return reply.code(400).send({ success: false, message: policyRefError }); @@ -496,6 +568,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { supportedModels: toPersistenceJson(normalized.supportedModels), allowedRouteIds: toPersistenceJson(normalized.allowedRouteIds), siteWeightMultipliers: toPersistenceJson(normalized.siteWeightMultipliers), + excludedSiteIds: toPersistenceJson(normalized.excludedSiteIds), + excludedCredentialRefs: toPersistenceJson(normalized.excludedCredentialRefs), createdAt: nowIso, updatedAt: nowIso, }, @@ -552,6 +626,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { supportedModels: hasOwn('supportedModels') ? body.supportedModels : existingView.supportedModels, allowedRouteIds: hasOwn('allowedRouteIds') ? body.allowedRouteIds : existingView.allowedRouteIds, siteWeightMultipliers: hasOwn('siteWeightMultipliers') ? body.siteWeightMultipliers : existingView.siteWeightMultipliers, + excludedSiteIds: hasOwn('excludedSiteIds') ? body.excludedSiteIds : existingView.excludedSiteIds, + excludedCredentialRefs: hasOwn('excludedCredentialRefs') ? body.excludedCredentialRefs : existingView.excludedCredentialRefs, }); } catch (error: unknown) { return reply.code(400).send({ success: false, message: (error as Error)?.message || '参数无效' }); @@ -569,6 +645,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { const policyRefError = await validatePolicyReferences({ allowedRouteIds: normalized.allowedRouteIds, siteWeightMultipliers: normalized.siteWeightMultipliers, + excludedSiteIds: normalized.excludedSiteIds, + excludedCredentialRefs: normalized.excludedCredentialRefs, }); if (policyRefError) { return reply.code(400).send({ success: false, message: policyRefError }); @@ -589,6 +667,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { supportedModels: toPersistenceJson(normalized.supportedModels), allowedRouteIds: toPersistenceJson(normalized.allowedRouteIds), siteWeightMultipliers: toPersistenceJson(normalized.siteWeightMultipliers), + excludedSiteIds: toPersistenceJson(normalized.excludedSiteIds), + excludedCredentialRefs: toPersistenceJson(normalized.excludedCredentialRefs), updatedAt: nowIso, }).where(eq(schema.downstreamApiKeys.id, id)).run(); diff --git a/src/server/services/backupService.test.ts b/src/server/services/backupService.test.ts index 1a1ad92..870d2b3 100644 --- a/src/server/services/backupService.test.ts +++ b/src/server/services/backupService.test.ts @@ -202,6 +202,10 @@ describe('backupService', () => { supportedModels: '["gpt-4o-mini"]', allowedRouteIds: `[${route.id}]`, siteWeightMultipliers: `{"${site.id}":1.5}`, + excludedSiteIds: `[${site.id}]`, + excludedCredentialRefs: JSON.stringify([ + { kind: 'account_token', siteId: site.id, accountId: account.id, tokenId: accountToken.id }, + ]), lastUsedAt: now, createdAt: now, updatedAt: now, @@ -238,6 +242,8 @@ describe('backupService', () => { supportedModels: '["gpt-4o-mini"]', allowedRouteIds: `[${route.id}]`, siteWeightMultipliers: `{"${site.id}":1.5}`, + excludedSiteIds: `[${site.id}]`, + excludedCredentialRefs: `[{"kind":"account_token","siteId":${site.id},"accountId":${account.id},"tokenId":${accountToken.id}}]`, }), ]); expect(exported.accounts.accounts[0]).not.toHaveProperty('balanceUsed'); @@ -307,6 +313,8 @@ describe('backupService', () => { supportedModels: '["gpt-4o-mini"]', allowedRouteIds: `[${route.id}]`, siteWeightMultipliers: `{"${site.id}":1.5}`, + excludedSiteIds: `[${site.id}]`, + excludedCredentialRefs: `[{"kind":"account_token","siteId":${site.id},"accountId":${account.id},"tokenId":${accountToken.id}}]`, lastUsedAt: now, }), ]); @@ -684,6 +692,10 @@ describe('backupService', () => { supportedModels: '["gpt-4o"]', allowedRouteIds: `[${route.id}]`, siteWeightMultipliers: `{"${site.id}":1.25}`, + excludedSiteIds: `[${site.id}]`, + excludedCredentialRefs: JSON.stringify([ + { kind: 'account_token', siteId: site.id, accountId: account.id, tokenId: accountToken.id }, + ]), lastUsedAt: exportedAt, createdAt: exportedAt, updatedAt: exportedAt, @@ -828,6 +840,8 @@ describe('backupService', () => { supportedModels: '["gpt-local"]', allowedRouteIds: '[999]', siteWeightMultipliers: '{"999":9}', + excludedSiteIds: '[999]', + excludedCredentialRefs: '[{"kind":"default_api_key","siteId":999,"accountId":999}]', lastUsedAt: localRuntimeAt, updatedAt: localRuntimeAt, }).where(eq(schema.downstreamApiKeys.id, downstreamKey.id)).run(); @@ -991,6 +1005,8 @@ describe('backupService', () => { supportedModels: '["gpt-4o"]', allowedRouteIds: `[${route.id}]`, siteWeightMultipliers: `{"${site.id}":1.25}`, + excludedSiteIds: `[${site.id}]`, + excludedCredentialRefs: `[{"kind":"account_token","siteId":${site.id},"accountId":${account.id},"tokenId":${accountToken.id}}]`, lastUsedAt: localRuntimeAt, }), ]); diff --git a/src/server/services/backupService.ts b/src/server/services/backupService.ts index 4598b6d..a25ff47 100644 --- a/src/server/services/backupService.ts +++ b/src/server/services/backupService.ts @@ -97,6 +97,8 @@ type BackupDownstreamApiKeyRow = Pick & Partial>; interface AccountsBackupSection { @@ -1769,6 +1771,8 @@ async function importAccountsSection(section: AccountsBackupSection): Promise { supportedModels: ['gpt-4o-mini'], allowedRouteIds: [3], siteWeightMultipliers: { 1: 1.5 }, + excludedSiteIds: [1], + excludedCredentialRefs: [{ kind: 'default_api_key', siteId: 1, accountId: 2 }], enabled: true, }], events: [], @@ -404,6 +406,8 @@ describe('databaseMigrationService', () => { expect(downstreamKeyStatement?.values[downstreamKeyStatement.columns.indexOf('supported_models')]).toBe('["gpt-4o-mini"]'); expect(downstreamKeyStatement?.values[downstreamKeyStatement.columns.indexOf('allowed_route_ids')]).toBe('[3]'); expect(downstreamKeyStatement?.values[downstreamKeyStatement.columns.indexOf('site_weight_multipliers')]).toBe('{"1":1.5}'); + expect(downstreamKeyStatement?.values[downstreamKeyStatement.columns.indexOf('excluded_site_ids')]).toBe('[1]'); + expect(downstreamKeyStatement?.values[downstreamKeyStatement.columns.indexOf('excluded_credential_refs')]).toBe('[{"kind":"default_api_key","siteId":1,"accountId":2}]'); }); it('uses schema logical types to serialize JSON columns instead of String(value)', () => { diff --git a/src/server/services/databaseMigrationService.ts b/src/server/services/databaseMigrationService.ts index 0df7bc7..3d675bb 100644 --- a/src/server/services/databaseMigrationService.ts +++ b/src/server/services/databaseMigrationService.ts @@ -663,7 +663,7 @@ function buildStatements( for (const row of snapshot.accounts.downstreamApiKeys) { statements.push({ table: 'downstream_api_keys', - columns: ['id', 'name', 'key', 'description', 'enabled', 'expires_at', 'max_cost', 'used_cost', 'max_requests', 'used_requests', 'supported_models', 'allowed_route_ids', 'site_weight_multipliers', 'last_used_at', 'created_at', 'updated_at'], + columns: ['id', 'name', 'key', 'description', 'enabled', 'expires_at', 'max_cost', 'used_cost', 'max_requests', 'used_requests', 'supported_models', 'allowed_route_ids', 'site_weight_multipliers', 'excluded_site_ids', 'excluded_credential_refs', 'last_used_at', 'created_at', 'updated_at'], values: [ asNumber(row.id, 0), asNullableString(row.name), @@ -678,6 +678,8 @@ function buildStatements( serializeColumnValue('downstream_api_keys', 'supported_models', row.supportedModels, contract), serializeColumnValue('downstream_api_keys', 'allowed_route_ids', row.allowedRouteIds, contract), serializeColumnValue('downstream_api_keys', 'site_weight_multipliers', row.siteWeightMultipliers, contract), + serializeColumnValue('downstream_api_keys', 'excluded_site_ids', row.excludedSiteIds, contract), + serializeColumnValue('downstream_api_keys', 'excluded_credential_refs', row.excludedCredentialRefs, contract), asNullableString(row.lastUsedAt), asNullableString(row.createdAt), asNullableString(row.updatedAt), diff --git a/src/server/services/downstreamApiKeyService.ts b/src/server/services/downstreamApiKeyService.ts index 88068db..4c70631 100644 --- a/src/server/services/downstreamApiKeyService.ts +++ b/src/server/services/downstreamApiKeyService.ts @@ -2,7 +2,11 @@ import { and, eq, inArray, sql } from 'drizzle-orm'; import { minimatch } from 'minimatch'; import { db, schema } from '../db/index.js'; import { config } from '../config.js'; -import { EMPTY_DOWNSTREAM_ROUTING_POLICY, type DownstreamRoutingPolicy } from './downstreamPolicyTypes.js'; +import { + EMPTY_DOWNSTREAM_ROUTING_POLICY, + type DownstreamExcludedCredentialRef, + type DownstreamRoutingPolicy, +} from './downstreamPolicyTypes.js'; export type DownstreamApiKeyRow = typeof schema.downstreamApiKeys.$inferSelect; @@ -23,6 +27,8 @@ export type DownstreamApiKeyPolicyView = { supportedModels: string[]; allowedRouteIds: number[]; siteWeightMultipliers: Record; + excludedSiteIds: number[]; + excludedCredentialRefs: DownstreamExcludedCredentialRef[]; lastUsedAt: string | null; createdAt: string | null; updatedAt: string | null; @@ -196,6 +202,86 @@ export function normalizeSiteWeightMultipliersInput(input: unknown): Record= 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(); + for (const item of raw) { + if (!item || typeof item !== 'object' || Array.isArray(item)) continue; + const kind = String((item as Record).kind || '').trim(); + const siteId = Math.trunc(Number((item as Record).siteId)); + const accountId = Math.trunc(Number((item as Record).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).tokenId)); + if (!Number.isFinite(tokenId) || tokenId <= 0) continue; + normalizedRef = { + kind: 'account_token', + siteId, + accountId, + tokenId, + }; + } else if (kind === 'default_api_key') { + normalizedRef = { + kind: 'default_api_key', + siteId, + accountId, + }; + } + + if (!normalizedRef) continue; + const dedupeKey = buildExcludedCredentialRefKey(normalizedRef); + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + refs.push(normalizedRef); + if (refs.length >= 1000) break; + } + + return refs.sort(compareExcludedCredentialRefs); +} + export function matchesDownstreamModelPattern(model: string, pattern: string): boolean { const normalizedPattern = (pattern || '').trim(); if (!normalizedPattern) return false; @@ -259,6 +345,8 @@ export function toDownstreamApiKeyPolicyView(row: DownstreamApiKeyRow): Downstre const supportedModels = normalizeSupportedModelsInput(parseJson(row.supportedModels)); const allowedRouteIds = normalizeAllowedRouteIdsInput(parseJson(row.allowedRouteIds)); const siteWeightMultipliers = normalizeSiteWeightMultipliersInput(parseJson(row.siteWeightMultipliers)); + const excludedSiteIds = normalizeExcludedSiteIdsInput(parseJson(row.excludedSiteIds)); + const excludedCredentialRefs = normalizeExcludedCredentialRefsInput(parseJson(row.excludedCredentialRefs)); return { id: row.id, @@ -277,17 +365,21 @@ export function toDownstreamApiKeyPolicyView(row: DownstreamApiKeyRow): Downstre supportedModels, allowedRouteIds, siteWeightMultipliers, + excludedSiteIds, + excludedCredentialRefs, lastUsedAt: row.lastUsedAt || null, createdAt: row.createdAt || null, updatedAt: row.updatedAt || null, }; } -export function toPolicyFromView(view: Pick): DownstreamRoutingPolicy { +export function toPolicyFromView(view: Pick): DownstreamRoutingPolicy { return { supportedModels: normalizeSupportedModelsInput(view.supportedModels), allowedRouteIds: normalizeAllowedRouteIdsInput(view.allowedRouteIds), siteWeightMultipliers: normalizeSiteWeightMultipliersInput(view.siteWeightMultipliers), + excludedSiteIds: normalizeExcludedSiteIdsInput(view.excludedSiteIds), + excludedCredentialRefs: normalizeExcludedCredentialRefsInput(view.excludedCredentialRefs), denyAllWhenEmpty: true, }; } @@ -437,6 +529,8 @@ export function normalizeDownstreamApiKeyPayload(input: { supportedModels?: unknown; allowedRouteIds?: unknown; siteWeightMultipliers?: unknown; + excludedSiteIds?: unknown; + excludedCredentialRefs?: unknown; }) { const name = typeof input.name === 'string' ? input.name.trim() : ''; const key = typeof input.key === 'string' ? input.key.trim() : ''; @@ -464,6 +558,8 @@ export function normalizeDownstreamApiKeyPayload(input: { const supportedModels = normalizeSupportedModelsInput(input.supportedModels); const allowedRouteIds = normalizeAllowedRouteIdsInput(input.allowedRouteIds); const siteWeightMultipliers = normalizeSiteWeightMultipliersInput(input.siteWeightMultipliers); + const excludedSiteIds = normalizeExcludedSiteIdsInput(input.excludedSiteIds); + const excludedCredentialRefs = normalizeExcludedCredentialRefsInput(input.excludedCredentialRefs); return { name, @@ -478,6 +574,8 @@ export function normalizeDownstreamApiKeyPayload(input: { supportedModels, allowedRouteIds, siteWeightMultipliers, + excludedSiteIds, + excludedCredentialRefs, }; } diff --git a/src/server/services/downstreamPolicyTypes.ts b/src/server/services/downstreamPolicyTypes.ts index 08bdfee..f1dedc4 100644 --- a/src/server/services/downstreamPolicyTypes.ts +++ b/src/server/services/downstreamPolicyTypes.ts @@ -1,7 +1,26 @@ +export type DownstreamAccountTokenCredentialRef = { + kind: 'account_token'; + siteId: number; + accountId: number; + tokenId: number; +}; + +export type DownstreamDefaultApiKeyCredentialRef = { + kind: 'default_api_key'; + siteId: number; + accountId: number; +}; + +export type DownstreamExcludedCredentialRef = + | DownstreamAccountTokenCredentialRef + | DownstreamDefaultApiKeyCredentialRef; + export interface DownstreamRoutingPolicy { supportedModels: string[]; allowedRouteIds: number[]; siteWeightMultipliers: Record; + excludedSiteIds: number[]; + excludedCredentialRefs: DownstreamExcludedCredentialRef[]; denyAllWhenEmpty?: boolean; } @@ -9,4 +28,6 @@ export const EMPTY_DOWNSTREAM_ROUTING_POLICY: DownstreamRoutingPolicy = { supportedModels: [], allowedRouteIds: [], siteWeightMultipliers: {}, + excludedSiteIds: [], + excludedCredentialRefs: [], }; diff --git a/src/server/services/tokenRouter.downstream-policy.test.ts b/src/server/services/tokenRouter.downstream-policy.test.ts index c394a5f..3c9cc53 100644 --- a/src/server/services/tokenRouter.downstream-policy.test.ts +++ b/src/server/services/tokenRouter.downstream-policy.test.ts @@ -365,4 +365,201 @@ describe('TokenRouter downstream policy', () => { expect(claudePick?.channel.routeId).toBe(claudeGroupRoute.id); expect(gptPick?.channel.routeId).toBe(gptExactRoute.id); }); + + it('excludes candidates from excluded sites before route selection', async () => { + const site = await db.insert(schema.sites).values({ + name: 'site-excluded', + url: 'https://excluded.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'user-excluded', + accessToken: 'access-excluded', + apiToken: 'sk-excluded', + status: 'active', + }).returning().get(); + + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-4o-mini', + enabled: true, + }).returning().get(); + + const channel = await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: account.id, + tokenId: null, + priority: 0, + weight: 10, + enabled: true, + }).returning().get(); + + const router = new TokenRouter(); + const policy: any = { + allowedRouteIds: [route.id], + supportedModels: [], + siteWeightMultipliers: {}, + excludedSiteIds: [site.id], + excludedCredentialRefs: [], + }; + + const pick = await router.selectChannel('gpt-4o-mini', policy); + const decision = await router.explainSelectionForRoute(route.id, 'gpt-4o-mini', [], policy); + const candidate = decision.candidates.find((item) => item.channelId === channel.id); + + expect(pick).toBeNull(); + expect(candidate?.eligible).toBe(false); + expect(candidate?.reason).toContain('站点已被下游密钥排除'); + }); + + it('excludes explicitly bound tokens by downstream credential refs', async () => { + const site = await db.insert(schema.sites).values({ + name: 'site-token', + url: 'https://token.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const blockedAccount = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'user-blocked', + accessToken: 'access-blocked', + apiToken: 'sk-blocked-default', + status: 'active', + }).returning().get(); + const allowedAccount = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'user-allowed', + accessToken: 'access-allowed', + apiToken: 'sk-allowed-default', + status: 'active', + }).returning().get(); + + const blockedToken = await db.insert(schema.accountTokens).values({ + accountId: blockedAccount.id, + name: 'blocked-token', + token: 'sk-blocked-token', + enabled: true, + isDefault: true, + valueStatus: 'ready', + }).returning().get(); + const allowedToken = await db.insert(schema.accountTokens).values({ + accountId: allowedAccount.id, + name: 'allowed-token', + token: 'sk-allowed-token', + enabled: true, + isDefault: true, + valueStatus: 'ready', + }).returning().get(); + + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'claude-sonnet-4-6', + enabled: true, + }).returning().get(); + + const blockedChannel = await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: blockedAccount.id, + tokenId: blockedToken.id, + priority: 0, + weight: 10, + enabled: true, + }).returning().get(); + const allowedChannel = await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: allowedAccount.id, + tokenId: allowedToken.id, + priority: 0, + weight: 10, + enabled: true, + }).returning().get(); + + const router = new TokenRouter(); + const policy: any = { + allowedRouteIds: [route.id], + supportedModels: [], + siteWeightMultipliers: {}, + excludedSiteIds: [], + excludedCredentialRefs: [ + { kind: 'account_token', siteId: site.id, accountId: blockedAccount.id, tokenId: blockedToken.id }, + ], + }; + + const pick = await router.selectChannel('claude-sonnet-4-6', policy); + const decision = await router.explainSelectionForRoute(route.id, 'claude-sonnet-4-6', [], policy); + const blockedCandidate = decision.candidates.find((item) => item.channelId === blockedChannel.id); + const allowedCandidate = decision.candidates.find((item) => item.channelId === allowedChannel.id); + + expect(pick?.channel.id).toBe(allowedChannel.id); + expect(blockedCandidate?.eligible).toBe(false); + expect(blockedCandidate?.reason).toContain('API Key/令牌已被下游密钥排除'); + expect(allowedCandidate?.eligible).toBe(true); + }); + + it('excludes default api key channels by downstream credential refs', async () => { + const site = await db.insert(schema.sites).values({ + name: 'site-default', + url: 'https://default.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const blockedAccount = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'user-blocked', + accessToken: 'access-blocked', + apiToken: 'sk-blocked-default', + status: 'active', + }).returning().get(); + const allowedAccount = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'user-allowed', + accessToken: 'access-allowed', + apiToken: 'sk-allowed-default', + status: 'active', + }).returning().get(); + + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-5-mini', + enabled: true, + }).returning().get(); + + const blockedChannel = await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: blockedAccount.id, + tokenId: null, + priority: 0, + weight: 10, + enabled: true, + }).returning().get(); + const allowedChannel = await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: allowedAccount.id, + tokenId: null, + priority: 0, + weight: 10, + enabled: true, + }).returning().get(); + + const router = new TokenRouter(); + const policy: any = { + allowedRouteIds: [route.id], + supportedModels: [], + siteWeightMultipliers: {}, + excludedSiteIds: [], + excludedCredentialRefs: [ + { kind: 'default_api_key', siteId: site.id, accountId: blockedAccount.id }, + ], + }; + + const pick = await router.selectChannel('gpt-5-mini', policy); + const decision = await router.explainSelectionForRoute(route.id, 'gpt-5-mini', [], policy); + const blockedCandidate = decision.candidates.find((item) => item.channelId === blockedChannel.id); + + expect(pick?.channel.id).toBe(allowedChannel.id); + expect(blockedCandidate?.eligible).toBe(false); + expect(blockedCandidate?.reason).toContain('API Key/令牌已被下游密钥排除'); + }); }); diff --git a/src/server/services/tokenRouter.ts b/src/server/services/tokenRouter.ts index 58ed3cb..2785f4a 100644 --- a/src/server/services/tokenRouter.ts +++ b/src/server/services/tokenRouter.ts @@ -1260,6 +1260,7 @@ type CandidateEligibilityOptions = { bypassSourceModelCheck?: boolean; excludeChannelIds?: number[]; nowIso?: string; + downstreamPolicy?: DownstreamRoutingPolicy; }; type CostSignal = { @@ -1819,6 +1820,7 @@ export class TokenRouter { match, requestedModel, normalizedPreferredChannelId, + downstreamPolicy, excludeChannelIds, ); } @@ -1936,6 +1938,7 @@ export class TokenRouter { bypassSourceModelCheck, excludeChannelIds, nowIso, + downstreamPolicy, }); const recentlyFailed = routeStrategy !== 'round_robin' @@ -2629,6 +2632,7 @@ export class TokenRouter { bypassSourceModelCheck, excludeChannelIds, nowIso, + downstreamPolicy, }).length === 0 )); @@ -2763,6 +2767,7 @@ export class TokenRouter { match: RouteMatch, requestedModel: string, preferredChannelId: number, + downstreamPolicy: DownstreamRoutingPolicy, excludeChannelIds: number[] = [], recordSelection = true, ): Promise { @@ -2782,6 +2787,7 @@ export class TokenRouter { bypassSourceModelCheck, excludeChannelIds, nowIso, + downstreamPolicy, }).length === 0 )); @@ -2891,6 +2897,55 @@ export class TokenRouter { return null; } + private resolveDownstreamExclusionReason( + candidate: RouteChannelCandidate, + downstreamPolicy?: DownstreamRoutingPolicy, + ): string | null { + if (!downstreamPolicy) return null; + + const excludedSiteIds = Array.isArray(downstreamPolicy.excludedSiteIds) + ? downstreamPolicy.excludedSiteIds + : []; + if (excludedSiteIds.includes(candidate.site.id)) { + return '站点已被下游密钥排除'; + } + + const excludedCredentialRefs = Array.isArray(downstreamPolicy.excludedCredentialRefs) + ? downstreamPolicy.excludedCredentialRefs + : []; + if (excludedCredentialRefs.length <= 0) { + return null; + } + + for (const ref of excludedCredentialRefs) { + if (ref.kind === 'account_token') { + if ( + candidate.channel.tokenId === ref.tokenId + && candidate.token?.id === ref.tokenId + && candidate.account.id === ref.accountId + && candidate.site.id === ref.siteId + ) { + return 'API Key/令牌已被下游密钥排除'; + } + continue; + } + + if ( + candidate.channel.tokenId == null + && candidate.account.id === ref.accountId + && candidate.site.id === ref.siteId + ) { + const resolvedTokenValue = this.resolveChannelTokenValue(candidate); + const accountApiToken = candidate.account.apiToken?.trim() || ''; + if (resolvedTokenValue && accountApiToken && resolvedTokenValue === accountApiToken) { + return 'API Key/令牌已被下游密钥排除'; + } + } + } + + return null; + } + private getCandidateEligibilityReasons( candidate: RouteChannelCandidate, options: CandidateEligibilityOptions, @@ -2918,6 +2973,11 @@ export class TokenRouter { reasonParts.push(`站点状态=${candidate.site.status || 'disabled'}`); } + const downstreamExclusionReason = this.resolveDownstreamExclusionReason(candidate, options.downstreamPolicy); + if (downstreamExclusionReason) { + reasonParts.push(downstreamExclusionReason); + } + if (excludeChannelIds.includes(candidate.channel.id)) { reasonParts.push('当前请求已尝试'); } diff --git a/src/web/api.ts b/src/web/api.ts index 250623b..13c5b4a 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -660,6 +660,32 @@ export type OAuthConnectionsResponse = { 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 = { // Sites getSites: () => request('/api/sites'), @@ -673,7 +699,7 @@ export const api = { getSiteAvailableModels: (siteId: number) => request(`/api/sites/${siteId}/available-models`), // 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) }), 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) }), @@ -798,6 +824,10 @@ export const api = { method: 'POST', body: JSON.stringify({}), }) as Promise<{ success: true; quota: OAuthQuotaInfo }>, + refreshOAuthConnectionQuotaBatch: (accountIds: number[]) => request('/api/oauth/connections/quota/refresh-batch', { + method: 'POST', + body: JSON.stringify({ accountIds }), + }) as Promise, rebindOAuthConnection: (accountId: number, data?: { proxyUrl?: string | null }) => request(`/api/oauth/connections/${accountId}/rebind`, { method: 'POST', body: JSON.stringify(data || {}), @@ -805,6 +835,10 @@ export const api = { deleteOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}`, { method: 'DELETE', }) as Promise<{ success: true }>, + importOAuthConnections: (data: Record) => request('/api/oauth/import', { + method: 'POST', + body: JSON.stringify({ data }), + }) as Promise, // Events getEvents: (params?: string) => request(`/api/events${params ? '?' + params : ''}`), diff --git a/src/web/pages/DownstreamKeys.test.tsx b/src/web/pages/DownstreamKeys.test.tsx index d5bd40d..b9c48f2 100644 --- a/src/web/pages/DownstreamKeys.test.tsx +++ b/src/web/pages/DownstreamKeys.test.tsx @@ -9,6 +9,8 @@ const { apiMock } = vi.hoisted(() => ({ getDownstreamApiKeysSummary: vi.fn(), getDownstreamApiKeys: vi.fn(), getRoutesLite: vi.fn(), + getAccounts: vi.fn(), + getAccountTokens: vi.fn(), getDownstreamApiKeyOverview: vi.fn(), getDownstreamApiKeyTrend: vi.fn(), createDownstreamApiKey: vi.fn(), @@ -135,6 +137,50 @@ beforeEach(() => { { id: 11, modelPattern: 'claude-*', displayName: '默认群组', 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({ success: true, item: buildSummaryItem(), @@ -776,4 +822,85 @@ describe('DownstreamKeys page', () => { root?.unmount(); } }); + + it('lazy loads exclusion sources and submits excluded sites and credentials', async () => { + let root!: WebTestRenderer; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + 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(); + } + }); }); diff --git a/src/web/pages/DownstreamKeys.tsx b/src/web/pages/DownstreamKeys.tsx index a2f6a0f..f8438f8 100644 --- a/src/web/pages/DownstreamKeys.tsx +++ b/src/web/pages/DownstreamKeys.tsx @@ -9,6 +9,13 @@ import { useToast } from '../components/Toast.js'; import ModernSelect from '../components/ModernSelect.js'; import { useIsMobile } from '../components/useIsMobile.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 { formatCompactTokens, @@ -20,9 +27,7 @@ import { type Range, type SummaryItem, } from './downstream-keys/shared.js'; -import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js'; -const PROXY_TOKEN_PREFIX = 'sk-'; type Status = 'all' | 'enabled' | 'disabled'; type DownstreamApiKeyItem = { @@ -42,6 +47,8 @@ type DownstreamApiKeyItem = { supportedModels: string[]; allowedRouteIds: number[]; siteWeightMultipliers: Record; + excludedSiteIds: number[]; + excludedCredentialRefs: DownstreamExcludedCredentialRef[]; lastUsedAt: string | null; }; @@ -56,21 +63,6 @@ type RouteSelectorItem = { 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 = | null | { mode: 'single'; item: ManagedItem } @@ -85,7 +77,7 @@ type BatchMetadataForm = { tags: string[]; }; -type DefaultRouteSelections = Pick; +type DefaultRouteSelections = Pick; function toDateTimeLocal(isoString: string | null | undefined): string { if (!isoString) return ''; 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)))]; } +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(); + 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 { return { selectedModels: uniqStrings( @@ -299,7 +326,7 @@ function buildEditorForm( item?: ManagedItem | DownstreamApiKeyItem | null, routeOptions: RouteSelectorItem[] = [], selectAllByDefault = false, -): EditorForm { +): DownstreamKeyEditorForm { const defaultSelections = selectAllByDefault ? buildDefaultRouteSelections(routeOptions) : { selectedModels: [], selectedGroupRouteIds: [] }; @@ -323,6 +350,8 @@ function buildEditorForm( selectedModels: uniqStrings(selectedModels), selectedGroupRouteIds: uniqIds(selectedGroupRouteIds), 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}`; } -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 ( -
-
-
- {tags.map((tag) => ( - - ))} -
- 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 }} - /> -
- {suggestionPool.length > 0 ? ( -
- {suggestionPool.map((tag) => ( - - ))} -
- ) : null} -
- ); -} - function SummaryMetric({ label, 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 ( - - - - - )} - > -
- 支持为每个下游密钥独立配置分组、标签、额度与有效期。高级限制项可按需展开。 -
- -
-
-
名称
- onChange((prev) => ({ ...prev, name: e.target.value }))} placeholder="例如:项目 A / 移动端" style={inputStyle} /> -
-
-
下游密钥
-
- onChange((prev) => ({ ...prev, key: e.target.value }))} - placeholder="sk-..." - style={{ ...inputStyle, flex: 1, minWidth: 0, fontFamily: 'var(--font-mono)' }} - /> - -
-
-
-
主分组
- onChange((prev) => ({ ...prev, groupName: e.target.value }))} - placeholder="例如:VIP / 内部项目 / A组" - list="downstream-group-suggestions" - style={inputStyle} - /> -
-
-
请求额度
- onChange((prev) => ({ ...prev, maxRequests: e.target.value }))} placeholder="留空表示不限" style={inputStyle} /> -
-
-
成本额度
- onChange((prev) => ({ ...prev, maxCost: e.target.value }))} placeholder="留空表示不限" style={inputStyle} /> -
-
-
过期时间
- onChange((prev) => ({ ...prev, expiresAt: e.target.value }))} style={inputStyle} /> -
- -
- -
-
备注说明
-