[codex] add oauth route pools and proxy save flow (#440)
* feat: add oauth route pools and proxy save flow * fix: align oauth route pool schema and rate limits * fix: harden oauth route pool review fixes * fix: address oauth ci regressions * fix: close oauth codeql alerts * fix: harden oauth route unit recovery * fix: split oauth route limiters for codeql * fix: address remaining oauth review comments
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
CREATE TABLE `oauth_route_unit_members` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`unit_id` integer NOT NULL,
|
||||
`account_id` integer NOT NULL,
|
||||
`sort_order` integer DEFAULT 0,
|
||||
`success_count` integer DEFAULT 0,
|
||||
`fail_count` integer DEFAULT 0,
|
||||
`total_latency_ms` integer DEFAULT 0,
|
||||
`total_cost` real DEFAULT 0,
|
||||
`last_used_at` text,
|
||||
`last_selected_at` text,
|
||||
`last_fail_at` text,
|
||||
`consecutive_fail_count` integer DEFAULT 0 NOT NULL,
|
||||
`cooldown_level` integer DEFAULT 0 NOT NULL,
|
||||
`cooldown_until` text,
|
||||
`created_at` text DEFAULT (datetime('now')),
|
||||
`updated_at` text DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (`unit_id`) REFERENCES `oauth_route_units`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `oauth_route_unit_members_unit_account_unique` ON `oauth_route_unit_members` (`unit_id`,`account_id`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `oauth_route_unit_members_account_unique` ON `oauth_route_unit_members` (`account_id`);--> statement-breakpoint
|
||||
CREATE INDEX `oauth_route_unit_members_unit_sort_idx` ON `oauth_route_unit_members` (`unit_id`,`sort_order`);--> statement-breakpoint
|
||||
CREATE INDEX `oauth_route_unit_members_unit_cooldown_idx` ON `oauth_route_unit_members` (`unit_id`,`cooldown_until`);--> statement-breakpoint
|
||||
CREATE TABLE `oauth_route_units` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`site_id` integer NOT NULL,
|
||||
`provider` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`strategy` text DEFAULT 'round_robin' NOT NULL,
|
||||
`enabled` integer DEFAULT true,
|
||||
`created_at` text DEFAULT (datetime('now')),
|
||||
`updated_at` text DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `oauth_route_units_site_provider_idx` ON `oauth_route_units` (`site_id`,`provider`);--> statement-breakpoint
|
||||
CREATE INDEX `oauth_route_units_enabled_idx` ON `oauth_route_units` (`enabled`);--> statement-breakpoint
|
||||
ALTER TABLE `route_channels` ADD `oauth_route_unit_id` integer;--> statement-breakpoint
|
||||
CREATE INDEX `route_channels_oauth_route_unit_id_idx` ON `route_channels` (`oauth_route_unit_id`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -141,6 +141,13 @@
|
||||
"when": 1775397600000,
|
||||
"tag": "0020_downstream_api_key_exclusions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "6",
|
||||
"when": 1775441167045,
|
||||
"tag": "0021_young_shriek",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -27,12 +27,36 @@ const oauthConnectionRebindPayloadSchema = z.object({
|
||||
useSystemProxy: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const oauthConnectionProxyUpdatePayloadSchema = z.object({
|
||||
proxyUrl: z.union([z.string(), z.null()]).optional(),
|
||||
useSystemProxy: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const oauthQuotaBatchRefreshPayloadSchema = z.object({
|
||||
accountIds: z.array(z.number().int().positive()).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const oauthImportPayloadSchema = z.object({
|
||||
data: z.unknown().optional(),
|
||||
items: z.array(z.object({}).passthrough()).optional(),
|
||||
proxyUrl: z.union([z.string(), z.null()]).optional(),
|
||||
useSystemProxy: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const oauthRouteUnitStrategySchema = z.preprocess((value) => {
|
||||
if (typeof value !== 'string') return value;
|
||||
return value.trim().toLowerCase();
|
||||
}, z.enum(['round_robin', 'stick_until_unavailable']));
|
||||
|
||||
const oauthRouteUnitCreatePayloadSchema = z.object({
|
||||
accountIds: z.array(z.number().int().positive()).optional(),
|
||||
name: z.string().optional(),
|
||||
strategy: oauthRouteUnitStrategySchema.optional(),
|
||||
}).passthrough();
|
||||
|
||||
const oauthRouteUnitUpdatePayloadSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
strategy: oauthRouteUnitStrategySchema.optional(),
|
||||
}).passthrough();
|
||||
|
||||
const updateCenterConfigPayloadSchema = z.object({
|
||||
@@ -61,9 +85,12 @@ const updateCenterRollbackPayloadSchema = z.object({
|
||||
export type AuthChangePayload = z.output<typeof authChangePayloadSchema>;
|
||||
export type MonitorConfigPayload = z.output<typeof monitorConfigPayloadSchema>;
|
||||
export type OauthConnectionRebindPayload = z.output<typeof oauthConnectionRebindPayloadSchema>;
|
||||
export type OauthConnectionProxyUpdatePayload = z.output<typeof oauthConnectionProxyUpdatePayloadSchema>;
|
||||
export type OauthImportPayload = z.output<typeof oauthImportPayloadSchema>;
|
||||
export type OauthManualCallbackPayload = z.output<typeof oauthManualCallbackPayloadSchema>;
|
||||
export type OauthQuotaBatchRefreshPayload = z.output<typeof oauthQuotaBatchRefreshPayloadSchema>;
|
||||
export type OauthRouteUnitCreatePayload = z.output<typeof oauthRouteUnitCreatePayloadSchema>;
|
||||
export type OauthRouteUnitUpdatePayload = z.output<typeof oauthRouteUnitUpdatePayloadSchema>;
|
||||
export type OauthStartPayload = z.output<typeof oauthStartPayloadSchema>;
|
||||
export type UpdateCenterConfigPayload = z.output<typeof updateCenterConfigPayloadSchema>;
|
||||
export type UpdateCenterDeployPayload = z.output<typeof updateCenterDeployPayloadSchema>;
|
||||
@@ -103,12 +130,21 @@ function formatSupportRoutePayloadError(error: z.ZodError): string {
|
||||
if (firstPath === 'accountIds') {
|
||||
return 'Invalid accountIds. Expected positive number array.';
|
||||
}
|
||||
if (firstPath === 'name') {
|
||||
return 'Invalid name. Expected string.';
|
||||
}
|
||||
if (firstPath === 'strategy') {
|
||||
return 'Invalid strategy. Expected round_robin/stick_until_unavailable.';
|
||||
}
|
||||
if (firstPath === 'callbackUrl') {
|
||||
return 'Invalid callbackUrl. Expected string.';
|
||||
}
|
||||
if (firstPath === 'data') {
|
||||
return 'Invalid data. Expected object.';
|
||||
}
|
||||
if (firstPath === 'items') {
|
||||
return 'Invalid items. Expected object array.';
|
||||
}
|
||||
if (firstPath === 'enabled') {
|
||||
return 'Invalid enabled. Expected boolean.';
|
||||
}
|
||||
@@ -196,6 +232,11 @@ export function parseOauthConnectionRebindPayload(input: unknown):
|
||||
return parseSupportRoutePayload(oauthConnectionRebindPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseOauthConnectionProxyUpdatePayload(input: unknown):
|
||||
{ success: true; data: OauthConnectionProxyUpdatePayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(oauthConnectionProxyUpdatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseOauthQuotaBatchRefreshPayload(input: unknown):
|
||||
{ success: true; data: OauthQuotaBatchRefreshPayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(oauthQuotaBatchRefreshPayloadSchema, input);
|
||||
@@ -206,6 +247,16 @@ export function parseOauthImportPayload(input: unknown):
|
||||
return parseSupportRoutePayload(oauthImportPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseOauthRouteUnitCreatePayload(input: unknown):
|
||||
{ success: true; data: OauthRouteUnitCreatePayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(oauthRouteUnitCreatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseOauthRouteUnitUpdatePayload(input: unknown):
|
||||
{ success: true; data: OauthRouteUnitUpdatePayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(oauthRouteUnitUpdatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseUpdateCenterConfigPayload(input: unknown):
|
||||
{ success: true; data: UpdateCenterConfigPayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(updateCenterConfigPayloadSchema, input);
|
||||
|
||||
@@ -5,13 +5,15 @@ CREATE TABLE IF NOT EXISTS `checkin_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIM
|
||||
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 `oauth_route_units` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `provider` TEXT NOT NULL, `name` TEXT NOT NULL, `strategy` VARCHAR(191) NOT NULL DEFAULT 'round_robin', `enabled` BOOLEAN DEFAULT true, `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')), FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `oauth_route_unit_members` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `unit_id` INT NOT NULL, `account_id` INT NOT NULL, `sort_order` INT DEFAULT 0, `success_count` INT DEFAULT 0, `fail_count` INT DEFAULT 0, `total_latency_ms` INT DEFAULT 0, `total_cost` DOUBLE DEFAULT 0, `last_used_at` VARCHAR(191), `last_selected_at` VARCHAR(191), `last_fail_at` VARCHAR(191), `consecutive_fail_count` INT NOT NULL DEFAULT 0, `cooldown_level` INT NOT NULL DEFAULT 0, `cooldown_until` 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')), FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE, FOREIGN KEY (`unit_id`) REFERENCES `oauth_route_units`(`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_attempts` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `trace_id` INT NOT NULL, `attempt_index` INT NOT NULL, `endpoint` TEXT NOT NULL, `request_path` TEXT NOT NULL, `target_url` TEXT NOT NULL, `runtime_executor` TEXT, `request_headers_json` JSON, `request_body_json` JSON, `response_status` INT, `response_headers_json` JSON, `response_body_json` JSON, `raw_error_text` TEXT, `recover_applied` BOOLEAN DEFAULT false, `downgrade_decision` BOOLEAN DEFAULT false, `downgrade_reason` TEXT, `memory_write_json` JSON, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`trace_id`) REFERENCES `proxy_debug_traces`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `proxy_files` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `public_id` TEXT NOT NULL, `owner_type` TEXT NOT NULL, `owner_id` TEXT NOT NULL, `filename` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `purpose` TEXT, `byte_size` INT NOT NULL, `sha256` TEXT NOT NULL, `content_base64` TEXT NOT NULL, `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')), `deleted_at` VARCHAR(191));
|
||||
CREATE TABLE IF NOT EXISTS `proxy_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `route_id` INT, `channel_id` INT, `account_id` INT, `model_requested` TEXT, `model_actual` TEXT, `status` TEXT, `http_status` INT, `latency_ms` INT, `prompt_tokens` INT, `completion_tokens` INT, `total_tokens` INT, `estimated_cost` DOUBLE, `error_message` TEXT, `retry_count` INT DEFAULT 0, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `billing_details` JSON, `downstream_api_key_id` INT, `client_family` TEXT, `client_app_id` TEXT, `client_app_name` TEXT, `client_confidence` TEXT, `is_stream` BOOLEAN, `first_byte_latency_ms` INT);
|
||||
CREATE TABLE IF NOT EXISTS `proxy_video_tasks` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `public_id` TEXT NOT NULL, `upstream_video_id` TEXT NOT NULL, `site_url` TEXT NOT NULL, `token_value` TEXT NOT NULL, `requested_model` TEXT, `actual_model` TEXT, `channel_id` INT, `account_id` INT, `status_snapshot` JSON, `upstream_response_meta` JSON, `last_upstream_status` INT, `last_polled_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')));
|
||||
CREATE TABLE IF NOT EXISTS `token_routes` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `model_pattern` TEXT NOT NULL, `model_mapping` JSON, `enabled` BOOLEAN DEFAULT true, `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')), `display_name` TEXT, `display_icon` TEXT, `decision_snapshot` JSON, `decision_refreshed_at` VARCHAR(191), `routing_strategy` VARCHAR(191) DEFAULT 'weighted', `route_mode` VARCHAR(191) DEFAULT 'pattern');
|
||||
CREATE TABLE IF NOT EXISTS `route_channels` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `route_id` INT NOT NULL, `account_id` INT NOT NULL, `token_id` INT, `source_model` TEXT, `priority` INT DEFAULT 0, `weight` INT DEFAULT 10, `enabled` BOOLEAN DEFAULT true, `manual_override` BOOLEAN DEFAULT false, `success_count` INT DEFAULT 0, `fail_count` INT DEFAULT 0, `total_latency_ms` INT DEFAULT 0, `total_cost` DOUBLE DEFAULT 0, `last_used_at` VARCHAR(191), `last_selected_at` VARCHAR(191), `last_fail_at` VARCHAR(191), `consecutive_fail_count` INT NOT NULL DEFAULT 0, `cooldown_level` INT NOT NULL DEFAULT 0, `cooldown_until` VARCHAR(191), FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE, FOREIGN KEY (`route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE, FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON DELETE SET NULL);
|
||||
CREATE TABLE IF NOT EXISTS `route_channels` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `route_id` INT NOT NULL, `account_id` INT NOT NULL, `token_id` INT, `source_model` TEXT, `priority` INT DEFAULT 0, `weight` INT DEFAULT 10, `enabled` BOOLEAN DEFAULT true, `manual_override` BOOLEAN DEFAULT false, `success_count` INT DEFAULT 0, `fail_count` INT DEFAULT 0, `total_latency_ms` INT DEFAULT 0, `total_cost` DOUBLE DEFAULT 0, `last_used_at` VARCHAR(191), `last_selected_at` VARCHAR(191), `last_fail_at` VARCHAR(191), `consecutive_fail_count` INT NOT NULL DEFAULT 0, `cooldown_level` INT NOT NULL DEFAULT 0, `cooldown_until` VARCHAR(191), `oauth_route_unit_id` INT, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE, FOREIGN KEY (`route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE, FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON DELETE SET NULL);
|
||||
CREATE TABLE IF NOT EXISTS `route_group_sources` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `group_route_id` INT NOT NULL, `source_route_id` INT NOT NULL, FOREIGN KEY (`group_route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE, FOREIGN KEY (`source_route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `settings` (`key` VARCHAR(191) NOT NULL PRIMARY KEY, `value` TEXT);
|
||||
CREATE TABLE IF NOT EXISTS `site_announcements` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `platform` TEXT NOT NULL, `source_key` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `level` VARCHAR(191) NOT NULL DEFAULT 'info', `source_url` TEXT, `starts_at` VARCHAR(191), `ends_at` VARCHAR(191), `upstream_created_at` VARCHAR(191), `upstream_updated_at` VARCHAR(191), `first_seen_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `last_seen_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `read_at` VARCHAR(191), `dismissed_at` VARCHAR(191), `raw_payload` TEXT, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
|
||||
@@ -20,6 +22,8 @@ CREATE TABLE IF NOT EXISTS `site_disabled_models` (`id` INT AUTO_INCREMENT NOT N
|
||||
CREATE TABLE IF NOT EXISTS `token_model_availability` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `token_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')), FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON DELETE CASCADE);
|
||||
CREATE UNIQUE INDEX `downstream_api_keys_key_unique` ON `downstream_api_keys` (`key`(191));
|
||||
CREATE UNIQUE INDEX `model_availability_account_model_unique` ON `model_availability` (`account_id`, `model_name`(191));
|
||||
CREATE UNIQUE INDEX `oauth_route_unit_members_account_unique` ON `oauth_route_unit_members` (`account_id`);
|
||||
CREATE UNIQUE INDEX `oauth_route_unit_members_unit_account_unique` ON `oauth_route_unit_members` (`unit_id`, `account_id`);
|
||||
CREATE UNIQUE INDEX `proxy_debug_attempts_trace_attempt_unique` ON `proxy_debug_attempts` (`trace_id`, `attempt_index`);
|
||||
CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191));
|
||||
CREATE UNIQUE INDEX `proxy_video_tasks_public_id_unique` ON `proxy_video_tasks` (`public_id`(191));
|
||||
@@ -48,6 +52,10 @@ CREATE INDEX `events_read_created_at_idx` ON `events` (`read`, `created_at`);
|
||||
CREATE INDEX `events_type_created_at_idx` ON `events` (`type`(191), `created_at`);
|
||||
CREATE INDEX `model_availability_account_available_idx` ON `model_availability` (`account_id`, `available`);
|
||||
CREATE INDEX `model_availability_model_name_idx` ON `model_availability` (`model_name`(191));
|
||||
CREATE INDEX `oauth_route_unit_members_unit_cooldown_idx` ON `oauth_route_unit_members` (`unit_id`, `cooldown_until`);
|
||||
CREATE INDEX `oauth_route_unit_members_unit_sort_idx` ON `oauth_route_unit_members` (`unit_id`, `sort_order`);
|
||||
CREATE INDEX `oauth_route_units_enabled_idx` ON `oauth_route_units` (`enabled`);
|
||||
CREATE INDEX `oauth_route_units_site_provider_idx` ON `oauth_route_units` (`site_id`, `provider`(191));
|
||||
CREATE INDEX `proxy_debug_attempts_trace_created_at_idx` ON `proxy_debug_attempts` (`trace_id`, `created_at`);
|
||||
CREATE INDEX `proxy_debug_traces_created_at_idx` ON `proxy_debug_traces` (`created_at`);
|
||||
CREATE INDEX `proxy_debug_traces_final_status_created_at_idx` ON `proxy_debug_traces` (`final_status`(191), `created_at`);
|
||||
@@ -64,6 +72,7 @@ CREATE INDEX `proxy_logs_status_created_at_idx` ON `proxy_logs` (`status`(191),
|
||||
CREATE INDEX `proxy_video_tasks_created_at_idx` ON `proxy_video_tasks` (`created_at`);
|
||||
CREATE INDEX `proxy_video_tasks_upstream_video_id_idx` ON `proxy_video_tasks` (`upstream_video_id`(191));
|
||||
CREATE INDEX `route_channels_account_id_idx` ON `route_channels` (`account_id`);
|
||||
CREATE INDEX `route_channels_oauth_route_unit_id_idx` ON `route_channels` (`oauth_route_unit_id`);
|
||||
CREATE INDEX `route_channels_route_enabled_idx` ON `route_channels` (`route_id`, `enabled`);
|
||||
CREATE INDEX `route_channels_route_id_idx` ON `route_channels` (`route_id`);
|
||||
CREATE INDEX `route_channels_route_token_idx` ON `route_channels` (`route_id`, `token_id`);
|
||||
|
||||
@@ -1 +1,10 @@
|
||||
-- no schema changes detected for mysql
|
||||
CREATE TABLE IF NOT EXISTS `oauth_route_units` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `provider` TEXT NOT NULL, `name` TEXT NOT NULL, `strategy` VARCHAR(191) NOT NULL DEFAULT 'round_robin', `enabled` BOOLEAN DEFAULT true, `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')), FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `oauth_route_unit_members` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `unit_id` INT NOT NULL, `account_id` INT NOT NULL, `sort_order` INT DEFAULT 0, `success_count` INT DEFAULT 0, `fail_count` INT DEFAULT 0, `total_latency_ms` INT DEFAULT 0, `total_cost` DOUBLE DEFAULT 0, `last_used_at` VARCHAR(191), `last_selected_at` VARCHAR(191), `last_fail_at` VARCHAR(191), `consecutive_fail_count` INT NOT NULL DEFAULT 0, `cooldown_level` INT NOT NULL DEFAULT 0, `cooldown_until` 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')), FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE, FOREIGN KEY (`unit_id`) REFERENCES `oauth_route_units`(`id`) ON DELETE CASCADE);
|
||||
ALTER TABLE `route_channels` ADD COLUMN `oauth_route_unit_id` INT;
|
||||
CREATE UNIQUE INDEX `oauth_route_unit_members_account_unique` ON `oauth_route_unit_members` (`account_id`);
|
||||
CREATE UNIQUE INDEX `oauth_route_unit_members_unit_account_unique` ON `oauth_route_unit_members` (`unit_id`, `account_id`);
|
||||
CREATE INDEX `oauth_route_unit_members_unit_cooldown_idx` ON `oauth_route_unit_members` (`unit_id`, `cooldown_until`);
|
||||
CREATE INDEX `oauth_route_unit_members_unit_sort_idx` ON `oauth_route_unit_members` (`unit_id`, `sort_order`);
|
||||
CREATE INDEX `oauth_route_units_enabled_idx` ON `oauth_route_units` (`enabled`);
|
||||
CREATE INDEX `oauth_route_units_site_provider_idx` ON `oauth_route_units` (`site_id`, `provider`(191));
|
||||
CREATE INDEX `route_channels_oauth_route_unit_id_idx` ON `route_channels` (`oauth_route_unit_id`);
|
||||
|
||||
@@ -5,13 +5,15 @@ CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS
|
||||
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 "oauth_route_units" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "provider" TEXT NOT NULL, "name" TEXT NOT NULL, "strategy" TEXT NOT NULL DEFAULT 'round_robin', "enabled" BOOLEAN DEFAULT true, "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'), FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "oauth_route_unit_members" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "unit_id" INTEGER NOT NULL, "account_id" INTEGER NOT NULL, "sort_order" INTEGER DEFAULT 0, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" 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'), FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE, FOREIGN KEY ("unit_id") REFERENCES "oauth_route_units"("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_attempts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "trace_id" INTEGER NOT NULL, "attempt_index" INTEGER NOT NULL, "endpoint" TEXT NOT NULL, "request_path" TEXT NOT NULL, "target_url" TEXT NOT NULL, "runtime_executor" TEXT, "request_headers_json" JSONB, "request_body_json" JSONB, "response_status" INTEGER, "response_headers_json" JSONB, "response_body_json" JSONB, "raw_error_text" TEXT, "recover_applied" BOOLEAN DEFAULT false, "downgrade_decision" BOOLEAN DEFAULT false, "downgrade_reason" TEXT, "memory_write_json" JSONB, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("trace_id") REFERENCES "proxy_debug_traces"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "public_id" TEXT NOT NULL, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "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'), "deleted_at" TEXT);
|
||||
CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "billing_details" JSONB, "downstream_api_key_id" INTEGER, "client_family" TEXT, "client_app_id" TEXT, "client_app_name" TEXT, "client_confidence" TEXT, "is_stream" BOOLEAN, "first_byte_latency_ms" INTEGER);
|
||||
CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "public_id" TEXT NOT NULL, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" JSONB, "upstream_response_meta" JSONB, "last_upstream_status" INTEGER, "last_polled_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'));
|
||||
CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "model_pattern" TEXT NOT NULL, "model_mapping" JSONB, "enabled" BOOLEAN DEFAULT true, "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'), "display_name" TEXT, "display_icon" TEXT, "decision_snapshot" JSONB, "decision_refreshed_at" TEXT, "routing_strategy" TEXT DEFAULT 'weighted', "route_mode" TEXT DEFAULT 'pattern');
|
||||
CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "route_id" INTEGER NOT NULL, "account_id" INTEGER NOT NULL, "token_id" INTEGER, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT true, "manual_override" BOOLEAN DEFAULT false, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" TEXT, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE, FOREIGN KEY ("route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, FOREIGN KEY ("token_id") REFERENCES "account_tokens"("id") ON DELETE SET NULL);
|
||||
CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "route_id" INTEGER NOT NULL, "account_id" INTEGER NOT NULL, "token_id" INTEGER, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT true, "manual_override" BOOLEAN DEFAULT false, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" TEXT, "oauth_route_unit_id" INTEGER, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE, FOREIGN KEY ("route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, FOREIGN KEY ("token_id") REFERENCES "account_tokens"("id") ON DELETE SET NULL);
|
||||
CREATE TABLE IF NOT EXISTS "route_group_sources" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "group_route_id" INTEGER NOT NULL, "source_route_id" INTEGER NOT NULL, FOREIGN KEY ("group_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, FOREIGN KEY ("source_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT NOT NULL PRIMARY KEY, "value" TEXT);
|
||||
CREATE TABLE IF NOT EXISTS "site_announcements" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "platform" TEXT NOT NULL, "source_key" TEXT NOT NULL, "title" TEXT NOT NULL, "content" TEXT NOT NULL, "level" TEXT NOT NULL DEFAULT 'info', "source_url" TEXT, "starts_at" TEXT, "ends_at" TEXT, "upstream_created_at" TEXT, "upstream_updated_at" TEXT, "first_seen_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "last_seen_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "read_at" TEXT, "dismissed_at" TEXT, "raw_payload" TEXT, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
|
||||
@@ -20,6 +22,8 @@ CREATE TABLE IF NOT EXISTS "site_disabled_models" ("id" INTEGER GENERATED BY DEF
|
||||
CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "token_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'), FOREIGN KEY ("token_id") REFERENCES "account_tokens"("id") ON DELETE CASCADE);
|
||||
CREATE UNIQUE INDEX "downstream_api_keys_key_unique" ON "downstream_api_keys" ("key");
|
||||
CREATE UNIQUE INDEX "model_availability_account_model_unique" ON "model_availability" ("account_id", "model_name");
|
||||
CREATE UNIQUE INDEX "oauth_route_unit_members_account_unique" ON "oauth_route_unit_members" ("account_id");
|
||||
CREATE UNIQUE INDEX "oauth_route_unit_members_unit_account_unique" ON "oauth_route_unit_members" ("unit_id", "account_id");
|
||||
CREATE UNIQUE INDEX "proxy_debug_attempts_trace_attempt_unique" ON "proxy_debug_attempts" ("trace_id", "attempt_index");
|
||||
CREATE UNIQUE INDEX "proxy_files_public_id_unique" ON "proxy_files" ("public_id");
|
||||
CREATE UNIQUE INDEX "proxy_video_tasks_public_id_unique" ON "proxy_video_tasks" ("public_id");
|
||||
@@ -48,6 +52,10 @@ CREATE INDEX "events_read_created_at_idx" ON "events" ("read", "created_at");
|
||||
CREATE INDEX "events_type_created_at_idx" ON "events" ("type", "created_at");
|
||||
CREATE INDEX "model_availability_account_available_idx" ON "model_availability" ("account_id", "available");
|
||||
CREATE INDEX "model_availability_model_name_idx" ON "model_availability" ("model_name");
|
||||
CREATE INDEX "oauth_route_unit_members_unit_cooldown_idx" ON "oauth_route_unit_members" ("unit_id", "cooldown_until");
|
||||
CREATE INDEX "oauth_route_unit_members_unit_sort_idx" ON "oauth_route_unit_members" ("unit_id", "sort_order");
|
||||
CREATE INDEX "oauth_route_units_enabled_idx" ON "oauth_route_units" ("enabled");
|
||||
CREATE INDEX "oauth_route_units_site_provider_idx" ON "oauth_route_units" ("site_id", "provider");
|
||||
CREATE INDEX "proxy_debug_attempts_trace_created_at_idx" ON "proxy_debug_attempts" ("trace_id", "created_at");
|
||||
CREATE INDEX "proxy_debug_traces_created_at_idx" ON "proxy_debug_traces" ("created_at");
|
||||
CREATE INDEX "proxy_debug_traces_final_status_created_at_idx" ON "proxy_debug_traces" ("final_status", "created_at");
|
||||
@@ -64,6 +72,7 @@ CREATE INDEX "proxy_logs_status_created_at_idx" ON "proxy_logs" ("status", "crea
|
||||
CREATE INDEX "proxy_video_tasks_created_at_idx" ON "proxy_video_tasks" ("created_at");
|
||||
CREATE INDEX "proxy_video_tasks_upstream_video_id_idx" ON "proxy_video_tasks" ("upstream_video_id");
|
||||
CREATE INDEX "route_channels_account_id_idx" ON "route_channels" ("account_id");
|
||||
CREATE INDEX "route_channels_oauth_route_unit_id_idx" ON "route_channels" ("oauth_route_unit_id");
|
||||
CREATE INDEX "route_channels_route_enabled_idx" ON "route_channels" ("route_id", "enabled");
|
||||
CREATE INDEX "route_channels_route_id_idx" ON "route_channels" ("route_id");
|
||||
CREATE INDEX "route_channels_route_token_idx" ON "route_channels" ("route_id", "token_id");
|
||||
|
||||
@@ -1 +1,10 @@
|
||||
-- no schema changes detected for postgres
|
||||
CREATE TABLE IF NOT EXISTS "oauth_route_units" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "provider" TEXT NOT NULL, "name" TEXT NOT NULL, "strategy" TEXT NOT NULL DEFAULT 'round_robin', "enabled" BOOLEAN DEFAULT true, "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'), FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "oauth_route_unit_members" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "unit_id" INTEGER NOT NULL, "account_id" INTEGER NOT NULL, "sort_order" INTEGER DEFAULT 0, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" 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'), FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE, FOREIGN KEY ("unit_id") REFERENCES "oauth_route_units"("id") ON DELETE CASCADE);
|
||||
ALTER TABLE "route_channels" ADD COLUMN "oauth_route_unit_id" INTEGER;
|
||||
CREATE UNIQUE INDEX "oauth_route_unit_members_account_unique" ON "oauth_route_unit_members" ("account_id");
|
||||
CREATE UNIQUE INDEX "oauth_route_unit_members_unit_account_unique" ON "oauth_route_unit_members" ("unit_id", "account_id");
|
||||
CREATE INDEX "oauth_route_unit_members_unit_cooldown_idx" ON "oauth_route_unit_members" ("unit_id", "cooldown_until");
|
||||
CREATE INDEX "oauth_route_unit_members_unit_sort_idx" ON "oauth_route_unit_members" ("unit_id", "sort_order");
|
||||
CREATE INDEX "oauth_route_units_enabled_idx" ON "oauth_route_units" ("enabled");
|
||||
CREATE INDEX "oauth_route_units_site_provider_idx" ON "oauth_route_units" ("site_id", "provider");
|
||||
CREATE INDEX "route_channels_oauth_route_unit_id_idx" ON "route_channels" ("oauth_route_unit_id");
|
||||
|
||||
@@ -474,6 +474,158 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"oauth_route_unit_members": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"logicalType": "integer",
|
||||
"notNull": true,
|
||||
"defaultValue": null,
|
||||
"primaryKey": true
|
||||
},
|
||||
"unit_id": {
|
||||
"logicalType": "integer",
|
||||
"notNull": true,
|
||||
"defaultValue": null,
|
||||
"primaryKey": false
|
||||
},
|
||||
"account_id": {
|
||||
"logicalType": "integer",
|
||||
"notNull": true,
|
||||
"defaultValue": null,
|
||||
"primaryKey": false
|
||||
},
|
||||
"sort_order": {
|
||||
"logicalType": "integer",
|
||||
"notNull": false,
|
||||
"defaultValue": "0",
|
||||
"primaryKey": false
|
||||
},
|
||||
"success_count": {
|
||||
"logicalType": "integer",
|
||||
"notNull": false,
|
||||
"defaultValue": "0",
|
||||
"primaryKey": false
|
||||
},
|
||||
"fail_count": {
|
||||
"logicalType": "integer",
|
||||
"notNull": false,
|
||||
"defaultValue": "0",
|
||||
"primaryKey": false
|
||||
},
|
||||
"total_latency_ms": {
|
||||
"logicalType": "integer",
|
||||
"notNull": false,
|
||||
"defaultValue": "0",
|
||||
"primaryKey": false
|
||||
},
|
||||
"total_cost": {
|
||||
"logicalType": "real",
|
||||
"notNull": false,
|
||||
"defaultValue": "0",
|
||||
"primaryKey": false
|
||||
},
|
||||
"last_used_at": {
|
||||
"logicalType": "datetime",
|
||||
"notNull": false,
|
||||
"defaultValue": null,
|
||||
"primaryKey": false
|
||||
},
|
||||
"last_selected_at": {
|
||||
"logicalType": "datetime",
|
||||
"notNull": false,
|
||||
"defaultValue": null,
|
||||
"primaryKey": false
|
||||
},
|
||||
"last_fail_at": {
|
||||
"logicalType": "datetime",
|
||||
"notNull": false,
|
||||
"defaultValue": null,
|
||||
"primaryKey": false
|
||||
},
|
||||
"consecutive_fail_count": {
|
||||
"logicalType": "integer",
|
||||
"notNull": true,
|
||||
"defaultValue": "0",
|
||||
"primaryKey": false
|
||||
},
|
||||
"cooldown_level": {
|
||||
"logicalType": "integer",
|
||||
"notNull": true,
|
||||
"defaultValue": "0",
|
||||
"primaryKey": false
|
||||
},
|
||||
"cooldown_until": {
|
||||
"logicalType": "datetime",
|
||||
"notNull": false,
|
||||
"defaultValue": null,
|
||||
"primaryKey": false
|
||||
},
|
||||
"created_at": {
|
||||
"logicalType": "datetime",
|
||||
"notNull": false,
|
||||
"defaultValue": "datetime('now')",
|
||||
"primaryKey": false
|
||||
},
|
||||
"updated_at": {
|
||||
"logicalType": "datetime",
|
||||
"notNull": false,
|
||||
"defaultValue": "datetime('now')",
|
||||
"primaryKey": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"oauth_route_units": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"logicalType": "integer",
|
||||
"notNull": true,
|
||||
"defaultValue": null,
|
||||
"primaryKey": true
|
||||
},
|
||||
"site_id": {
|
||||
"logicalType": "integer",
|
||||
"notNull": true,
|
||||
"defaultValue": null,
|
||||
"primaryKey": false
|
||||
},
|
||||
"provider": {
|
||||
"logicalType": "text",
|
||||
"notNull": true,
|
||||
"defaultValue": null,
|
||||
"primaryKey": false
|
||||
},
|
||||
"name": {
|
||||
"logicalType": "text",
|
||||
"notNull": true,
|
||||
"defaultValue": null,
|
||||
"primaryKey": false
|
||||
},
|
||||
"strategy": {
|
||||
"logicalType": "text",
|
||||
"notNull": true,
|
||||
"defaultValue": "'round_robin'",
|
||||
"primaryKey": false
|
||||
},
|
||||
"enabled": {
|
||||
"logicalType": "boolean",
|
||||
"notNull": false,
|
||||
"defaultValue": "true",
|
||||
"primaryKey": false
|
||||
},
|
||||
"created_at": {
|
||||
"logicalType": "datetime",
|
||||
"notNull": false,
|
||||
"defaultValue": "datetime('now')",
|
||||
"primaryKey": false
|
||||
},
|
||||
"updated_at": {
|
||||
"logicalType": "datetime",
|
||||
"notNull": false,
|
||||
"defaultValue": "datetime('now')",
|
||||
"primaryKey": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"proxy_debug_attempts": {
|
||||
"columns": {
|
||||
"id": {
|
||||
@@ -1185,6 +1337,12 @@
|
||||
"notNull": false,
|
||||
"defaultValue": null,
|
||||
"primaryKey": false
|
||||
},
|
||||
"oauth_route_unit_id": {
|
||||
"logicalType": "integer",
|
||||
"notNull": false,
|
||||
"defaultValue": null,
|
||||
"primaryKey": false
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1819,6 +1977,58 @@
|
||||
],
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"name": "oauth_route_unit_members_account_unique",
|
||||
"table": "oauth_route_unit_members",
|
||||
"columns": [
|
||||
"account_id"
|
||||
],
|
||||
"unique": true
|
||||
},
|
||||
{
|
||||
"name": "oauth_route_unit_members_unit_account_unique",
|
||||
"table": "oauth_route_unit_members",
|
||||
"columns": [
|
||||
"unit_id",
|
||||
"account_id"
|
||||
],
|
||||
"unique": true
|
||||
},
|
||||
{
|
||||
"name": "oauth_route_unit_members_unit_cooldown_idx",
|
||||
"table": "oauth_route_unit_members",
|
||||
"columns": [
|
||||
"unit_id",
|
||||
"cooldown_until"
|
||||
],
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"name": "oauth_route_unit_members_unit_sort_idx",
|
||||
"table": "oauth_route_unit_members",
|
||||
"columns": [
|
||||
"unit_id",
|
||||
"sort_order"
|
||||
],
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"name": "oauth_route_units_enabled_idx",
|
||||
"table": "oauth_route_units",
|
||||
"columns": [
|
||||
"enabled"
|
||||
],
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"name": "oauth_route_units_site_provider_idx",
|
||||
"table": "oauth_route_units",
|
||||
"columns": [
|
||||
"site_id",
|
||||
"provider"
|
||||
],
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"name": "proxy_debug_attempts_trace_attempt_unique",
|
||||
"table": "proxy_debug_attempts",
|
||||
@@ -1984,6 +2194,14 @@
|
||||
],
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"name": "route_channels_oauth_route_unit_id_idx",
|
||||
"table": "route_channels",
|
||||
"columns": [
|
||||
"oauth_route_unit_id"
|
||||
],
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"name": "route_channels_route_enabled_idx",
|
||||
"table": "route_channels",
|
||||
@@ -2190,6 +2408,21 @@
|
||||
"model_name"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "oauth_route_unit_members_account_unique",
|
||||
"table": "oauth_route_unit_members",
|
||||
"columns": [
|
||||
"account_id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "oauth_route_unit_members_unit_account_unique",
|
||||
"table": "oauth_route_unit_members",
|
||||
"columns": [
|
||||
"unit_id",
|
||||
"account_id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "proxy_debug_attempts_trace_attempt_unique",
|
||||
"table": "proxy_debug_attempts",
|
||||
@@ -2306,6 +2539,39 @@
|
||||
],
|
||||
"onDelete": "CASCADE"
|
||||
},
|
||||
{
|
||||
"table": "oauth_route_unit_members",
|
||||
"columns": [
|
||||
"account_id"
|
||||
],
|
||||
"referencedTable": "accounts",
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "CASCADE"
|
||||
},
|
||||
{
|
||||
"table": "oauth_route_unit_members",
|
||||
"columns": [
|
||||
"unit_id"
|
||||
],
|
||||
"referencedTable": "oauth_route_units",
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "CASCADE"
|
||||
},
|
||||
{
|
||||
"table": "oauth_route_units",
|
||||
"columns": [
|
||||
"site_id"
|
||||
],
|
||||
"referencedTable": "sites",
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "CASCADE"
|
||||
},
|
||||
{
|
||||
"table": "proxy_debug_attempts",
|
||||
"columns": [
|
||||
|
||||
@@ -167,11 +167,50 @@ export const routeGroupSources = sqliteTable('route_group_sources', {
|
||||
sourceRouteIdx: index('route_group_sources_source_route_id_idx').on(table.sourceRouteId),
|
||||
}));
|
||||
|
||||
export const oauthRouteUnits = sqliteTable('oauth_route_units', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
siteId: integer('site_id').notNull().references(() => sites.id, { onDelete: 'cascade' }),
|
||||
provider: text('provider').notNull(),
|
||||
name: text('name').notNull(),
|
||||
strategy: text('strategy').notNull().default('round_robin'),
|
||||
enabled: integer('enabled', { mode: 'boolean' }).default(true),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
siteProviderIdx: index('oauth_route_units_site_provider_idx').on(table.siteId, table.provider),
|
||||
enabledIdx: index('oauth_route_units_enabled_idx').on(table.enabled),
|
||||
}));
|
||||
|
||||
export const oauthRouteUnitMembers = sqliteTable('oauth_route_unit_members', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
unitId: integer('unit_id').notNull().references(() => oauthRouteUnits.id, { onDelete: 'cascade' }),
|
||||
accountId: integer('account_id').notNull().references(() => accounts.id, { onDelete: 'cascade' }),
|
||||
sortOrder: integer('sort_order').default(0),
|
||||
successCount: integer('success_count').default(0),
|
||||
failCount: integer('fail_count').default(0),
|
||||
totalLatencyMs: integer('total_latency_ms').default(0),
|
||||
totalCost: real('total_cost').default(0),
|
||||
lastUsedAt: text('last_used_at'),
|
||||
lastSelectedAt: text('last_selected_at'),
|
||||
lastFailAt: text('last_fail_at'),
|
||||
consecutiveFailCount: integer('consecutive_fail_count').notNull().default(0),
|
||||
cooldownLevel: integer('cooldown_level').notNull().default(0),
|
||||
cooldownUntil: text('cooldown_until'),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
unitAccountUnique: uniqueIndex('oauth_route_unit_members_unit_account_unique').on(table.unitId, table.accountId),
|
||||
accountUnique: uniqueIndex('oauth_route_unit_members_account_unique').on(table.accountId),
|
||||
unitSortIdx: index('oauth_route_unit_members_unit_sort_idx').on(table.unitId, table.sortOrder),
|
||||
unitCooldownIdx: index('oauth_route_unit_members_unit_cooldown_idx').on(table.unitId, table.cooldownUntil),
|
||||
}));
|
||||
|
||||
export const routeChannels = sqliteTable('route_channels', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
routeId: integer('route_id').notNull().references(() => tokenRoutes.id, { onDelete: 'cascade' }),
|
||||
accountId: integer('account_id').notNull().references(() => accounts.id, { onDelete: 'cascade' }),
|
||||
tokenId: integer('token_id').references(() => accountTokens.id, { onDelete: 'set null' }),
|
||||
oauthRouteUnitId: integer('oauth_route_unit_id'),
|
||||
sourceModel: text('source_model'),
|
||||
priority: integer('priority').default(0),
|
||||
weight: integer('weight').default(10),
|
||||
@@ -191,6 +230,7 @@ export const routeChannels = sqliteTable('route_channels', {
|
||||
routeIdIdx: index('route_channels_route_id_idx').on(table.routeId),
|
||||
accountIdIdx: index('route_channels_account_id_idx').on(table.accountId),
|
||||
tokenIdIdx: index('route_channels_token_id_idx').on(table.tokenId),
|
||||
oauthRouteUnitIdx: index('route_channels_oauth_route_unit_id_idx').on(table.oauthRouteUnitId),
|
||||
routeEnabledIdx: index('route_channels_route_enabled_idx').on(table.routeId, table.enabled),
|
||||
routeTokenIdx: index('route_channels_route_token_idx').on(table.routeId, table.tokenId),
|
||||
}));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+213
-13
@@ -2,6 +2,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible';
|
||||
import { createRateLimitGuard } from '../../middleware/requestRateLimit.js';
|
||||
import {
|
||||
getOauthProviderDefaults,
|
||||
deleteOauthConnection,
|
||||
importOauthConnectionsFromNativeJson,
|
||||
getOauthSessionStatus,
|
||||
@@ -14,13 +15,22 @@ import {
|
||||
startOauthProviderFlow,
|
||||
startOauthRebindFlow,
|
||||
submitOauthManualCallback,
|
||||
updateOauthConnectionProxySettings,
|
||||
} from '../../services/oauth/service.js';
|
||||
import {
|
||||
createOauthRouteUnit,
|
||||
deleteOauthRouteUnit,
|
||||
updateOauthRouteUnit,
|
||||
} from '../../services/oauth/routeUnitService.js';
|
||||
import { parseSiteProxyUrlInput } from '../../services/siteProxy.js';
|
||||
import {
|
||||
parseOauthConnectionRebindPayload,
|
||||
parseOauthConnectionProxyUpdatePayload,
|
||||
parseOauthImportPayload,
|
||||
parseOauthManualCallbackPayload,
|
||||
parseOauthQuotaBatchRefreshPayload,
|
||||
parseOauthRouteUnitCreatePayload,
|
||||
parseOauthRouteUnitUpdatePayload,
|
||||
parseOauthStartPayload,
|
||||
} from '../../contracts/supportRoutePayloads.js';
|
||||
|
||||
@@ -60,21 +70,47 @@ const limitOauthConnectionMutate = createRateLimitGuard({
|
||||
windowMs: 60_000,
|
||||
});
|
||||
|
||||
const oauthSensitiveRouteLimiter = new RateLimiterMemory({
|
||||
keyPrefix: 'oauth-connection-sensitive',
|
||||
points: 20,
|
||||
duration: 60,
|
||||
});
|
||||
function createOauthSensitiveRouteLimiter(keyPrefix: string, points = 20) {
|
||||
return new RateLimiterMemory({
|
||||
keyPrefix,
|
||||
points,
|
||||
duration: 60,
|
||||
});
|
||||
}
|
||||
|
||||
let oauthQuotaBatchRefreshLimiter = createOauthSensitiveRouteLimiter('oauth-connection-sensitive-quota-batch');
|
||||
let oauthProxyUpdateLimiter = createOauthSensitiveRouteLimiter('oauth-connection-sensitive-proxy');
|
||||
let oauthImportLimiter = createOauthSensitiveRouteLimiter('oauth-connection-sensitive-import');
|
||||
let oauthRouteUnitCreateLimiter = createOauthSensitiveRouteLimiter('oauth-connection-sensitive-route-unit-create');
|
||||
let oauthRouteUnitUpdateLimiter = createOauthSensitiveRouteLimiter('oauth-connection-sensitive-route-unit-update');
|
||||
let oauthRouteUnitDeleteLimiter = createOauthSensitiveRouteLimiter('oauth-connection-sensitive-route-unit-delete');
|
||||
const MAX_OAUTH_QUOTA_BATCH_SIZE = 100;
|
||||
|
||||
export function resetOauthSensitiveRouteLimiterForTests(options: {
|
||||
points?: number;
|
||||
} = {}): void {
|
||||
const points = options.points ?? 20;
|
||||
oauthQuotaBatchRefreshLimiter = createOauthSensitiveRouteLimiter('oauth-connection-sensitive-quota-batch', points);
|
||||
oauthProxyUpdateLimiter = createOauthSensitiveRouteLimiter('oauth-connection-sensitive-proxy', points);
|
||||
oauthImportLimiter = createOauthSensitiveRouteLimiter('oauth-connection-sensitive-import', points);
|
||||
oauthRouteUnitCreateLimiter = createOauthSensitiveRouteLimiter('oauth-connection-sensitive-route-unit-create', points);
|
||||
oauthRouteUnitUpdateLimiter = createOauthSensitiveRouteLimiter('oauth-connection-sensitive-route-unit-update', points);
|
||||
oauthRouteUnitDeleteLimiter = createOauthSensitiveRouteLimiter('oauth-connection-sensitive-route-unit-delete', points);
|
||||
}
|
||||
|
||||
function sendOauthSensitiveRateLimit(reply: FastifyReply, error: unknown): void {
|
||||
const retryState = error instanceof RateLimiterRes ? error : null;
|
||||
const retryAfterSec = Math.max(1, Math.ceil((retryState?.msBeforeNext ?? 60_000) / 1000));
|
||||
reply.code(429).header('retry-after', String(retryAfterSec))
|
||||
.send({ message: '请求过于频繁,请稍后再试' });
|
||||
}
|
||||
|
||||
async function limitOauthSensitiveRoute(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
await oauthSensitiveRouteLimiter.consume(request.ip);
|
||||
await oauthQuotaBatchRefreshLimiter.consume(request.ip);
|
||||
} catch (error) {
|
||||
const retryState = error instanceof RateLimiterRes ? error : null;
|
||||
const retryAfterSec = Math.max(1, Math.ceil((retryState?.msBeforeNext ?? 60_000) / 1000));
|
||||
reply.code(429).header('retry-after', String(retryAfterSec))
|
||||
.send({ message: '请求过于频繁,请稍后再试' });
|
||||
sendOauthSensitiveRateLimit(reply, error);
|
||||
return reply;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +173,7 @@ function resolveRequestOrigin(request: FastifyRequest): string | undefined {
|
||||
|
||||
export async function oauthRoutes(app: FastifyInstance) {
|
||||
app.get('/api/oauth/providers', { preHandler: [limitOauthProviderRead] }, async () => ({
|
||||
defaults: getOauthProviderDefaults(),
|
||||
providers: listOauthProviders(),
|
||||
}));
|
||||
|
||||
@@ -282,6 +319,46 @@ export async function oauthRoutes(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
app.patch<{ Params: { accountId: string }; Body: unknown }>(
|
||||
'/api/oauth/connections/:accountId/proxy',
|
||||
{ preHandler: [limitOauthConnectionMutate] },
|
||||
async (request, reply) => {
|
||||
try {
|
||||
await oauthProxyUpdateLimiter.consume(request.ip);
|
||||
} catch (error) {
|
||||
sendOauthSensitiveRateLimit(reply, error);
|
||||
return;
|
||||
}
|
||||
const parsedBody = parseOauthConnectionProxyUpdatePayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ message: parsedBody.error });
|
||||
}
|
||||
|
||||
const accountId = parsePositiveInteger(request.params.accountId);
|
||||
if (accountId === null) {
|
||||
return reply.code(400).send({ message: 'invalid account id' });
|
||||
}
|
||||
const normalizedProxyUrl = parseSiteProxyUrlInput(parsedBody.data.proxyUrl);
|
||||
if (normalizedProxyUrl.present && !normalizedProxyUrl.valid) {
|
||||
return reply.code(400).send({ message: 'invalid proxy url' });
|
||||
}
|
||||
try {
|
||||
return await updateOauthConnectionProxySettings({
|
||||
accountId,
|
||||
proxyUrl: normalizedProxyUrl.present ? normalizedProxyUrl.proxyUrl : undefined,
|
||||
useSystemProxy: parsedBody.data.useSystemProxy,
|
||||
});
|
||||
} catch (error: any) {
|
||||
const message = error?.message || 'oauth account not found';
|
||||
if (message === 'oauth account not found' || message === 'account is not managed by oauth') {
|
||||
return reply.code(404).send({ message });
|
||||
}
|
||||
request.log.error({ err: error }, 'oauth proxy update failed');
|
||||
return reply.code(500).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.delete<{ Params: { accountId: string } }>(
|
||||
'/api/oauth/connections/:accountId',
|
||||
{ preHandler: [limitOauthConnectionMutate] },
|
||||
@@ -337,18 +414,34 @@ export async function oauthRoutes(app: FastifyInstance) {
|
||||
|
||||
app.post<{ Body: unknown }>(
|
||||
'/api/oauth/import',
|
||||
{ preHandler: [limitOauthConnectionMutate, limitOauthSensitiveRoute] },
|
||||
{ preHandler: [limitOauthConnectionMutate] },
|
||||
async (request, reply) => {
|
||||
try {
|
||||
await oauthImportLimiter.consume(request.ip);
|
||||
} catch (error) {
|
||||
sendOauthSensitiveRateLimit(reply, error);
|
||||
return;
|
||||
}
|
||||
const parsedBody = parseOauthImportPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ message: parsedBody.error });
|
||||
}
|
||||
const hasBatchItems = Array.isArray(parsedBody.data.items) && parsedBody.data.items.length > 0;
|
||||
const data = parsedBody.data.data;
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||
if (!hasBatchItems && (!data || typeof data !== 'object' || Array.isArray(data))) {
|
||||
return reply.code(400).send({ message: 'data must be a native oauth json object' });
|
||||
}
|
||||
const normalizedProxyUrl = parseSiteProxyUrlInput(parsedBody.data.proxyUrl);
|
||||
if (normalizedProxyUrl.present && !normalizedProxyUrl.valid) {
|
||||
return reply.code(400).send({ message: 'invalid proxy url' });
|
||||
}
|
||||
try {
|
||||
return await importOauthConnectionsFromNativeJson(data);
|
||||
return await importOauthConnectionsFromNativeJson({
|
||||
data,
|
||||
items: hasBatchItems ? parsedBody.data.items : undefined,
|
||||
proxyUrl: normalizedProxyUrl.present ? normalizedProxyUrl.proxyUrl : undefined,
|
||||
useSystemProxy: parsedBody.data.useSystemProxy,
|
||||
});
|
||||
} catch (error: any) {
|
||||
const message = error?.message || 'oauth import failed';
|
||||
if (error instanceof OauthImportValidationError) {
|
||||
@@ -360,6 +453,113 @@ export async function oauthRoutes(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Body: unknown }>(
|
||||
'/api/oauth/route-units',
|
||||
{ preHandler: [limitOauthConnectionMutate] },
|
||||
async (request, reply) => {
|
||||
try {
|
||||
await oauthRouteUnitCreateLimiter.consume(request.ip);
|
||||
} catch (error) {
|
||||
sendOauthSensitiveRateLimit(reply, error);
|
||||
return;
|
||||
}
|
||||
const parsedBody = parseOauthRouteUnitCreatePayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ message: parsedBody.error });
|
||||
}
|
||||
try {
|
||||
return await createOauthRouteUnit({
|
||||
accountIds: Array.isArray(parsedBody.data.accountIds) ? parsedBody.data.accountIds : [],
|
||||
name: parsedBody.data.name || '',
|
||||
strategy: parsedBody.data.strategy || 'round_robin',
|
||||
});
|
||||
} catch (error: any) {
|
||||
const message = error?.message || 'oauth route unit creation failed';
|
||||
if (message === 'oauth route unit accounts not found' || message === 'oauth route unit not found') {
|
||||
return reply.code(404).send({ message });
|
||||
}
|
||||
if (
|
||||
message === 'oauth route unit requires at least 2 accounts'
|
||||
|| message === 'oauth route unit name is required'
|
||||
|| message === 'invalid oauth route unit strategy'
|
||||
|| message === 'oauth route unit accounts already grouped'
|
||||
|| message === 'oauth route unit only supports oauth accounts'
|
||||
|| message === 'oauth route unit accounts must belong to the same site'
|
||||
|| message === 'oauth route unit accounts must share the same provider'
|
||||
) {
|
||||
return reply.code(400).send({ message });
|
||||
}
|
||||
request.log.error({ err: error }, 'oauth route unit creation failed');
|
||||
return reply.code(500).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.patch<{ Params: { routeUnitId: string }; Body: unknown }>(
|
||||
'/api/oauth/route-units/:routeUnitId',
|
||||
{ preHandler: [limitOauthConnectionMutate] },
|
||||
async (request, reply) => {
|
||||
try {
|
||||
await oauthRouteUnitUpdateLimiter.consume(request.ip);
|
||||
} catch (error) {
|
||||
sendOauthSensitiveRateLimit(reply, error);
|
||||
return;
|
||||
}
|
||||
const parsedBody = parseOauthRouteUnitUpdatePayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ message: parsedBody.error });
|
||||
}
|
||||
const routeUnitId = parsePositiveInteger(request.params.routeUnitId);
|
||||
if (routeUnitId === null) {
|
||||
return reply.code(400).send({ message: 'invalid route unit id' });
|
||||
}
|
||||
try {
|
||||
return await updateOauthRouteUnit({
|
||||
routeUnitId,
|
||||
name: parsedBody.data.name,
|
||||
strategy: parsedBody.data.strategy,
|
||||
});
|
||||
} catch (error: any) {
|
||||
const message = error?.message || 'oauth route unit update failed';
|
||||
if (message === 'oauth route unit not found') {
|
||||
return reply.code(404).send({ message });
|
||||
}
|
||||
if (message === 'oauth route unit name is required' || message === 'invalid oauth route unit strategy') {
|
||||
return reply.code(400).send({ message });
|
||||
}
|
||||
request.log.error({ err: error }, 'oauth route unit update failed');
|
||||
return reply.code(500).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.delete<{ Params: { routeUnitId: string } }>(
|
||||
'/api/oauth/route-units/:routeUnitId',
|
||||
{ preHandler: [limitOauthConnectionMutate] },
|
||||
async (request, reply) => {
|
||||
try {
|
||||
await oauthRouteUnitDeleteLimiter.consume(request.ip);
|
||||
} catch (error) {
|
||||
sendOauthSensitiveRateLimit(reply, error);
|
||||
return;
|
||||
}
|
||||
const routeUnitId = parsePositiveInteger(request.params.routeUnitId);
|
||||
if (routeUnitId === null) {
|
||||
return reply.code(400).send({ message: 'invalid route unit id' });
|
||||
}
|
||||
try {
|
||||
return await deleteOauthRouteUnit(routeUnitId);
|
||||
} catch (error: any) {
|
||||
const message = error?.message || 'oauth route unit not found';
|
||||
if (message === 'oauth route unit not found') {
|
||||
return reply.code(404).send({ message });
|
||||
}
|
||||
request.log.error({ err: error }, 'oauth route unit deletion failed');
|
||||
return reply.code(500).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.get<{ Params: { provider: string }; Querystring: { state?: string; code?: string; error?: string } }>(
|
||||
'/api/oauth/callback/:provider',
|
||||
{ preHandler: [limitOauthCallback] },
|
||||
|
||||
@@ -11,6 +11,7 @@ describe('PUT /api/routes/:id route rebuild', () => {
|
||||
let app: FastifyInstance;
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let resetTokenRouteReadLimitersForTests: (options?: { summaryPoints?: number; listPoints?: number }) => void;
|
||||
let dataDir = '';
|
||||
let seedId = 0;
|
||||
|
||||
@@ -61,12 +62,14 @@ describe('PUT /api/routes/:id route rebuild', () => {
|
||||
const routesModule = await import('./tokens.js');
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
resetTokenRouteReadLimitersForTests = routesModule.resetTokenRouteReadLimitersForTests;
|
||||
|
||||
app = Fastify();
|
||||
await app.register(routesModule.tokensRoutes);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
resetTokenRouteReadLimitersForTests();
|
||||
await db.delete(schema.routeChannels).run();
|
||||
await db.delete(schema.tokenRoutes).run();
|
||||
await db.delete(schema.tokenModelAvailability).run();
|
||||
@@ -150,6 +153,35 @@ describe('PUT /api/routes/:id route rebuild', () => {
|
||||
expect(rebuiltAuto?.weight).toBe(10);
|
||||
});
|
||||
|
||||
it('rate limits repeated route overview reads', async () => {
|
||||
resetTokenRouteReadLimitersForTests({
|
||||
summaryPoints: 1,
|
||||
listPoints: 1,
|
||||
});
|
||||
|
||||
const firstSummary = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/routes/summary',
|
||||
});
|
||||
const secondSummary = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/routes/summary',
|
||||
});
|
||||
const firstRoutes = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/routes',
|
||||
});
|
||||
const secondRoutes = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/routes',
|
||||
});
|
||||
|
||||
expect(firstSummary.statusCode).toBe(200);
|
||||
expect(secondSummary.statusCode).toBe(429);
|
||||
expect(firstRoutes.statusCode).toBe(200);
|
||||
expect(secondRoutes.statusCode).toBe(429);
|
||||
});
|
||||
|
||||
it('creates explicit-group routes with sourceRouteIds and aggregates source channels', async () => {
|
||||
const sourceA = await seedAccountWithToken('claude-opus-4-5');
|
||||
const sourceB = await seedAccountWithToken('claude-sonnet-4-5');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { FastifyInstance, type FastifyReply } from 'fastify';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import { requireInsertedRowId } from '../../db/insertHelpers.js';
|
||||
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||
@@ -26,6 +27,10 @@ import {
|
||||
ROUTE_DECISION_REFRESH_DEDUPE_KEY,
|
||||
ROUTE_DECISION_REFRESH_TASK_TYPE,
|
||||
} from '../../services/routeDecisionRefreshService.js';
|
||||
import {
|
||||
listOauthRouteUnitMembersByUnitIds,
|
||||
loadOauthRouteUnitSummariesByIds,
|
||||
} from '../../services/oauth/routeUnitService.js';
|
||||
import { normalizeTokenRouteMode, type RouteMode } from '../../../shared/tokenRouteContract.js';
|
||||
import {
|
||||
parseRouteChannelBatchCreatePayload,
|
||||
@@ -37,6 +42,32 @@ import {
|
||||
parseTokenRouteUpdatePayload,
|
||||
} from '../../contracts/tokenRoutePayloads.js';
|
||||
|
||||
function createTokenRouteReadLimiter(keyPrefix: string, points = 60) {
|
||||
return new RateLimiterMemory({
|
||||
keyPrefix,
|
||||
points,
|
||||
duration: 60,
|
||||
});
|
||||
}
|
||||
|
||||
let routeSummaryReadLimiter = createTokenRouteReadLimiter('token-routes-summary-read');
|
||||
let routeListReadLimiter = createTokenRouteReadLimiter('token-routes-list-read');
|
||||
|
||||
export function resetTokenRouteReadLimitersForTests(options: {
|
||||
summaryPoints?: number;
|
||||
listPoints?: number;
|
||||
} = {}): void {
|
||||
routeSummaryReadLimiter = createTokenRouteReadLimiter('token-routes-summary-read', options.summaryPoints ?? 60);
|
||||
routeListReadLimiter = createTokenRouteReadLimiter('token-routes-list-read', options.listPoints ?? 60);
|
||||
}
|
||||
|
||||
function sendTokenRouteRateLimit(reply: FastifyReply, error: unknown): void {
|
||||
const retryState = error instanceof RateLimiterRes ? error : null;
|
||||
const retryAfterSec = Math.max(1, Math.ceil((retryState?.msBeforeNext ?? 60_000) / 1000));
|
||||
reply.code(429).header('retry-after', String(retryAfterSec))
|
||||
.send({ success: false, message: '请求过于频繁,请稍后再试' });
|
||||
}
|
||||
|
||||
function isExactModelPattern(modelPattern: string): boolean {
|
||||
const normalized = modelPattern.trim();
|
||||
if (!normalized) return false;
|
||||
@@ -610,8 +641,14 @@ type RouteChannelSummary = {
|
||||
siteNames: Set<string>;
|
||||
};
|
||||
|
||||
async function fetchChannelsForRouteRows(routes: RouteRow[]): Promise<Map<number, any[]>> {
|
||||
async function fetchChannelsForRouteRows(
|
||||
routes: RouteRow[],
|
||||
options: {
|
||||
includeRouteUnitDetails?: boolean;
|
||||
} = {},
|
||||
): Promise<Map<number, any[]>> {
|
||||
if (routes.length === 0) return new Map();
|
||||
const includeRouteUnitDetails = options.includeRouteUnitDetails !== false;
|
||||
|
||||
const explicitSourceRouteIds = Array.from(new Set(routes
|
||||
.filter((route) => isExplicitGroupRoute(route))
|
||||
@@ -652,6 +689,18 @@ async function fetchChannelsForRouteRows(routes: RouteRow[]): Promise<Map<number
|
||||
.where(inArray(schema.routeChannels.routeId, actualRouteIds))
|
||||
.all();
|
||||
|
||||
const oauthRouteUnitIds: number[] = Array.from(new Set<number>(
|
||||
channelRows
|
||||
.map((row) => Number(row.route_channels.oauthRouteUnitId))
|
||||
.filter((id): id is number => Number.isFinite(id) && id > 0),
|
||||
));
|
||||
const routeUnitSummaries = includeRouteUnitDetails
|
||||
? await loadOauthRouteUnitSummariesByIds(oauthRouteUnitIds)
|
||||
: new Map();
|
||||
const routeUnitMembersByUnitId = includeRouteUnitDetails
|
||||
? await listOauthRouteUnitMembersByUnitIds(oauthRouteUnitIds)
|
||||
: new Map();
|
||||
|
||||
const channelsByActualRouteId = new Map<number, any[]>();
|
||||
|
||||
for (const row of channelRows) {
|
||||
@@ -662,6 +711,9 @@ async function fetchChannelsForRouteRows(routes: RouteRow[]): Promise<Map<number
|
||||
: null;
|
||||
const resolvedSourceModel = (row.route_channels.sourceModel || fallbackSourceModel || '').trim();
|
||||
if (!channelsByActualRouteId.has(routeId)) channelsByActualRouteId.set(routeId, []);
|
||||
const routeUnit = row.route_channels.oauthRouteUnitId
|
||||
? routeUnitSummaries.get(row.route_channels.oauthRouteUnitId) || null
|
||||
: null;
|
||||
channelsByActualRouteId.get(routeId)!.push({
|
||||
...row.route_channels,
|
||||
sourceModel: resolvedSourceModel || null,
|
||||
@@ -676,6 +728,19 @@ async function fetchChannelsForRouteRows(routes: RouteRow[]): Promise<Map<number
|
||||
isDefault: row.account_tokens.isDefault,
|
||||
}
|
||||
: null,
|
||||
routeUnit: includeRouteUnitDetails && routeUnit
|
||||
? {
|
||||
id: routeUnit.id,
|
||||
name: routeUnit.name,
|
||||
strategy: routeUnit.strategy,
|
||||
memberCount: routeUnit.memberCount,
|
||||
members: (routeUnitMembersByUnitId.get(routeUnit.id) || []).map((member) => ({
|
||||
accountId: member.account.id,
|
||||
username: member.account.username,
|
||||
siteName: member.site.name,
|
||||
})),
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -703,7 +768,7 @@ async function fetchChannelsForRoutes(routeIds: number[]): Promise<Map<number, a
|
||||
}
|
||||
|
||||
async function buildRouteChannelSummaryMap(routes: RouteRow[]): Promise<Map<number, RouteChannelSummary>> {
|
||||
const channelsByRoute = await fetchChannelsForRouteRows(routes);
|
||||
const channelsByRoute = await fetchChannelsForRouteRows(routes, { includeRouteUnitDetails: false });
|
||||
const summaryByRoute = new Map<number, RouteChannelSummary>();
|
||||
for (const route of routes) {
|
||||
const channels = channelsByRoute.get(route.id) || [];
|
||||
@@ -738,7 +803,13 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// Route summary (no channel details) for first-screen rendering
|
||||
app.get('/api/routes/summary', async () => {
|
||||
app.get('/api/routes/summary', async (request, reply) => {
|
||||
try {
|
||||
await routeSummaryReadLimiter.consume(request.ip);
|
||||
} catch (error) {
|
||||
sendTokenRouteRateLimit(reply, error);
|
||||
return;
|
||||
}
|
||||
const routes = await listRoutesWithSources();
|
||||
if (routes.length === 0) return [];
|
||||
const aggByRoute = await buildRouteChannelSummaryMap(routes);
|
||||
@@ -862,7 +933,13 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// List all routes
|
||||
app.get('/api/routes', async () => {
|
||||
app.get('/api/routes', async (request, reply) => {
|
||||
try {
|
||||
await routeListReadLimiter.consume(request.ip);
|
||||
} catch (error) {
|
||||
sendTokenRouteRateLimit(reply, error);
|
||||
return;
|
||||
}
|
||||
const routes = await listRoutesWithSources();
|
||||
if (routes.length === 0) return [];
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ describe('refreshModelsForAccount credential discovery', () => {
|
||||
let schema: DbModule['schema'];
|
||||
let refreshModelsForAccount: ModelServiceModule['refreshModelsForAccount'];
|
||||
let refreshModelsAndRebuildRoutes: ModelServiceModule['refreshModelsAndRebuildRoutes'];
|
||||
let rebuildTokenRoutesFromAvailability: ModelServiceModule['rebuildTokenRoutesFromAvailability'];
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -60,6 +61,7 @@ describe('refreshModelsForAccount credential discovery', () => {
|
||||
schema = dbModule.schema;
|
||||
refreshModelsForAccount = modelService.refreshModelsForAccount;
|
||||
refreshModelsAndRebuildRoutes = modelService.refreshModelsAndRebuildRoutes;
|
||||
rebuildTokenRoutesFromAvailability = modelService.rebuildTokenRoutesFromAvailability;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -2166,4 +2168,65 @@ describe('refreshModelsForAccount credential discovery', () => {
|
||||
const manualRow = rows.find((r) => r.modelName === 'my-custom-model');
|
||||
expect(manualRow?.isManual).toBe(true);
|
||||
});
|
||||
|
||||
it('rebuilds one pooled route channel for grouped oauth accounts that share a model', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'ChatGPT Codex OAuth',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const accountA = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'pool-a@example.com',
|
||||
accessToken: 'oauth-access-token-a',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
oauthProvider: 'codex',
|
||||
oauthAccountKey: 'chatgpt-model-pool-a',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: { provider: 'codex', accountId: 'chatgpt-model-pool-a', email: 'pool-a@example.com' },
|
||||
}),
|
||||
}).returning().get();
|
||||
const accountB = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'pool-b@example.com',
|
||||
accessToken: 'oauth-access-token-b',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
oauthProvider: 'codex',
|
||||
oauthAccountKey: 'chatgpt-model-pool-b',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: { provider: 'codex', accountId: 'chatgpt-model-pool-b', email: 'pool-b@example.com' },
|
||||
}),
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.modelAvailability).values([
|
||||
{ accountId: accountA.id, modelName: 'gpt-5.4', available: true },
|
||||
{ accountId: accountB.id, modelName: 'gpt-5.4', available: true },
|
||||
]).run();
|
||||
const routeUnit = await db.insert(schema.oauthRouteUnits).values({
|
||||
siteId: site.id,
|
||||
provider: 'codex',
|
||||
name: 'Codex Pool',
|
||||
strategy: 'round_robin',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
await db.insert(schema.oauthRouteUnitMembers).values([
|
||||
{ unitId: routeUnit.id, accountId: accountA.id, sortOrder: 0 },
|
||||
{ unitId: routeUnit.id, accountId: accountB.id, sortOrder: 1 },
|
||||
]).run();
|
||||
|
||||
const rebuild = await rebuildTokenRoutesFromAvailability();
|
||||
expect(rebuild.createdChannels).toBe(1);
|
||||
|
||||
const channels = await db.select().from(schema.routeChannels).all();
|
||||
expect(channels).toHaveLength(1);
|
||||
expect(channels[0]).toMatchObject({
|
||||
oauthRouteUnitId: routeUnit.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import { withAccountProxyOverride } from './siteProxy.js';
|
||||
import { isCodexPlatform } from './oauth/codexAccount.js';
|
||||
import { buildStoredOauthStateFromAccount, getOauthInfoFromAccount } from './oauth/oauthAccount.js';
|
||||
import { refreshOauthAccessTokenSingleflight } from './oauth/refreshSingleflight.js';
|
||||
import { listEnabledOauthRouteUnitsWithMembers } from './oauth/routeUnitService.js';
|
||||
import { requireSiteApiBaseUrl } from './siteApiEndpointService.js';
|
||||
import {
|
||||
discoverAntigravityModelsFromCloud,
|
||||
@@ -1104,16 +1105,56 @@ export async function rebuildTokenRoutesFromAvailability() {
|
||||
return globalAllowedModels.has(modelName.toLowerCase().trim());
|
||||
}
|
||||
|
||||
const modelCandidates = new Map<string, Map<string, { accountId: number; tokenId: number | null }>>();
|
||||
const addModelCandidate = (modelNameRaw: string | null | undefined, accountId: number, tokenId: number | null, siteId: number) => {
|
||||
const enabledOauthRouteUnits = await listEnabledOauthRouteUnitsWithMembers();
|
||||
const routeUnitByAccountId = new Map<number, {
|
||||
routeUnitId: number;
|
||||
representativeAccountId: number;
|
||||
}>();
|
||||
for (const routeUnit of enabledOauthRouteUnits) {
|
||||
const representativeAccountId = routeUnit.members[0]?.account.id;
|
||||
if (!representativeAccountId) continue;
|
||||
for (const member of routeUnit.members) {
|
||||
routeUnitByAccountId.set(member.account.id, {
|
||||
routeUnitId: routeUnit.unit.id,
|
||||
representativeAccountId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const modelCandidates = new Map<string, Map<string, {
|
||||
accountId: number;
|
||||
tokenId: number | null;
|
||||
oauthRouteUnitId: number | null;
|
||||
}>>();
|
||||
const buildCandidateKey = (input: {
|
||||
accountId: number;
|
||||
tokenId: number | null;
|
||||
oauthRouteUnitId: number | null;
|
||||
}) => (
|
||||
input.oauthRouteUnitId
|
||||
? `route-unit:${input.oauthRouteUnitId}`
|
||||
: `${input.accountId}:${input.tokenId ?? 'account'}`
|
||||
);
|
||||
const buildChannelKey = (channel: typeof schema.routeChannels.$inferSelect) => (
|
||||
channel.oauthRouteUnitId
|
||||
? `route-unit:${channel.oauthRouteUnitId}`
|
||||
: `${channel.accountId}:${channel.tokenId ?? 'account'}`
|
||||
);
|
||||
const addModelCandidate = (
|
||||
modelNameRaw: string | null | undefined,
|
||||
accountId: number,
|
||||
tokenId: number | null,
|
||||
siteId: number,
|
||||
oauthRouteUnitId: number | null = null,
|
||||
) => {
|
||||
const modelName = (modelNameRaw || '').trim();
|
||||
if (!modelName) return;
|
||||
if (!isModelAllowedByWhitelist(modelName)) return;
|
||||
if (isModelDisabledForSite(siteId, modelName)) return;
|
||||
if (blockedBrandRules.length > 0 && isModelBlockedByBrand(modelName, blockedBrandRules)) return;
|
||||
if (!modelCandidates.has(modelName)) modelCandidates.set(modelName, new Map());
|
||||
const candidateKey = `${accountId}:${tokenId ?? 'account'}`;
|
||||
modelCandidates.get(modelName)!.set(candidateKey, { accountId, tokenId });
|
||||
const candidate = { accountId, tokenId, oauthRouteUnitId };
|
||||
modelCandidates.get(modelName)!.set(buildCandidateKey(candidate), candidate);
|
||||
};
|
||||
|
||||
for (const row of usableTokenRows) {
|
||||
@@ -1122,6 +1163,17 @@ export async function rebuildTokenRoutesFromAvailability() {
|
||||
|
||||
for (const row of accountRows) {
|
||||
if (!supportsDirectAccountRoutingConnection(row.accounts)) continue;
|
||||
const routeUnit = routeUnitByAccountId.get(row.accounts.id);
|
||||
if (routeUnit) {
|
||||
addModelCandidate(
|
||||
row.model_availability.modelName,
|
||||
routeUnit.representativeAccountId,
|
||||
null,
|
||||
row.accounts.siteId,
|
||||
routeUnit.routeUnitId,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
addModelCandidate(row.model_availability.modelName, row.accounts.id, null, row.accounts.siteId);
|
||||
}
|
||||
|
||||
@@ -1153,16 +1205,14 @@ export async function rebuildTokenRoutesFromAvailability() {
|
||||
const desiredKeys = new Set(Array.from(candidateMap.keys()));
|
||||
|
||||
for (const [candidateKey, candidate] of candidateMap.entries()) {
|
||||
const exists = routeChannels.some((channel) => (
|
||||
channel.accountId === candidate.accountId
|
||||
&& (channel.tokenId ?? null) === candidate.tokenId
|
||||
));
|
||||
const exists = routeChannels.some((channel) => buildChannelKey(channel) === candidateKey);
|
||||
if (exists) continue;
|
||||
|
||||
const inserted = await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: candidate.accountId,
|
||||
tokenId: candidate.tokenId,
|
||||
oauthRouteUnitId: candidate.oauthRouteUnitId,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
@@ -1178,7 +1228,7 @@ export async function rebuildTokenRoutesFromAvailability() {
|
||||
}
|
||||
|
||||
for (const channel of routeChannels) {
|
||||
const channelKey = `${channel.accountId}:${channel.tokenId ?? 'account'}`;
|
||||
const channelKey = buildChannelKey(channel);
|
||||
if (desiredKeys.has(channelKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
import { and, eq, inArray, sql } from 'drizzle-orm';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import { insertAndGetById } from '../../db/insertHelpers.js';
|
||||
import * as routeRefreshWorkflow from '../routeRefreshWorkflow.js';
|
||||
import { invalidateTokenRouterCache } from '../tokenRouter.js';
|
||||
|
||||
export type OAuthRouteUnitStrategy = 'round_robin' | 'stick_until_unavailable';
|
||||
|
||||
export type OAuthRouteUnitSummary = {
|
||||
id: number;
|
||||
siteId: number;
|
||||
provider: string;
|
||||
name: string;
|
||||
strategy: OAuthRouteUnitStrategy;
|
||||
enabled: boolean;
|
||||
memberCount: number;
|
||||
};
|
||||
|
||||
export type OAuthRouteUnitAccountParticipation = {
|
||||
kind: 'route_unit';
|
||||
id: number;
|
||||
name: string;
|
||||
strategy: OAuthRouteUnitStrategy;
|
||||
memberCount: number;
|
||||
};
|
||||
|
||||
export type OAuthRouteUnitMemberDetail = {
|
||||
member: typeof schema.oauthRouteUnitMembers.$inferSelect;
|
||||
unit: typeof schema.oauthRouteUnits.$inferSelect;
|
||||
account: typeof schema.accounts.$inferSelect;
|
||||
site: typeof schema.sites.$inferSelect;
|
||||
};
|
||||
|
||||
function normalizeRouteUnitStrategy(value: unknown): OAuthRouteUnitStrategy | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'round_robin') return 'round_robin';
|
||||
if (normalized === 'stick_until_unavailable') return 'stick_until_unavailable';
|
||||
return null;
|
||||
}
|
||||
|
||||
function uniquePositiveIds(accountIds: number[]): number[] {
|
||||
return Array.from(new Set(
|
||||
accountIds
|
||||
.filter((id) => Number.isFinite(id) && id > 0)
|
||||
.map((id) => Math.trunc(id)),
|
||||
));
|
||||
}
|
||||
|
||||
function assertRouteUnitStrategy(value: unknown): OAuthRouteUnitStrategy {
|
||||
const normalized = normalizeRouteUnitStrategy(value);
|
||||
if (!normalized) {
|
||||
throw new Error('invalid oauth route unit strategy');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isOauthRouteUnitAccountUniqueConflict(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
const code = String((error as { code?: unknown }).code || '');
|
||||
const lowered = String((error as { message?: unknown }).message || '').toLowerCase();
|
||||
return (
|
||||
((code === 'SQLITE_CONSTRAINT' || code === 'SQLITE_CONSTRAINT_UNIQUE')
|
||||
&& lowered.includes('oauth_route_unit_members.account_id'))
|
||||
|| (code === 'ER_DUP_ENTRY' && lowered.includes('oauth_route_unit_members_account_unique'))
|
||||
|| (code === '23505' && lowered.includes('oauth_route_unit_members_account_unique'))
|
||||
|| (lowered.includes('duplicate key value violates unique constraint')
|
||||
&& lowered.includes('oauth_route_unit_members_account_unique'))
|
||||
);
|
||||
}
|
||||
|
||||
export async function listOauthRouteUnitsByAccountIds(accountIds: number[]): Promise<Map<number, OAuthRouteUnitAccountParticipation>> {
|
||||
const uniqueIds = uniquePositiveIds(accountIds);
|
||||
if (uniqueIds.length === 0) return new Map();
|
||||
|
||||
const rows = await db.select({
|
||||
accountId: schema.oauthRouteUnitMembers.accountId,
|
||||
unitId: schema.oauthRouteUnits.id,
|
||||
siteId: schema.oauthRouteUnits.siteId,
|
||||
provider: schema.oauthRouteUnits.provider,
|
||||
name: schema.oauthRouteUnits.name,
|
||||
strategy: schema.oauthRouteUnits.strategy,
|
||||
enabled: schema.oauthRouteUnits.enabled,
|
||||
memberCount: sql<number>`COUNT(*) OVER (PARTITION BY ${schema.oauthRouteUnits.id})`,
|
||||
}).from(schema.oauthRouteUnitMembers)
|
||||
.innerJoin(schema.oauthRouteUnits, eq(schema.oauthRouteUnitMembers.unitId, schema.oauthRouteUnits.id))
|
||||
.where(inArray(schema.oauthRouteUnitMembers.accountId, uniqueIds))
|
||||
.all();
|
||||
|
||||
const result = new Map<number, OAuthRouteUnitAccountParticipation>();
|
||||
for (const row of rows) {
|
||||
const strategy = normalizeRouteUnitStrategy(row.strategy) || 'round_robin';
|
||||
result.set(row.accountId, {
|
||||
kind: 'route_unit',
|
||||
id: row.unitId,
|
||||
name: row.name,
|
||||
strategy,
|
||||
memberCount: Math.max(1, Number(row.memberCount || 0)),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function listOauthRouteUnitMembersByUnitIds(unitIds: number[]): Promise<Map<number, OAuthRouteUnitMemberDetail[]>> {
|
||||
const normalizedUnitIds = uniquePositiveIds(unitIds);
|
||||
if (normalizedUnitIds.length === 0) return new Map();
|
||||
|
||||
const rows = await db.select()
|
||||
.from(schema.oauthRouteUnitMembers)
|
||||
.innerJoin(schema.oauthRouteUnits, eq(schema.oauthRouteUnitMembers.unitId, schema.oauthRouteUnits.id))
|
||||
.innerJoin(schema.accounts, eq(schema.oauthRouteUnitMembers.accountId, schema.accounts.id))
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(inArray(schema.oauthRouteUnitMembers.unitId, normalizedUnitIds))
|
||||
.all();
|
||||
|
||||
const result = new Map<number, OAuthRouteUnitMemberDetail[]>();
|
||||
for (const row of rows) {
|
||||
if (!result.has(row.oauth_route_unit_members.unitId)) {
|
||||
result.set(row.oauth_route_unit_members.unitId, []);
|
||||
}
|
||||
result.get(row.oauth_route_unit_members.unitId)!.push({
|
||||
member: row.oauth_route_unit_members,
|
||||
unit: row.oauth_route_units,
|
||||
account: row.accounts,
|
||||
site: row.sites,
|
||||
});
|
||||
}
|
||||
|
||||
for (const members of result.values()) {
|
||||
members.sort((left, right) => (
|
||||
(left.member.sortOrder ?? 0) - (right.member.sortOrder ?? 0)
|
||||
|| left.member.id - right.member.id
|
||||
));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function rollbackCreatedOauthRouteUnit(routeUnitId: number): Promise<void> {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(schema.routeChannels)
|
||||
.where(eq(schema.routeChannels.oauthRouteUnitId, routeUnitId))
|
||||
.run();
|
||||
await tx.delete(schema.oauthRouteUnitMembers)
|
||||
.where(eq(schema.oauthRouteUnitMembers.unitId, routeUnitId))
|
||||
.run();
|
||||
await tx.delete(schema.oauthRouteUnits)
|
||||
.where(eq(schema.oauthRouteUnits.id, routeUnitId))
|
||||
.run();
|
||||
});
|
||||
}
|
||||
|
||||
async function restoreDeletedOauthRouteUnit(snapshot: {
|
||||
unit: typeof schema.oauthRouteUnits.$inferSelect;
|
||||
members: Array<typeof schema.oauthRouteUnitMembers.$inferSelect>;
|
||||
}): Promise<void> {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(schema.oauthRouteUnits).values({
|
||||
id: snapshot.unit.id,
|
||||
siteId: snapshot.unit.siteId,
|
||||
provider: snapshot.unit.provider,
|
||||
name: snapshot.unit.name,
|
||||
strategy: snapshot.unit.strategy,
|
||||
enabled: snapshot.unit.enabled,
|
||||
createdAt: snapshot.unit.createdAt,
|
||||
updatedAt: snapshot.unit.updatedAt,
|
||||
}).run();
|
||||
|
||||
if (snapshot.members.length > 0) {
|
||||
await tx.insert(schema.oauthRouteUnitMembers).values(snapshot.members.map((member) => ({
|
||||
id: member.id,
|
||||
unitId: member.unitId,
|
||||
accountId: member.accountId,
|
||||
sortOrder: member.sortOrder,
|
||||
createdAt: member.createdAt,
|
||||
updatedAt: member.updatedAt,
|
||||
}))).run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function listEnabledOauthRouteUnitsWithMembers(): Promise<Array<{
|
||||
unit: typeof schema.oauthRouteUnits.$inferSelect;
|
||||
members: OAuthRouteUnitMemberDetail[];
|
||||
}>> {
|
||||
const units = await db.select().from(schema.oauthRouteUnits)
|
||||
.where(eq(schema.oauthRouteUnits.enabled, true))
|
||||
.all();
|
||||
if (units.length === 0) return [];
|
||||
|
||||
const membersByUnitId = await listOauthRouteUnitMembersByUnitIds(units.map((unit) => unit.id));
|
||||
return units.map((unit) => ({
|
||||
unit,
|
||||
members: membersByUnitId.get(unit.id) || [],
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createOauthRouteUnit(input: {
|
||||
accountIds: number[];
|
||||
name: string;
|
||||
strategy: OAuthRouteUnitStrategy;
|
||||
}) {
|
||||
const accountIds = uniquePositiveIds(input.accountIds);
|
||||
if (accountIds.length < 2) {
|
||||
throw new Error('oauth route unit requires at least 2 accounts');
|
||||
}
|
||||
const name = String(input.name || '').trim();
|
||||
if (!name) {
|
||||
throw new Error('oauth route unit name is required');
|
||||
}
|
||||
const strategy = assertRouteUnitStrategy(input.strategy);
|
||||
|
||||
const rows = await db.select()
|
||||
.from(schema.accounts)
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(inArray(schema.accounts.id, accountIds))
|
||||
.all();
|
||||
if (rows.length !== accountIds.length) {
|
||||
throw new Error('oauth route unit accounts not found');
|
||||
}
|
||||
|
||||
const first = rows[0]!;
|
||||
const expectedSiteId = first.accounts.siteId;
|
||||
const expectedProvider = (first.accounts.oauthProvider || '').trim();
|
||||
if (!expectedProvider) {
|
||||
throw new Error('oauth route unit only supports oauth accounts');
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.accounts.siteId !== expectedSiteId) {
|
||||
throw new Error('oauth route unit accounts must belong to the same site');
|
||||
}
|
||||
if ((row.accounts.oauthProvider || '').trim() !== expectedProvider) {
|
||||
throw new Error('oauth route unit accounts must share the same provider');
|
||||
}
|
||||
}
|
||||
|
||||
let created;
|
||||
try {
|
||||
created = await db.transaction(async (tx) => {
|
||||
const existingMembers = await tx.select({
|
||||
accountId: schema.oauthRouteUnitMembers.accountId,
|
||||
}).from(schema.oauthRouteUnitMembers)
|
||||
.where(inArray(schema.oauthRouteUnitMembers.accountId, accountIds))
|
||||
.all();
|
||||
if (existingMembers.length > 0) {
|
||||
throw new Error('oauth route unit accounts already grouped');
|
||||
}
|
||||
|
||||
const inserted = await insertAndGetById<typeof schema.oauthRouteUnits.$inferSelect>({
|
||||
txDb: tx,
|
||||
table: schema.oauthRouteUnits,
|
||||
idColumn: schema.oauthRouteUnits.id,
|
||||
values: {
|
||||
siteId: expectedSiteId,
|
||||
provider: expectedProvider,
|
||||
name,
|
||||
strategy,
|
||||
enabled: true,
|
||||
},
|
||||
insertErrorMessage: 'oauth route unit creation failed',
|
||||
});
|
||||
|
||||
await tx.insert(schema.oauthRouteUnitMembers).values(
|
||||
accountIds.map((accountId, index) => ({
|
||||
unitId: inserted.id,
|
||||
accountId,
|
||||
sortOrder: index,
|
||||
})),
|
||||
).run();
|
||||
return inserted;
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as Error)?.message === 'oauth route unit accounts already grouped' || isOauthRouteUnitAccountUniqueConflict(error)) {
|
||||
throw new Error('oauth route unit accounts already grouped');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
await routeRefreshWorkflow.rebuildRoutesOnly();
|
||||
} catch (error) {
|
||||
await rollbackCreatedOauthRouteUnit(created.id);
|
||||
try {
|
||||
await routeRefreshWorkflow.rebuildRoutesOnly();
|
||||
} catch {
|
||||
// Best-effort restore to the pre-create routing shape before surfacing the original failure.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
routeUnit: {
|
||||
id: created.id,
|
||||
siteId: created.siteId,
|
||||
provider: created.provider,
|
||||
name: created.name,
|
||||
strategy,
|
||||
enabled: created.enabled ?? true,
|
||||
memberCount: accountIds.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateOauthRouteUnit(input: {
|
||||
routeUnitId: number;
|
||||
name?: string;
|
||||
strategy?: OAuthRouteUnitStrategy;
|
||||
}) {
|
||||
const existing = await db.select().from(schema.oauthRouteUnits)
|
||||
.where(eq(schema.oauthRouteUnits.id, input.routeUnitId))
|
||||
.get();
|
||||
if (!existing) {
|
||||
throw new Error('oauth route unit not found');
|
||||
}
|
||||
|
||||
const updates: Partial<typeof schema.oauthRouteUnits.$inferInsert> = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
if (input.name !== undefined) {
|
||||
const nextName = String(input.name || '').trim();
|
||||
if (!nextName) {
|
||||
throw new Error('oauth route unit name is required');
|
||||
}
|
||||
updates.name = nextName;
|
||||
}
|
||||
if (input.strategy !== undefined) {
|
||||
updates.strategy = assertRouteUnitStrategy(input.strategy);
|
||||
}
|
||||
|
||||
await db.update(schema.oauthRouteUnits).set(updates)
|
||||
.where(eq(schema.oauthRouteUnits.id, input.routeUnitId))
|
||||
.run();
|
||||
|
||||
invalidateTokenRouterCache();
|
||||
|
||||
const memberCountRow = await db.select({
|
||||
count: sql<number>`COUNT(*)`,
|
||||
}).from(schema.oauthRouteUnitMembers)
|
||||
.where(eq(schema.oauthRouteUnitMembers.unitId, input.routeUnitId))
|
||||
.get();
|
||||
|
||||
const updated = await db.select().from(schema.oauthRouteUnits)
|
||||
.where(eq(schema.oauthRouteUnits.id, input.routeUnitId))
|
||||
.get();
|
||||
if (!updated) {
|
||||
throw new Error('oauth route unit not found');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
routeUnit: {
|
||||
id: updated.id,
|
||||
siteId: updated.siteId,
|
||||
provider: updated.provider,
|
||||
name: updated.name,
|
||||
strategy: assertRouteUnitStrategy(updated.strategy),
|
||||
enabled: updated.enabled ?? true,
|
||||
memberCount: Number(memberCountRow?.count || 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteOauthRouteUnit(routeUnitId: number) {
|
||||
const existing = await db.select().from(schema.oauthRouteUnits)
|
||||
.where(eq(schema.oauthRouteUnits.id, routeUnitId))
|
||||
.get();
|
||||
if (!existing) {
|
||||
throw new Error('oauth route unit not found');
|
||||
}
|
||||
|
||||
const existingMembers = await db.select().from(schema.oauthRouteUnitMembers)
|
||||
.where(eq(schema.oauthRouteUnitMembers.unitId, routeUnitId))
|
||||
.all();
|
||||
|
||||
await db.delete(schema.routeChannels)
|
||||
.where(eq(schema.routeChannels.oauthRouteUnitId, routeUnitId))
|
||||
.run();
|
||||
await db.delete(schema.oauthRouteUnitMembers)
|
||||
.where(eq(schema.oauthRouteUnitMembers.unitId, routeUnitId))
|
||||
.run();
|
||||
await db.delete(schema.oauthRouteUnits)
|
||||
.where(eq(schema.oauthRouteUnits.id, routeUnitId))
|
||||
.run();
|
||||
|
||||
try {
|
||||
await routeRefreshWorkflow.rebuildRoutesOnly();
|
||||
} catch (error) {
|
||||
await restoreDeletedOauthRouteUnit({
|
||||
unit: existing,
|
||||
members: existingMembers,
|
||||
});
|
||||
try {
|
||||
await routeRefreshWorkflow.rebuildRoutesOnly();
|
||||
} catch {
|
||||
// Best-effort restore to the pre-delete routing shape before surfacing the original failure.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return { success: true as const };
|
||||
}
|
||||
|
||||
export async function loadOauthRouteUnitSummariesByIds(routeUnitIds: number[]): Promise<Map<number, OAuthRouteUnitSummary>> {
|
||||
const normalizedIds = uniquePositiveIds(routeUnitIds);
|
||||
if (normalizedIds.length === 0) return new Map();
|
||||
|
||||
const rows = await db.select({
|
||||
id: schema.oauthRouteUnits.id,
|
||||
siteId: schema.oauthRouteUnits.siteId,
|
||||
provider: schema.oauthRouteUnits.provider,
|
||||
name: schema.oauthRouteUnits.name,
|
||||
strategy: schema.oauthRouteUnits.strategy,
|
||||
enabled: schema.oauthRouteUnits.enabled,
|
||||
memberCount: sql<number>`COUNT(${schema.oauthRouteUnitMembers.id})`,
|
||||
}).from(schema.oauthRouteUnits)
|
||||
.leftJoin(schema.oauthRouteUnitMembers, eq(schema.oauthRouteUnits.id, schema.oauthRouteUnitMembers.unitId))
|
||||
.where(inArray(schema.oauthRouteUnits.id, normalizedIds))
|
||||
.groupBy(schema.oauthRouteUnits.id)
|
||||
.all();
|
||||
|
||||
const result = new Map<number, OAuthRouteUnitSummary>();
|
||||
for (const row of rows) {
|
||||
result.set(row.id, {
|
||||
id: row.id,
|
||||
siteId: row.siteId,
|
||||
provider: row.provider,
|
||||
name: row.name,
|
||||
strategy: normalizeRouteUnitStrategy(row.strategy) || 'round_robin',
|
||||
enabled: row.enabled ?? true,
|
||||
memberCount: Number(row.memberCount || 0),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function loadOauthRouteUnitByAccountId(accountId: number): Promise<OAuthRouteUnitAccountParticipation | null> {
|
||||
const result = await listOauthRouteUnitsByAccountIds([accountId]);
|
||||
return result.get(accountId) || null;
|
||||
}
|
||||
|
||||
export function getOauthRouteUnitStrategyLabel(strategy: OAuthRouteUnitStrategy): string {
|
||||
return strategy === 'stick_until_unavailable' ? '单个用到不可用再切' : '轮询';
|
||||
}
|
||||
|
||||
export async function loadOauthRouteUnitMemberByChannelAndAccount(input: {
|
||||
routeUnitId: number;
|
||||
accountId: number;
|
||||
}) {
|
||||
return await db.select()
|
||||
.from(schema.oauthRouteUnitMembers)
|
||||
.where(and(
|
||||
eq(schema.oauthRouteUnitMembers.unitId, input.routeUnitId),
|
||||
eq(schema.oauthRouteUnitMembers.accountId, input.accountId),
|
||||
))
|
||||
.get();
|
||||
}
|
||||
@@ -39,10 +39,14 @@ import {
|
||||
import { resolveOauthAccountProxyUrl, resolveOauthProviderProxyUrl } from './requestProxy.js';
|
||||
import { ensureOauthIdentityBackfill } from './oauthIdentityBackfill.js';
|
||||
import { buildQuotaSnapshotFromOauthInfo, refreshOauthQuotaSnapshot } from './quota.js';
|
||||
import {
|
||||
listOauthRouteUnitsByAccountIds,
|
||||
} from './routeUnitService.js';
|
||||
|
||||
type OAuthProviderMetadata = ReturnType<typeof listOauthProviders>[number];
|
||||
const MANUAL_CALLBACK_DELAY_MS = 15_000;
|
||||
const OAUTH_QUOTA_BATCH_REFRESH_CONCURRENCY = 4;
|
||||
const MAX_OAUTH_IMPORT_BATCH_SIZE = 100;
|
||||
type OauthProviderHeaderAccountInput = OauthIdentityCarrierLike & {
|
||||
extraConfig?: OauthExtraConfigInput;
|
||||
};
|
||||
@@ -365,6 +369,7 @@ async function revertPersistedOauthAccount(input: {
|
||||
accountId: number;
|
||||
created: boolean;
|
||||
previousAccount: typeof schema.accounts.$inferSelect | null;
|
||||
previousModelAvailability?: Array<typeof schema.modelAvailability.$inferSelect>;
|
||||
}) {
|
||||
if (input.created) {
|
||||
await db.delete(schema.accounts).where(eq(schema.accounts.id, input.accountId)).run();
|
||||
@@ -372,19 +377,37 @@ async function revertPersistedOauthAccount(input: {
|
||||
}
|
||||
|
||||
if (!input.previousAccount) return;
|
||||
await db.update(schema.accounts).set({
|
||||
siteId: input.previousAccount.siteId,
|
||||
username: input.previousAccount.username,
|
||||
accessToken: input.previousAccount.accessToken,
|
||||
apiToken: input.previousAccount.apiToken,
|
||||
checkinEnabled: input.previousAccount.checkinEnabled,
|
||||
status: input.previousAccount.status,
|
||||
oauthProvider: input.previousAccount.oauthProvider,
|
||||
oauthAccountKey: input.previousAccount.oauthAccountKey,
|
||||
oauthProjectId: input.previousAccount.oauthProjectId,
|
||||
extraConfig: input.previousAccount.extraConfig,
|
||||
updatedAt: input.previousAccount.updatedAt,
|
||||
}).where(eq(schema.accounts.id, input.previousAccount.id)).run();
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.update(schema.accounts).set({
|
||||
siteId: input.previousAccount!.siteId,
|
||||
username: input.previousAccount!.username,
|
||||
accessToken: input.previousAccount!.accessToken,
|
||||
apiToken: input.previousAccount!.apiToken,
|
||||
checkinEnabled: input.previousAccount!.checkinEnabled,
|
||||
status: input.previousAccount!.status,
|
||||
oauthProvider: input.previousAccount!.oauthProvider,
|
||||
oauthAccountKey: input.previousAccount!.oauthAccountKey,
|
||||
oauthProjectId: input.previousAccount!.oauthProjectId,
|
||||
extraConfig: input.previousAccount!.extraConfig,
|
||||
updatedAt: input.previousAccount!.updatedAt,
|
||||
}).where(eq(schema.accounts.id, input.previousAccount!.id)).run();
|
||||
|
||||
if (input.previousModelAvailability) {
|
||||
await tx.delete(schema.modelAvailability)
|
||||
.where(eq(schema.modelAvailability.accountId, input.accountId))
|
||||
.run();
|
||||
if (input.previousModelAvailability.length > 0) {
|
||||
await tx.insert(schema.modelAvailability).values(input.previousModelAvailability.map((row) => ({
|
||||
accountId: input.previousAccount!.id,
|
||||
modelName: row.modelName,
|
||||
available: row.available,
|
||||
isManual: row.isManual,
|
||||
latencyMs: row.latencyMs,
|
||||
checkedAt: row.checkedAt,
|
||||
}))).run();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T, TResult>(
|
||||
@@ -410,6 +433,112 @@ async function mapWithConcurrency<T, TResult>(
|
||||
return results;
|
||||
}
|
||||
|
||||
function normalizeImportedOauthJsonItems(input: {
|
||||
data?: unknown;
|
||||
items?: unknown[];
|
||||
}): unknown[] {
|
||||
const batchItems = Array.isArray(input.items)
|
||||
? input.items
|
||||
: [];
|
||||
if (batchItems.length > 0) {
|
||||
return batchItems;
|
||||
}
|
||||
if (input.data === undefined) {
|
||||
return [];
|
||||
}
|
||||
return [input.data];
|
||||
}
|
||||
|
||||
async function activatePersistedOauthAccount(input: {
|
||||
definition: OAuthProviderDefinition;
|
||||
exchange: {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
tokenExpiresAt?: number;
|
||||
email?: string;
|
||||
accountKey?: string;
|
||||
accountId?: string;
|
||||
planType?: string;
|
||||
projectId?: string;
|
||||
idToken?: string;
|
||||
providerData?: Record<string, unknown>;
|
||||
};
|
||||
rebindAccountId?: number;
|
||||
proxyUrl?: string | null;
|
||||
useSystemProxy?: boolean;
|
||||
persistedStatus?: 'active' | 'disabled';
|
||||
activateExistingAfterRefresh?: boolean;
|
||||
}) {
|
||||
const rollbackSnapshotByRebindAccountId = typeof input.rebindAccountId === 'number' && input.rebindAccountId > 0
|
||||
? await db.select().from(schema.modelAvailability)
|
||||
.where(eq(schema.modelAvailability.accountId, input.rebindAccountId))
|
||||
.all()
|
||||
: [];
|
||||
const persisted = await upsertOauthAccount({
|
||||
definition: input.definition,
|
||||
exchange: input.exchange,
|
||||
rebindAccountId: input.rebindAccountId,
|
||||
proxyUrl: input.proxyUrl,
|
||||
useSystemProxy: input.useSystemProxy,
|
||||
persistedStatus: input.persistedStatus,
|
||||
});
|
||||
|
||||
if (!persisted.account) {
|
||||
throw new Error('failed to persist oauth account');
|
||||
}
|
||||
|
||||
const previousModelAvailability = rollbackSnapshotByRebindAccountId.length > 0
|
||||
? rollbackSnapshotByRebindAccountId
|
||||
: persisted.created
|
||||
? []
|
||||
: await db.select().from(schema.modelAvailability)
|
||||
.where(eq(schema.modelAvailability.accountId, persisted.previousAccount?.id ?? persisted.account.id))
|
||||
.all();
|
||||
|
||||
const shouldRefreshModels = input.activateExistingAfterRefresh === true
|
||||
|| (persisted.account.status || 'active') === 'active';
|
||||
if (shouldRefreshModels) {
|
||||
const refreshResult = await refreshModelsForAccount(
|
||||
persisted.account.id,
|
||||
persisted.previousAccount ? { allowInactive: true } : undefined,
|
||||
);
|
||||
if (refreshResult.status !== 'success') {
|
||||
await revertPersistedOauthAccount({
|
||||
accountId: persisted.account.id,
|
||||
created: persisted.created,
|
||||
previousAccount: persisted.previousAccount,
|
||||
previousModelAvailability,
|
||||
});
|
||||
await routeRefreshWorkflow.rebuildRoutesOnly();
|
||||
throw new Error(refreshResult.errorMessage || `${input.definition.metadata.provider} model discovery failed`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (input.activateExistingAfterRefresh && persisted.previousAccount) {
|
||||
await db.update(schema.accounts).set({
|
||||
status: 'active',
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.accounts.id, persisted.account.id)).run();
|
||||
persisted.account = await db.select().from(schema.accounts)
|
||||
.where(eq(schema.accounts.id, persisted.account.id))
|
||||
.get();
|
||||
}
|
||||
|
||||
await routeRefreshWorkflow.rebuildRoutesOnly();
|
||||
return persisted;
|
||||
} catch (error) {
|
||||
await revertPersistedOauthAccount({
|
||||
accountId: persisted.account.id,
|
||||
created: persisted.created,
|
||||
previousAccount: persisted.previousAccount,
|
||||
previousModelAvailability,
|
||||
});
|
||||
await routeRefreshWorkflow.rebuildRoutesOnly();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureOauthSite(definition: OAuthProviderDefinition) {
|
||||
return ensureOauthProviderSite(definition);
|
||||
}
|
||||
@@ -559,6 +688,12 @@ export function listOauthProviders() {
|
||||
});
|
||||
}
|
||||
|
||||
export function getOauthProviderDefaults() {
|
||||
return {
|
||||
systemProxyConfigured: !!resolveProxyUrlFromExtraConfig({ useSystemProxy: true }),
|
||||
};
|
||||
}
|
||||
|
||||
export async function startOauthProviderFlow(input: {
|
||||
provider: string;
|
||||
rebindAccountId?: number;
|
||||
@@ -651,42 +786,18 @@ export async function handleOauthCallback(input: {
|
||||
projectId: session.projectId,
|
||||
proxyUrl: resolvedProxyUrl,
|
||||
});
|
||||
const { account, site, created, previousAccount } = await upsertOauthAccount({
|
||||
const { account, site } = await activatePersistedOauthAccount({
|
||||
definition,
|
||||
exchange,
|
||||
rebindAccountId: session.rebindAccountId,
|
||||
proxyUrl: session.proxyUrl,
|
||||
useSystemProxy: session.useSystemProxy,
|
||||
activateExistingAfterRefresh: true,
|
||||
});
|
||||
if (!account) {
|
||||
markOauthSessionError(input.state, 'failed to persist oauth account');
|
||||
throw new Error('failed to persist oauth account');
|
||||
}
|
||||
|
||||
const refreshResult = await refreshModelsForAccount(
|
||||
account.id,
|
||||
previousAccount ? { allowInactive: true } : undefined,
|
||||
);
|
||||
if (refreshResult.status !== 'success') {
|
||||
await revertPersistedOauthAccount({
|
||||
accountId: account.id,
|
||||
created,
|
||||
previousAccount,
|
||||
});
|
||||
await routeRefreshWorkflow.rebuildRoutesOnly();
|
||||
const errorMessage = refreshResult.errorMessage || `${input.provider} model discovery failed`;
|
||||
markOauthSessionError(input.state, errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (previousAccount) {
|
||||
await db.update(schema.accounts).set({
|
||||
status: 'active',
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.accounts.id, account.id)).run();
|
||||
}
|
||||
|
||||
await routeRefreshWorkflow.rebuildRoutesOnly();
|
||||
markOauthSessionSuccess(input.state, {
|
||||
accountId: account.id,
|
||||
siteId: site.id,
|
||||
@@ -774,16 +885,35 @@ export async function listOauthConnections(options: {
|
||||
modelMap.set(row.accountId, list);
|
||||
}
|
||||
|
||||
const routeParticipationByAccount = await listOauthRouteUnitsByAccountIds(accountIds);
|
||||
const routeUnitIds = Array.from(new Set(
|
||||
Array.from(routeParticipationByAccount.values())
|
||||
.map((item) => item.id)
|
||||
.filter((id): id is number => Number.isFinite(id) && id > 0),
|
||||
));
|
||||
|
||||
const routeChannelRows = await db.select({
|
||||
accountId: schema.routeChannels.accountId,
|
||||
oauthRouteUnitId: schema.routeChannels.oauthRouteUnitId,
|
||||
count: sql<number>`COUNT(*)`,
|
||||
}).from(schema.routeChannels)
|
||||
.where(inArray(schema.routeChannels.accountId, accountIds))
|
||||
.groupBy(schema.routeChannels.accountId)
|
||||
.where(routeUnitIds.length > 0
|
||||
? or(
|
||||
inArray(schema.routeChannels.accountId, accountIds),
|
||||
inArray(schema.routeChannels.oauthRouteUnitId, routeUnitIds),
|
||||
)
|
||||
: inArray(schema.routeChannels.accountId, accountIds))
|
||||
.groupBy(schema.routeChannels.accountId, schema.routeChannels.oauthRouteUnitId)
|
||||
.all();
|
||||
const routeChannelCountByAccount = new Map<number, number>();
|
||||
const routeChannelCountByRouteUnit = new Map<number, number>();
|
||||
for (const row of routeChannelRows) {
|
||||
routeChannelCountByAccount.set(row.accountId, row.count ?? 0);
|
||||
if (typeof row.accountId === 'number' && row.accountId > 0) {
|
||||
routeChannelCountByAccount.set(row.accountId, row.count ?? 0);
|
||||
}
|
||||
if (typeof row.oauthRouteUnitId === 'number' && row.oauthRouteUnitId > 0) {
|
||||
routeChannelCountByRouteUnit.set(row.oauthRouteUnitId, row.count ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
const items = rows.flatMap((row) => {
|
||||
@@ -795,6 +925,17 @@ export async function listOauthConnections(options: {
|
||||
|| row.accounts.status !== 'active'
|
||||
|| row.sites.status !== 'active'
|
||||
) ? 'abnormal' : 'healthy';
|
||||
const routeUnit = routeParticipationByAccount.get(row.accounts.id) || null;
|
||||
const routeParticipation = routeUnit
|
||||
? {
|
||||
kind: routeUnit.kind,
|
||||
id: routeUnit.id,
|
||||
routeUnitId: routeUnit.id,
|
||||
name: routeUnit.name,
|
||||
strategy: routeUnit.strategy,
|
||||
memberCount: routeUnit.memberCount,
|
||||
}
|
||||
: null;
|
||||
return [{
|
||||
accountId: row.accounts.id,
|
||||
siteId: row.sites.id,
|
||||
@@ -808,11 +949,15 @@ export async function listOauthConnections(options: {
|
||||
modelsPreview: models.slice(0, 10),
|
||||
quota: buildQuotaSnapshotFromOauthInfo(oauth),
|
||||
status,
|
||||
routeChannelCount: routeChannelCountByAccount.get(row.accounts.id) || 0,
|
||||
routeChannelCount: routeUnit?.kind === 'route_unit'
|
||||
? (routeChannelCountByRouteUnit.get(routeUnit.id) || 0)
|
||||
: (routeChannelCountByAccount.get(row.accounts.id) || 0),
|
||||
lastModelSyncAt: oauth.lastModelSyncAt,
|
||||
lastModelSyncError: oauth.lastModelSyncError,
|
||||
proxyUrl: getProxyUrlFromExtraConfig(row.accounts.extraConfig),
|
||||
useSystemProxy: getUseSystemProxyFromExtraConfig(row.accounts.extraConfig),
|
||||
routeParticipation,
|
||||
routeUnit,
|
||||
site: {
|
||||
id: row.sites.id,
|
||||
name: row.sites.name,
|
||||
@@ -880,13 +1025,20 @@ export async function refreshOauthConnectionQuotaBatch(accountIds: number[]) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function importOauthConnectionsFromNativeJson(data: unknown) {
|
||||
if (!isRecord(data)) {
|
||||
export async function importOauthConnectionsFromNativeJson(input: {
|
||||
data?: unknown;
|
||||
items?: unknown[];
|
||||
proxyUrl?: string | null;
|
||||
useSystemProxy?: boolean;
|
||||
}) {
|
||||
const payloadItems = normalizeImportedOauthJsonItems(input);
|
||||
const continueOnItemFailure = Array.isArray(input.items);
|
||||
if (payloadItems.length <= 0) {
|
||||
throwOauthImportValidationError('data must be a native oauth json object');
|
||||
}
|
||||
|
||||
const payload = data as ImportedNativeOauthJson;
|
||||
|
||||
if (payloadItems.length > MAX_OAUTH_IMPORT_BATCH_SIZE) {
|
||||
throwOauthImportValidationError(`oauth import supports at most ${MAX_OAUTH_IMPORT_BATCH_SIZE} items`);
|
||||
}
|
||||
const items: Array<{
|
||||
name: string;
|
||||
status: 'imported' | 'skipped' | 'failed';
|
||||
@@ -894,68 +1046,109 @@ export async function importOauthConnectionsFromNativeJson(data: unknown) {
|
||||
provider?: string;
|
||||
message?: string;
|
||||
}> = [];
|
||||
const changedAccountIds: number[] = [];
|
||||
let resolvedProvider: OAuthProviderId | null = null;
|
||||
let persistedAccount: Awaited<ReturnType<typeof upsertOauthAccount>> | null = null;
|
||||
try {
|
||||
const resolved = resolveImportedNativeOauthIdentity(payload);
|
||||
resolvedProvider = resolved.provider;
|
||||
const definition = getOAuthProviderDefinition(resolved.provider);
|
||||
if (!definition) {
|
||||
throw new Error(`unsupported oauth provider: ${resolved.provider}`);
|
||||
}
|
||||
const persisted = await upsertOauthAccount({
|
||||
definition,
|
||||
exchange: resolved.exchange,
|
||||
persistedStatus: resolved.disabled ? 'disabled' : 'active',
|
||||
});
|
||||
persistedAccount = persisted;
|
||||
changedAccountIds.push(persisted.account.id);
|
||||
items.push({
|
||||
name: resolved.name,
|
||||
status: 'imported',
|
||||
provider: resolved.provider,
|
||||
accountId: persisted.account.id,
|
||||
});
|
||||
} catch (error: any) {
|
||||
items.push({
|
||||
name: asNonEmptyString(payload.email) || asNonEmptyString(payload.account_key) || asNonEmptyString(payload.account_id) || asNonEmptyString(payload.type) || 'unknown',
|
||||
status: 'failed',
|
||||
provider: asNonEmptyString(payload.type) || undefined,
|
||||
message: error?.message || 'oauth import failed',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
let imported = 0;
|
||||
|
||||
if (changedAccountIds.length > 0) {
|
||||
const importedAccountId = changedAccountIds[0]!;
|
||||
if (persistedAccount && (persistedAccount.account.status || 'active') === 'active') {
|
||||
const refreshResult = await refreshModelsForAccount(
|
||||
importedAccountId,
|
||||
persistedAccount.previousAccount ? { allowInactive: true } : undefined,
|
||||
);
|
||||
if (refreshResult.status !== 'success') {
|
||||
await revertPersistedOauthAccount({
|
||||
accountId: importedAccountId,
|
||||
created: persistedAccount.created,
|
||||
previousAccount: persistedAccount.previousAccount,
|
||||
});
|
||||
await routeRefreshWorkflow.rebuildRoutesOnly();
|
||||
throw new Error(refreshResult.errorMessage || `${resolvedProvider || 'oauth'} model discovery failed`);
|
||||
for (const rawPayload of payloadItems) {
|
||||
if (!isRecord(rawPayload)) {
|
||||
throwOauthImportValidationError('data must be a native oauth json object');
|
||||
}
|
||||
const payload = rawPayload as ImportedNativeOauthJson;
|
||||
let resolvedIdentity: ReturnType<typeof resolveImportedNativeOauthIdentity> | null = null;
|
||||
try {
|
||||
resolvedIdentity = resolveImportedNativeOauthIdentity(payload);
|
||||
const definition = getOAuthProviderDefinition(resolvedIdentity.provider);
|
||||
if (!definition) {
|
||||
throw new Error(`unsupported oauth provider: ${resolvedIdentity.provider}`);
|
||||
}
|
||||
const persisted = await activatePersistedOauthAccount({
|
||||
definition,
|
||||
exchange: resolvedIdentity.exchange,
|
||||
proxyUrl: input.proxyUrl,
|
||||
useSystemProxy: input.useSystemProxy,
|
||||
persistedStatus: resolvedIdentity.disabled ? 'disabled' : 'active',
|
||||
});
|
||||
imported += 1;
|
||||
items.push({
|
||||
name: resolvedIdentity.name,
|
||||
status: 'imported',
|
||||
provider: resolvedIdentity.provider,
|
||||
accountId: persisted.account?.id,
|
||||
});
|
||||
} catch (error: any) {
|
||||
items.push({
|
||||
name: resolvedIdentity?.name
|
||||
|| asNonEmptyString(payload.email)
|
||||
|| asNonEmptyString(payload.account_key)
|
||||
|| asNonEmptyString(payload.account_id)
|
||||
|| asNonEmptyString(payload.type)
|
||||
|| 'unknown',
|
||||
status: 'failed',
|
||||
provider: resolvedIdentity?.provider || asNonEmptyString(payload.type) || undefined,
|
||||
message: error?.message || 'oauth import failed',
|
||||
});
|
||||
if (!continueOnItemFailure) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await routeRefreshWorkflow.rebuildRoutesOnly();
|
||||
}
|
||||
|
||||
const failed = items.filter((item) => item.status === 'failed').length;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
imported: 1,
|
||||
success: failed === 0,
|
||||
imported,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
failed,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateOauthConnectionProxySettings(input: {
|
||||
accountId: number;
|
||||
proxyUrl?: string | null;
|
||||
useSystemProxy?: boolean;
|
||||
}) {
|
||||
const account = await db.select().from(schema.accounts)
|
||||
.where(eq(schema.accounts.id, input.accountId))
|
||||
.get();
|
||||
if (!account) {
|
||||
throw new Error('oauth account not found');
|
||||
}
|
||||
const oauth = getOauthInfoFromAccount(account);
|
||||
if (!oauth) {
|
||||
throw new Error('account is not managed by oauth');
|
||||
}
|
||||
|
||||
const extraConfig = mergeAccountExtraConfig(account.extraConfig, {
|
||||
...(input.proxyUrl !== undefined ? { proxyUrl: input.proxyUrl } : {}),
|
||||
...(input.useSystemProxy !== undefined ? { useSystemProxy: input.useSystemProxy } : {}),
|
||||
});
|
||||
const updatedAt = new Date().toISOString();
|
||||
|
||||
await db.update(schema.accounts).set({
|
||||
extraConfig,
|
||||
updatedAt,
|
||||
}).where(eq(schema.accounts.id, input.accountId)).run();
|
||||
|
||||
const refreshResult = await refreshModelsForAccount(input.accountId, { allowInactive: true });
|
||||
await routeRefreshWorkflow.rebuildRoutesOnly();
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
accountId: input.accountId,
|
||||
proxyUrl: getProxyUrlFromExtraConfig(extraConfig),
|
||||
useSystemProxy: getUseSystemProxyFromExtraConfig(extraConfig),
|
||||
refreshedRoutes: true,
|
||||
modelRefresh: {
|
||||
success: refreshResult.status === 'success',
|
||||
status: refreshResult.status,
|
||||
errorMessage: refreshResult.status === 'success'
|
||||
? null
|
||||
: (refreshResult.errorMessage || '模型刷新失败'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function startOauthRebindFlow(
|
||||
accountId: number,
|
||||
options?: { requestOrigin?: string; proxyUrl?: string | null; useSystemProxy?: boolean },
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
type DbModule = typeof import('../db/index.js');
|
||||
type TokenRouterModule = typeof import('./tokenRouter.js');
|
||||
|
||||
describe('TokenRouter oauth route units', () => {
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let TokenRouter: TokenRouterModule['TokenRouter'];
|
||||
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
|
||||
let tokenRouterTestUtils: TokenRouterModule['__tokenRouterTestUtils'];
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-token-router-oauth-route-units-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../db/migrate.js');
|
||||
const dbModule = await import('../db/index.js');
|
||||
const tokenRouterModule = await import('./tokenRouter.js');
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
TokenRouter = tokenRouterModule.TokenRouter;
|
||||
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
|
||||
tokenRouterTestUtils = tokenRouterModule.__tokenRouterTestUtils;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.delete(schema.routeChannels).run();
|
||||
await db.delete(schema.tokenRoutes).run();
|
||||
await db.delete(schema.modelAvailability).run();
|
||||
await db.delete(schema.accountTokens).run();
|
||||
await db.delete(schema.oauthRouteUnitMembers).run();
|
||||
await db.delete(schema.oauthRouteUnits).run();
|
||||
await db.delete(schema.accounts).run();
|
||||
await db.delete(schema.sites).run();
|
||||
invalidateTokenRouterCache();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
invalidateTokenRouterCache();
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('round robins across healthy oauth route unit members while keeping a single outer channel', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'ChatGPT Codex OAuth',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const accountA = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'rr-a@example.com',
|
||||
accessToken: 'oauth-access-token-a',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
oauthProvider: 'codex',
|
||||
oauthAccountKey: 'chatgpt-rr-a',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: { provider: 'codex', accountId: 'chatgpt-rr-a', email: 'rr-a@example.com' },
|
||||
}),
|
||||
}).returning().get();
|
||||
const accountB = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'rr-b@example.com',
|
||||
accessToken: 'oauth-access-token-b',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
oauthProvider: 'codex',
|
||||
oauthAccountKey: 'chatgpt-rr-b',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: { provider: 'codex', accountId: 'chatgpt-rr-b', email: 'rr-b@example.com' },
|
||||
}),
|
||||
}).returning().get();
|
||||
|
||||
const route = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'gpt-5.4',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
const routeUnit = await db.insert(schema.oauthRouteUnits).values({
|
||||
siteId: site.id,
|
||||
provider: 'codex',
|
||||
name: 'Codex RR Pool',
|
||||
strategy: 'round_robin',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
await db.insert(schema.oauthRouteUnitMembers).values([
|
||||
{ unitId: routeUnit.id, accountId: accountA.id, sortOrder: 0 },
|
||||
{ unitId: routeUnit.id, accountId: accountB.id, sortOrder: 1 },
|
||||
]).run();
|
||||
await db.insert(schema.modelAvailability).values([
|
||||
{ accountId: accountA.id, modelName: 'gpt-5.4', available: true },
|
||||
{ accountId: accountB.id, modelName: 'gpt-5.4', available: true },
|
||||
]).run();
|
||||
const channel = await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountA.id,
|
||||
tokenId: null,
|
||||
oauthRouteUnitId: routeUnit.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
}).returning().get();
|
||||
|
||||
const router = new TokenRouter();
|
||||
const first = await router.selectChannel('gpt-5.4');
|
||||
const second = await router.selectChannel('gpt-5.4');
|
||||
|
||||
expect(first?.channel.id).toBe(channel.id);
|
||||
expect(second?.channel.id).toBe(channel.id);
|
||||
expect(first?.account.id).toBe(accountA.id);
|
||||
expect(second?.account.id).toBe(accountB.id);
|
||||
});
|
||||
|
||||
it('sticks to the same oauth route unit member until it becomes unavailable', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'ChatGPT Codex OAuth',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const accountA = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'sticky-a@example.com',
|
||||
accessToken: 'oauth-access-token-a',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
oauthProvider: 'codex',
|
||||
oauthAccountKey: 'chatgpt-sticky-a',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: { provider: 'codex', accountId: 'chatgpt-sticky-a', email: 'sticky-a@example.com' },
|
||||
}),
|
||||
}).returning().get();
|
||||
const accountB = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'sticky-b@example.com',
|
||||
accessToken: 'oauth-access-token-b',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
oauthProvider: 'codex',
|
||||
oauthAccountKey: 'chatgpt-sticky-b',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: { provider: 'codex', accountId: 'chatgpt-sticky-b', email: 'sticky-b@example.com' },
|
||||
}),
|
||||
}).returning().get();
|
||||
|
||||
const route = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'gpt-5.4',
|
||||
routingStrategy: 'stable_first',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
const routeUnit = await db.insert(schema.oauthRouteUnits).values({
|
||||
siteId: site.id,
|
||||
provider: 'codex',
|
||||
name: 'Codex Sticky Pool',
|
||||
strategy: 'stick_until_unavailable',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
await db.insert(schema.oauthRouteUnitMembers).values([
|
||||
{ unitId: routeUnit.id, accountId: accountA.id, sortOrder: 0 },
|
||||
{ unitId: routeUnit.id, accountId: accountB.id, sortOrder: 1 },
|
||||
]).run();
|
||||
await db.insert(schema.modelAvailability).values([
|
||||
{ accountId: accountA.id, modelName: 'gpt-5.4', available: true },
|
||||
{ accountId: accountB.id, modelName: 'gpt-5.4', available: true },
|
||||
]).run();
|
||||
const channel = await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountA.id,
|
||||
tokenId: null,
|
||||
oauthRouteUnitId: routeUnit.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
}).returning().get();
|
||||
|
||||
const router = new TokenRouter();
|
||||
const first = await router.selectChannel('gpt-5.4');
|
||||
const second = await router.selectChannel('gpt-5.4');
|
||||
expect(first?.account.id).toBe(accountA.id);
|
||||
expect(second?.account.id).toBe(accountA.id);
|
||||
|
||||
await router.recordFailure(channel.id, { status: 503, errorText: 'unavailable' }, accountA.id);
|
||||
const third = await router.selectChannel('gpt-5.4');
|
||||
expect(third?.channel.id).toBe(channel.id);
|
||||
expect(third?.account.id).toBe(accountB.id);
|
||||
});
|
||||
|
||||
it('keeps unrelated stable-first cache entries when pooled member state updates', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'ChatGPT Codex OAuth',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const accountA = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'cache-a@example.com',
|
||||
accessToken: 'oauth-cache-access-a',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
oauthProvider: 'codex',
|
||||
oauthAccountKey: 'chatgpt-cache-a',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: { provider: 'codex', accountId: 'chatgpt-cache-a', email: 'cache-a@example.com' },
|
||||
}),
|
||||
}).returning().get();
|
||||
const accountB = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'cache-b@example.com',
|
||||
accessToken: 'oauth-cache-access-b',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
oauthProvider: 'codex',
|
||||
oauthAccountKey: 'chatgpt-cache-b',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: { provider: 'codex', accountId: 'chatgpt-cache-b', email: 'cache-b@example.com' },
|
||||
}),
|
||||
}).returning().get();
|
||||
|
||||
const route = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'gpt-5.4',
|
||||
routingStrategy: 'weighted',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
const routeUnit = await db.insert(schema.oauthRouteUnits).values({
|
||||
siteId: site.id,
|
||||
provider: 'codex',
|
||||
name: 'Cache Pool',
|
||||
strategy: 'round_robin',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
await db.insert(schema.oauthRouteUnitMembers).values([
|
||||
{ unitId: routeUnit.id, accountId: accountA.id, sortOrder: 0 },
|
||||
{ unitId: routeUnit.id, accountId: accountB.id, sortOrder: 1 },
|
||||
]).run();
|
||||
await db.insert(schema.modelAvailability).values([
|
||||
{ accountId: accountA.id, modelName: 'gpt-5.4', available: true },
|
||||
{ accountId: accountB.id, modelName: 'gpt-5.4', available: true },
|
||||
]).run();
|
||||
const channel = await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountA.id,
|
||||
tokenId: null,
|
||||
oauthRouteUnitId: routeUnit.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
}).returning().get();
|
||||
|
||||
tokenRouterTestUtils.rememberStableFirstSiteSelectionForKey('999:other-model', 77);
|
||||
expect(tokenRouterTestUtils.getStableFirstRotationCacheSize()).toBe(1);
|
||||
|
||||
const router = new TokenRouter();
|
||||
const selected = await router.selectChannel('gpt-5.4');
|
||||
expect(selected?.channel.id).toBe(channel.id);
|
||||
expect(tokenRouterTestUtils.getStableFirstRotationCacheSize()).toBe(1);
|
||||
|
||||
await router.recordFailure(channel.id, { status: 503, errorText: 'pooled unavailable' }, accountA.id);
|
||||
expect(tokenRouterTestUtils.getStableFirstRotationCacheSize()).toBe(1);
|
||||
});
|
||||
|
||||
it('fails closed when a pooled channel has no loaded members', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'ChatGPT Codex OAuth',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'missing-members@example.com',
|
||||
accessToken: 'oauth-access-token-missing-members',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
oauthProvider: 'codex',
|
||||
oauthAccountKey: 'chatgpt-missing-members',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: { provider: 'codex', accountId: 'chatgpt-missing-members', email: 'missing-members@example.com' },
|
||||
}),
|
||||
}).returning().get();
|
||||
|
||||
const route = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'gpt-5.4',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
const routeUnit = await db.insert(schema.oauthRouteUnits).values({
|
||||
siteId: site.id,
|
||||
provider: 'codex',
|
||||
name: 'Broken Pool',
|
||||
strategy: 'round_robin',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: account.id,
|
||||
tokenId: null,
|
||||
oauthRouteUnitId: routeUnit.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
}).run();
|
||||
|
||||
const router = new TokenRouter();
|
||||
const selected = await router.selectChannel('gpt-5.4');
|
||||
|
||||
expect(selected).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the api token fallback for pooled oauth members when the access token is blank', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'ChatGPT Codex OAuth',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const accountA = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'fallback-a@example.com',
|
||||
accessToken: ' ',
|
||||
apiToken: 'oauth-api-token-a',
|
||||
status: 'active',
|
||||
oauthProvider: 'codex',
|
||||
oauthAccountKey: 'chatgpt-fallback-a',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: { provider: 'codex', accountId: 'chatgpt-fallback-a', email: 'fallback-a@example.com' },
|
||||
}),
|
||||
}).returning().get();
|
||||
const accountB = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'fallback-b@example.com',
|
||||
accessToken: 'oauth-access-token-b',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
oauthProvider: 'codex',
|
||||
oauthAccountKey: 'chatgpt-fallback-b',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: { provider: 'codex', accountId: 'chatgpt-fallback-b', email: 'fallback-b@example.com' },
|
||||
}),
|
||||
}).returning().get();
|
||||
|
||||
const route = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'gpt-5.4',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
const routeUnit = await db.insert(schema.oauthRouteUnits).values({
|
||||
siteId: site.id,
|
||||
provider: 'codex',
|
||||
name: 'Fallback Pool',
|
||||
strategy: 'round_robin',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
await db.insert(schema.oauthRouteUnitMembers).values([
|
||||
{ unitId: routeUnit.id, accountId: accountA.id, sortOrder: 0 },
|
||||
{ unitId: routeUnit.id, accountId: accountB.id, sortOrder: 1 },
|
||||
]).run();
|
||||
await db.insert(schema.modelAvailability).values([
|
||||
{ accountId: accountA.id, modelName: 'gpt-5.4', available: true },
|
||||
{ accountId: accountB.id, modelName: 'gpt-5.4', available: true },
|
||||
]).run();
|
||||
const channel = await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountA.id,
|
||||
tokenId: null,
|
||||
oauthRouteUnitId: routeUnit.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
}).returning().get();
|
||||
|
||||
const router = new TokenRouter();
|
||||
const selected = await router.selectChannel('gpt-5.4');
|
||||
|
||||
expect(selected?.channel.id).toBe(channel.id);
|
||||
expect(selected?.account.id).toBe(accountA.id);
|
||||
expect(selected?.tokenValue).toBe('oauth-api-token-a');
|
||||
});
|
||||
|
||||
it('does not immediately retry the same pooled member during failover when it just failed', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'ChatGPT Codex OAuth',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const accountA = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'failover-a@example.com',
|
||||
accessToken: 'oauth-access-token-a',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
oauthProvider: 'codex',
|
||||
oauthAccountKey: 'chatgpt-failover-a',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: { provider: 'codex', accountId: 'chatgpt-failover-a', email: 'failover-a@example.com' },
|
||||
}),
|
||||
}).returning().get();
|
||||
const accountB = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'failover-b@example.com',
|
||||
accessToken: 'oauth-access-token-b',
|
||||
apiToken: null,
|
||||
status: 'disabled',
|
||||
oauthProvider: 'codex',
|
||||
oauthAccountKey: 'chatgpt-failover-b',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: { provider: 'codex', accountId: 'chatgpt-failover-b', email: 'failover-b@example.com' },
|
||||
}),
|
||||
}).returning().get();
|
||||
|
||||
const route = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'gpt-5.4',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
const routeUnit = await db.insert(schema.oauthRouteUnits).values({
|
||||
siteId: site.id,
|
||||
provider: 'codex',
|
||||
name: 'Failover Pool',
|
||||
strategy: 'round_robin',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
await db.insert(schema.oauthRouteUnitMembers).values([
|
||||
{ unitId: routeUnit.id, accountId: accountA.id, sortOrder: 0 },
|
||||
{ unitId: routeUnit.id, accountId: accountB.id, sortOrder: 1 },
|
||||
]).run();
|
||||
await db.insert(schema.modelAvailability).values([
|
||||
{ accountId: accountA.id, modelName: 'gpt-5.4', available: true },
|
||||
{ accountId: accountB.id, modelName: 'gpt-5.4', available: true },
|
||||
]).run();
|
||||
const channel = await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountA.id,
|
||||
tokenId: null,
|
||||
oauthRouteUnitId: routeUnit.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
}).returning().get();
|
||||
|
||||
const router = new TokenRouter();
|
||||
const first = await router.selectChannel('gpt-5.4');
|
||||
expect(first?.account.id).toBe(accountA.id);
|
||||
|
||||
await router.recordFailure(channel.id, { status: 503, errorText: 'upstream unavailable' }, accountA.id);
|
||||
const failover = await router.selectNextChannel('gpt-5.4', [channel.id]);
|
||||
|
||||
expect(failover).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -190,6 +190,181 @@ describe('TokenRouter selection scoring', () => {
|
||||
await expect(router.selectPreferredChannel('gpt-5.2', preferredChannel.id)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('round-robins inside an oauth route unit while keeping one outer channel', async () => {
|
||||
const route = await createRoute('gpt-5.4');
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'oauth-pool-site',
|
||||
url: 'https://oauth-pool-site.example.com',
|
||||
platform: 'codex',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
const accountA = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'pool-a@example.com',
|
||||
accessToken: 'oauth-access-a',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
oauthProvider: 'codex',
|
||||
oauthAccountKey: 'pool-a',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: 'codex',
|
||||
accountId: 'pool-a',
|
||||
accountKey: 'pool-a',
|
||||
email: 'pool-a@example.com',
|
||||
},
|
||||
}),
|
||||
}).returning().get();
|
||||
const accountB = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'pool-b@example.com',
|
||||
accessToken: 'oauth-access-b',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
oauthProvider: 'codex',
|
||||
oauthAccountKey: 'pool-b',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: 'codex',
|
||||
accountId: 'pool-b',
|
||||
accountKey: 'pool-b',
|
||||
email: 'pool-b@example.com',
|
||||
},
|
||||
}),
|
||||
}).returning().get();
|
||||
const unit = await db.insert(schema.oauthRouteUnits).values({
|
||||
siteId: site.id,
|
||||
provider: 'codex',
|
||||
name: 'Codex Pool A',
|
||||
strategy: 'round_robin',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
await db.insert(schema.oauthRouteUnitMembers).values([
|
||||
{
|
||||
unitId: unit.id,
|
||||
accountId: accountA.id,
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
unitId: unit.id,
|
||||
accountId: accountB.id,
|
||||
sortOrder: 1,
|
||||
},
|
||||
]).run();
|
||||
const channel = await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountA.id,
|
||||
oauthRouteUnitId: unit.id,
|
||||
tokenId: null,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
}).returning().get();
|
||||
|
||||
const router = new TokenRouter();
|
||||
const first = await router.selectChannel('gpt-5.4');
|
||||
const second = await router.selectChannel('gpt-5.4');
|
||||
|
||||
expect(first?.channel.id).toBe(channel.id);
|
||||
expect(second?.channel.id).toBe(channel.id);
|
||||
expect(first?.account.id).toBe(accountA.id);
|
||||
expect(second?.account.id).toBe(accountB.id);
|
||||
});
|
||||
|
||||
it('sticks to one oauth route unit member until that member becomes unavailable', async () => {
|
||||
const route = await createRoute('gpt-5.4');
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'oauth-stick-site',
|
||||
url: 'https://oauth-stick-site.example.com',
|
||||
platform: 'codex',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
const accountA = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'stick-a@example.com',
|
||||
accessToken: 'oauth-stick-a',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
oauthProvider: 'codex',
|
||||
oauthAccountKey: 'stick-a',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: 'codex',
|
||||
accountId: 'stick-a',
|
||||
accountKey: 'stick-a',
|
||||
email: 'stick-a@example.com',
|
||||
},
|
||||
}),
|
||||
}).returning().get();
|
||||
const accountB = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'stick-b@example.com',
|
||||
accessToken: 'oauth-stick-b',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
oauthProvider: 'codex',
|
||||
oauthAccountKey: 'stick-b',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: 'codex',
|
||||
accountId: 'stick-b',
|
||||
accountKey: 'stick-b',
|
||||
email: 'stick-b@example.com',
|
||||
},
|
||||
}),
|
||||
}).returning().get();
|
||||
const unit = await db.insert(schema.oauthRouteUnits).values({
|
||||
siteId: site.id,
|
||||
provider: 'codex',
|
||||
name: 'Codex Stick Pool',
|
||||
strategy: 'stick_until_unavailable',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
await db.insert(schema.oauthRouteUnitMembers).values([
|
||||
{
|
||||
unitId: unit.id,
|
||||
accountId: accountA.id,
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
unitId: unit.id,
|
||||
accountId: accountB.id,
|
||||
sortOrder: 1,
|
||||
},
|
||||
]).run();
|
||||
const channel = await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountA.id,
|
||||
oauthRouteUnitId: unit.id,
|
||||
tokenId: null,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
}).returning().get();
|
||||
|
||||
const router = new TokenRouter();
|
||||
const first = await router.selectChannel('gpt-5.4');
|
||||
const second = await router.selectChannel('gpt-5.4');
|
||||
|
||||
expect(first?.account.id).toBe(accountA.id);
|
||||
expect(second?.account.id).toBe(accountA.id);
|
||||
|
||||
await router.recordFailure(channel.id, {
|
||||
status: 429,
|
||||
errorText: 'rate_limit',
|
||||
modelName: 'gpt-5.4',
|
||||
}, accountA.id);
|
||||
|
||||
const third = await router.selectChannel('gpt-5.4');
|
||||
expect(third?.account.id).toBe(accountB.id);
|
||||
});
|
||||
|
||||
it('avoids recently failed candidates by default when healthy alternatives exist', () => {
|
||||
const nowMs = Date.now();
|
||||
const filtered = filterRecentlyFailedCandidates([
|
||||
|
||||
@@ -17,6 +17,12 @@ import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from '.
|
||||
import { isUsableAccountToken } from './accountTokenService.js';
|
||||
import { getOauthInfoFromAccount } from './oauth/oauthAccount.js';
|
||||
import { parseCodexQuotaResetHint } from './oauth/quota.js';
|
||||
import {
|
||||
getOauthRouteUnitStrategyLabel,
|
||||
listOauthRouteUnitMembersByUnitIds,
|
||||
loadOauthRouteUnitSummariesByIds,
|
||||
type OAuthRouteUnitSummary,
|
||||
} from './oauth/routeUnitService.js';
|
||||
import {
|
||||
isExactTokenRouteModelPattern,
|
||||
isTokenRouteRegexPattern,
|
||||
@@ -37,6 +43,13 @@ interface RouteMatch {
|
||||
account: typeof schema.accounts.$inferSelect;
|
||||
site: typeof schema.sites.$inferSelect;
|
||||
token: typeof schema.accountTokens.$inferSelect | null;
|
||||
routeUnit: OAuthRouteUnitSummary | null;
|
||||
routeUnitMembers: Array<{
|
||||
member: typeof schema.oauthRouteUnitMembers.$inferSelect;
|
||||
account: typeof schema.accounts.$inferSelect;
|
||||
site: typeof schema.sites.$inferSelect;
|
||||
token: null;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -1161,6 +1174,16 @@ async function loadRouteMatch(route: RouteRow, nowMs = Date.now()): Promise<Rout
|
||||
.all()
|
||||
: [];
|
||||
|
||||
const oauthRouteUnitIds: number[] = Array.from(new Set<number>(
|
||||
channels
|
||||
.map((row) => Number(row.route_channels.oauthRouteUnitId))
|
||||
.filter((id): id is number => Number.isFinite(id) && id > 0),
|
||||
));
|
||||
const [routeUnitSummaries, routeUnitMembersByUnitId] = await Promise.all([
|
||||
loadOauthRouteUnitSummariesByIds(oauthRouteUnitIds),
|
||||
listOauthRouteUnitMembersByUnitIds(oauthRouteUnitIds),
|
||||
]);
|
||||
|
||||
const mapped = channels.map((row) => ({
|
||||
channel: {
|
||||
...row.route_channels,
|
||||
@@ -1171,6 +1194,17 @@ async function loadRouteMatch(route: RouteRow, nowMs = Date.now()): Promise<Rout
|
||||
account: row.accounts,
|
||||
site: row.sites,
|
||||
token: row.account_tokens,
|
||||
routeUnit: row.route_channels.oauthRouteUnitId
|
||||
? (routeUnitSummaries.get(row.route_channels.oauthRouteUnitId) || null)
|
||||
: null,
|
||||
routeUnitMembers: row.route_channels.oauthRouteUnitId
|
||||
? (routeUnitMembersByUnitId.get(row.route_channels.oauthRouteUnitId) || []).map((member) => ({
|
||||
member: member.member,
|
||||
account: member.account,
|
||||
site: member.site,
|
||||
token: null,
|
||||
}))
|
||||
: [],
|
||||
}));
|
||||
|
||||
const match = { route, channels: mapped };
|
||||
@@ -1190,6 +1224,31 @@ function patchCachedChannel(channelId: number, apply: (channel: ChannelRow) => v
|
||||
}
|
||||
}
|
||||
|
||||
function clearStableFirstCachesForRoute(routeId: number): void {
|
||||
const routePrefix = `${routeId}:`;
|
||||
for (const key of stableFirstLastSelectedSiteByKey.keys()) {
|
||||
if (key.startsWith(routePrefix)) {
|
||||
stableFirstLastSelectedSiteByKey.delete(key);
|
||||
}
|
||||
}
|
||||
for (const key of stableFirstObservationProgressByKey.keys()) {
|
||||
if (key.startsWith(routePrefix)) {
|
||||
stableFirstObservationProgressByKey.delete(key);
|
||||
}
|
||||
}
|
||||
for (const key of stableFirstObservationSiteCooldownByKey.keys()) {
|
||||
if (key.startsWith(routePrefix)) {
|
||||
stableFirstObservationSiteCooldownByKey.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function invalidateRouteScopedCache(routeId: number): void {
|
||||
if (!Number.isFinite(routeId) || routeId <= 0) return;
|
||||
routeMatchCache.delete(routeId);
|
||||
clearStableFirstCachesForRoute(routeId);
|
||||
}
|
||||
|
||||
export function invalidateTokenRouterCache(): void {
|
||||
routeCacheSnapshot = {
|
||||
loadedAt: 0,
|
||||
@@ -1465,6 +1524,21 @@ function compareNullableTimeAsc(left?: string | null, right?: string | null): nu
|
||||
return leftMs - rightMs;
|
||||
}
|
||||
|
||||
function compareNullableTimeDesc(left?: string | null, right?: string | null): number {
|
||||
return compareNullableTimeAsc(right, left);
|
||||
}
|
||||
|
||||
function isOauthRouteUnitCandidate(candidate: RouteChannelCandidate): boolean {
|
||||
return !!candidate.routeUnit || !!candidate.channel.oauthRouteUnitId;
|
||||
}
|
||||
|
||||
function isOauthRouteUnitMemberCoolingDown(
|
||||
member: typeof schema.oauthRouteUnitMembers.$inferSelect,
|
||||
nowIso: string,
|
||||
): boolean {
|
||||
return !!member.cooldownUntil && member.cooldownUntil > nowIso;
|
||||
}
|
||||
|
||||
function compareStableFirstCandidateOrder(left: RouteChannelCandidate, right: RouteChannelCandidate): number {
|
||||
const selectionOrder = compareNullableTimeAsc(
|
||||
left.channel.lastSelectedAt || left.channel.lastUsedAt,
|
||||
@@ -2373,7 +2447,13 @@ export class TokenRouter {
|
||||
/**
|
||||
* Record success for a channel.
|
||||
*/
|
||||
async recordSuccess(channelId: number, latencyMs: number, cost: number, modelName?: string | null) {
|
||||
async recordSuccess(
|
||||
channelId: number,
|
||||
latencyMs: number,
|
||||
cost: number,
|
||||
modelName?: string | null,
|
||||
actualAccountId?: number,
|
||||
) {
|
||||
await ensureSiteRuntimeHealthStateLoaded();
|
||||
const row = await db.select()
|
||||
.from(schema.routeChannels)
|
||||
@@ -2387,6 +2467,45 @@ export class TokenRouter {
|
||||
const nextSuccessCount = (ch.successCount ?? 0) + 1;
|
||||
const nextTotalLatencyMs = (ch.totalLatencyMs ?? 0) + latencyMs;
|
||||
const nextTotalCost = (ch.totalCost ?? 0) + cost;
|
||||
if (typeof ch.oauthRouteUnitId === 'number' && ch.oauthRouteUnitId > 0) {
|
||||
const targetAccountId = Number.isFinite(actualAccountId) && (actualAccountId ?? 0) > 0
|
||||
? Math.trunc(actualAccountId!)
|
||||
: account.id;
|
||||
const memberRow = await db.select({
|
||||
member: schema.oauthRouteUnitMembers,
|
||||
account: schema.accounts,
|
||||
}).from(schema.oauthRouteUnitMembers)
|
||||
.innerJoin(schema.accounts, eq(schema.oauthRouteUnitMembers.accountId, schema.accounts.id))
|
||||
.where(and(
|
||||
eq(schema.oauthRouteUnitMembers.unitId, ch.oauthRouteUnitId),
|
||||
eq(schema.oauthRouteUnitMembers.accountId, targetAccountId),
|
||||
))
|
||||
.get();
|
||||
|
||||
if (memberRow) {
|
||||
const memberSuccessCount = (memberRow.member.successCount ?? 0) + 1;
|
||||
const memberTotalLatencyMs = (memberRow.member.totalLatencyMs ?? 0) + latencyMs;
|
||||
const memberTotalCost = (memberRow.member.totalCost ?? 0) + cost;
|
||||
await db.update(schema.oauthRouteUnitMembers).set({
|
||||
successCount: memberSuccessCount,
|
||||
totalLatencyMs: memberTotalLatencyMs,
|
||||
totalCost: memberTotalCost,
|
||||
lastUsedAt: nowIso,
|
||||
cooldownUntil: null,
|
||||
lastFailAt: null,
|
||||
consecutiveFailCount: 0,
|
||||
cooldownLevel: 0,
|
||||
updatedAt: nowIso,
|
||||
}).where(eq(schema.oauthRouteUnitMembers.id, memberRow.member.id)).run();
|
||||
recordSiteRuntimeSuccess(memberRow.account.siteId, latencyMs, modelName);
|
||||
} else {
|
||||
recordSiteRuntimeSuccess(account.siteId, latencyMs, modelName);
|
||||
}
|
||||
invalidateRouteScopedCache(ch.routeId);
|
||||
} else {
|
||||
recordSiteRuntimeSuccess(account.siteId, latencyMs, modelName);
|
||||
}
|
||||
|
||||
await db.update(schema.routeChannels).set({
|
||||
successCount: nextSuccessCount,
|
||||
totalLatencyMs: nextTotalLatencyMs,
|
||||
@@ -2408,11 +2527,14 @@ export class TokenRouter {
|
||||
channel.consecutiveFailCount = 0;
|
||||
channel.cooldownLevel = 0;
|
||||
});
|
||||
|
||||
recordSiteRuntimeSuccess(account.siteId, latencyMs, modelName);
|
||||
}
|
||||
|
||||
async recordProbeSuccess(channelId: number, latencyMs: number, modelName?: string | null) {
|
||||
async recordProbeSuccess(
|
||||
channelId: number,
|
||||
latencyMs: number,
|
||||
modelName?: string | null,
|
||||
actualAccountId?: number,
|
||||
) {
|
||||
await ensureSiteRuntimeHealthStateLoaded();
|
||||
const row = await db.select()
|
||||
.from(schema.routeChannels)
|
||||
@@ -2423,6 +2545,51 @@ export class TokenRouter {
|
||||
|
||||
const ch = row.route_channels;
|
||||
const account = row.accounts;
|
||||
if (typeof ch.oauthRouteUnitId === 'number' && ch.oauthRouteUnitId > 0) {
|
||||
const targetAccountId = Number.isFinite(actualAccountId) && (actualAccountId ?? 0) > 0
|
||||
? Math.trunc(actualAccountId!)
|
||||
: account.id;
|
||||
const nowIso = new Date().toISOString();
|
||||
const memberRow = await db.select({
|
||||
member: schema.oauthRouteUnitMembers,
|
||||
account: schema.accounts,
|
||||
}).from(schema.oauthRouteUnitMembers)
|
||||
.innerJoin(schema.accounts, eq(schema.oauthRouteUnitMembers.accountId, schema.accounts.id))
|
||||
.where(and(
|
||||
eq(schema.oauthRouteUnitMembers.unitId, ch.oauthRouteUnitId),
|
||||
eq(schema.oauthRouteUnitMembers.accountId, targetAccountId),
|
||||
))
|
||||
.get();
|
||||
|
||||
if (memberRow) {
|
||||
await db.update(schema.oauthRouteUnitMembers).set({
|
||||
cooldownUntil: null,
|
||||
lastFailAt: null,
|
||||
consecutiveFailCount: 0,
|
||||
cooldownLevel: 0,
|
||||
updatedAt: nowIso,
|
||||
}).where(eq(schema.oauthRouteUnitMembers.id, memberRow.member.id)).run();
|
||||
recordSiteRuntimeSuccess(memberRow.account.siteId, latencyMs, modelName);
|
||||
} else {
|
||||
recordSiteRuntimeSuccess(account.siteId, latencyMs, modelName);
|
||||
}
|
||||
|
||||
await db.update(schema.routeChannels).set({
|
||||
cooldownUntil: null,
|
||||
lastFailAt: null,
|
||||
consecutiveFailCount: 0,
|
||||
cooldownLevel: 0,
|
||||
}).where(eq(schema.routeChannels.id, channelId)).run();
|
||||
patchCachedChannel(channelId, (channel) => {
|
||||
channel.cooldownUntil = null;
|
||||
channel.lastFailAt = null;
|
||||
channel.consecutiveFailCount = 0;
|
||||
channel.cooldownLevel = 0;
|
||||
});
|
||||
invalidateRouteScopedCache(ch.routeId);
|
||||
return;
|
||||
}
|
||||
|
||||
const affectedChannelIds = await loadCredentialScopedChannelIds(ch, account.id);
|
||||
const needsChannelReset = !!ch.cooldownUntil
|
||||
|| !!ch.lastFailAt
|
||||
@@ -2528,7 +2695,11 @@ export class TokenRouter {
|
||||
/**
|
||||
* Record failure and set cooldown.
|
||||
*/
|
||||
async recordFailure(channelId: number, context: SiteRuntimeFailureContext | string | null = {}) {
|
||||
async recordFailure(
|
||||
channelId: number,
|
||||
context: SiteRuntimeFailureContext | string | null = {},
|
||||
actualAccountId?: number,
|
||||
) {
|
||||
await ensureSiteRuntimeHealthStateLoaded();
|
||||
const row = await db.select()
|
||||
.from(schema.routeChannels)
|
||||
@@ -2546,6 +2717,65 @@ export class TokenRouter {
|
||||
const normalizedContext: SiteRuntimeFailureContext = typeof context === 'string'
|
||||
? { modelName: context }
|
||||
: (context ?? {});
|
||||
if (typeof ch.oauthRouteUnitId === 'number' && ch.oauthRouteUnitId > 0) {
|
||||
const targetAccountId = Number.isFinite(actualAccountId) && (actualAccountId ?? 0) > 0
|
||||
? Math.trunc(actualAccountId!)
|
||||
: account.id;
|
||||
const memberRow = await db.select({
|
||||
member: schema.oauthRouteUnitMembers,
|
||||
account: schema.accounts,
|
||||
unit: schema.oauthRouteUnits,
|
||||
}).from(schema.oauthRouteUnitMembers)
|
||||
.innerJoin(schema.accounts, eq(schema.oauthRouteUnitMembers.accountId, schema.accounts.id))
|
||||
.innerJoin(schema.oauthRouteUnits, eq(schema.oauthRouteUnitMembers.unitId, schema.oauthRouteUnits.id))
|
||||
.where(and(
|
||||
eq(schema.oauthRouteUnitMembers.unitId, ch.oauthRouteUnitId),
|
||||
eq(schema.oauthRouteUnitMembers.accountId, targetAccountId),
|
||||
))
|
||||
.get();
|
||||
if (memberRow) {
|
||||
const shortWindowLimitCooldownUntil = resolveShortWindowLimitCooldown(memberRow.account, normalizedContext, nowMs);
|
||||
const failCount = shortWindowLimitCooldownUntil ? 0 : ((memberRow.member.failCount ?? 0) + 1);
|
||||
const routeUnitStrategy = memberRow.unit.strategy === 'stick_until_unavailable'
|
||||
? 'stick_until_unavailable'
|
||||
: 'round_robin';
|
||||
let cooldownUntil: string | null = null;
|
||||
let consecutiveFailCount = Math.max(0, memberRow.member.consecutiveFailCount ?? 0) + 1;
|
||||
let cooldownLevel = Math.max(0, memberRow.member.cooldownLevel ?? 0);
|
||||
|
||||
if (shortWindowLimitCooldownUntil) {
|
||||
cooldownUntil = shortWindowLimitCooldownUntil;
|
||||
consecutiveFailCount = 0;
|
||||
cooldownLevel = 0;
|
||||
} else if (routeUnitStrategy === 'round_robin') {
|
||||
if (consecutiveFailCount >= ROUND_ROBIN_FAILURE_THRESHOLD) {
|
||||
cooldownLevel = Math.min(cooldownLevel + 1, ROUND_ROBIN_COOLDOWN_LEVELS_SEC.length - 1);
|
||||
const cooldownSec = resolveRoundRobinCooldownSec(cooldownLevel);
|
||||
cooldownUntil = cooldownSec > 0
|
||||
? new Date(nowMs + clampFailureCooldownMs(cooldownSec * 1000)).toISOString()
|
||||
: null;
|
||||
consecutiveFailCount = 0;
|
||||
}
|
||||
} else {
|
||||
cooldownUntil = new Date(nowMs + resolveEffectiveFailureCooldownMs(failCount)).toISOString();
|
||||
consecutiveFailCount = 0;
|
||||
cooldownLevel = 0;
|
||||
}
|
||||
|
||||
await db.update(schema.oauthRouteUnitMembers).set({
|
||||
failCount,
|
||||
lastFailAt: nowIso,
|
||||
consecutiveFailCount,
|
||||
cooldownLevel,
|
||||
cooldownUntil,
|
||||
updatedAt: nowIso,
|
||||
}).where(eq(schema.oauthRouteUnitMembers.id, memberRow.member.id)).run();
|
||||
recordSiteRuntimeFailure(memberRow.account.siteId, normalizedContext, nowMs);
|
||||
invalidateRouteScopedCache(route.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const shortWindowLimitCooldownUntil = resolveShortWindowLimitCooldown(account, normalizedContext, nowMs);
|
||||
const failCount = shortWindowLimitCooldownUntil ? 0 : ((ch.failCount ?? 0) + 1);
|
||||
const routeStrategy = resolveRouteStrategy(route);
|
||||
@@ -2642,26 +2872,20 @@ export class TokenRouter {
|
||||
const breakerFiltered = filterSiteRuntimeBrokenCandidatesByModel(available, runtimeModelResolver, nowMs);
|
||||
const selected = this.selectRoundRobinCandidate(breakerFiltered.candidates);
|
||||
if (!selected) return null;
|
||||
|
||||
const tokenValue = this.resolveChannelTokenValue(selected);
|
||||
if (!tokenValue) return null;
|
||||
if (recordSelection) {
|
||||
await this.recordChannelSelection(selected.channel.id);
|
||||
}
|
||||
|
||||
const actualModel = resolveActualModelForSelectedChannel(
|
||||
return await this.finalizeSelectedCandidateForDispatch(
|
||||
selected,
|
||||
match,
|
||||
requestedModel,
|
||||
match.route,
|
||||
mappedModel,
|
||||
selected.channel.sourceModel,
|
||||
downstreamPolicy,
|
||||
recordSelection,
|
||||
nowIso,
|
||||
nowMs,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
excludeChannelIds,
|
||||
);
|
||||
|
||||
return {
|
||||
...selected,
|
||||
tokenValue,
|
||||
tokenName: selected.token?.name || 'default',
|
||||
actualModel,
|
||||
};
|
||||
}
|
||||
|
||||
if (routeStrategy === 'stable_first') {
|
||||
@@ -2694,32 +2918,20 @@ export class TokenRouter {
|
||||
shouldUseObservation ? `${rotationKey}:observe` : rotationKey,
|
||||
);
|
||||
if (!selected) return null;
|
||||
|
||||
const tokenValue = this.resolveChannelTokenValue(selected);
|
||||
if (!tokenValue) return null;
|
||||
if (recordSelection) {
|
||||
rememberStableFirstSiteSelectionForKey(shouldUseObservation ? `${rotationKey}:observe` : rotationKey, selected.site.id);
|
||||
updateStableFirstObservationProgress(rotationKey, {
|
||||
usedObservation: shouldUseObservation,
|
||||
selectedSiteId: selected.site.id,
|
||||
nowMs,
|
||||
});
|
||||
await this.recordChannelSelection(selected.channel.id);
|
||||
}
|
||||
|
||||
const actualModel = resolveActualModelForSelectedChannel(
|
||||
return await this.finalizeSelectedCandidateForDispatch(
|
||||
selected,
|
||||
match,
|
||||
requestedModel,
|
||||
match.route,
|
||||
mappedModel,
|
||||
selected.channel.sourceModel,
|
||||
downstreamPolicy,
|
||||
recordSelection,
|
||||
nowIso,
|
||||
nowMs,
|
||||
rotationKey,
|
||||
`${rotationKey}:observe`,
|
||||
shouldUseObservation,
|
||||
excludeChannelIds,
|
||||
);
|
||||
|
||||
return {
|
||||
...selected,
|
||||
tokenValue,
|
||||
tokenName: selected.token?.name || 'default',
|
||||
actualModel,
|
||||
};
|
||||
}
|
||||
|
||||
const layers = new Map<number, typeof available>();
|
||||
@@ -2741,23 +2953,21 @@ export class TokenRouter {
|
||||
nowMs,
|
||||
);
|
||||
if (!selected) continue;
|
||||
|
||||
const tokenValue = this.resolveChannelTokenValue(selected);
|
||||
if (!tokenValue) continue;
|
||||
|
||||
const actualModel = resolveActualModelForSelectedChannel(
|
||||
const resolved = await this.finalizeSelectedCandidateForDispatch(
|
||||
selected,
|
||||
match,
|
||||
requestedModel,
|
||||
match.route,
|
||||
mappedModel,
|
||||
selected.channel.sourceModel,
|
||||
downstreamPolicy,
|
||||
recordSelection,
|
||||
nowIso,
|
||||
nowMs,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
excludeChannelIds,
|
||||
);
|
||||
|
||||
return {
|
||||
...selected,
|
||||
tokenValue,
|
||||
tokenName: selected.token?.name || 'default',
|
||||
actualModel,
|
||||
};
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -2799,35 +3009,23 @@ export class TokenRouter {
|
||||
|
||||
const selected = breakerFiltered.candidates.find((candidate) => candidate.channel.id === preferredChannelId);
|
||||
if (!selected) return null;
|
||||
if (routeStrategy !== 'round_robin' && isChannelRecentlyFailed(selected.channel, nowMs)) {
|
||||
if (!isOauthRouteUnitCandidate(selected) && routeStrategy !== 'round_robin' && isChannelRecentlyFailed(selected.channel, nowMs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenValue = this.resolveChannelTokenValue(selected);
|
||||
if (!tokenValue) return null;
|
||||
if (recordSelection && (routeStrategy === 'round_robin' || routeStrategy === 'stable_first')) {
|
||||
if (routeStrategy === 'stable_first') {
|
||||
rememberStableFirstSiteSelectionForKey(
|
||||
this.buildStableFirstRotationKey(match.route.id, requestedModel),
|
||||
selected.site.id,
|
||||
);
|
||||
}
|
||||
await this.recordChannelSelection(selected.channel.id);
|
||||
}
|
||||
|
||||
const actualModel = resolveActualModelForSelectedChannel(
|
||||
return await this.finalizeSelectedCandidateForDispatch(
|
||||
selected,
|
||||
match,
|
||||
requestedModel,
|
||||
match.route,
|
||||
mappedModel,
|
||||
selected.channel.sourceModel,
|
||||
downstreamPolicy,
|
||||
recordSelection && (routeStrategy === 'round_robin' || routeStrategy === 'stable_first'),
|
||||
nowIso,
|
||||
nowMs,
|
||||
routeStrategy === 'stable_first' ? this.buildStableFirstRotationKey(match.route.id, requestedModel) : undefined,
|
||||
routeStrategy === 'stable_first' ? `${this.buildStableFirstRotationKey(match.route.id, requestedModel)}:observe` : undefined,
|
||||
false,
|
||||
excludeChannelIds,
|
||||
);
|
||||
|
||||
return {
|
||||
...selected,
|
||||
tokenValue,
|
||||
tokenName: selected.token?.name || 'default',
|
||||
actualModel,
|
||||
};
|
||||
}
|
||||
|
||||
private async findRoute(model: string, downstreamPolicy: DownstreamRoutingPolicy): Promise<RouteMatch | null> {
|
||||
@@ -2872,6 +3070,182 @@ export class TokenRouter {
|
||||
return await loadRouteMatch(route);
|
||||
}
|
||||
|
||||
private resolveRouteUnitMemberTokenValue(candidate: {
|
||||
account: typeof schema.accounts.$inferSelect;
|
||||
}): string | null {
|
||||
const oauthAccessToken = candidate.account.accessToken?.trim();
|
||||
if (oauthAccessToken) return oauthAccessToken;
|
||||
const apiToken = candidate.account.apiToken?.trim();
|
||||
return apiToken || null;
|
||||
}
|
||||
|
||||
private buildRouteUnitMemberDispatchCandidate(
|
||||
outerCandidate: RouteChannelCandidate,
|
||||
memberCandidate: RouteChannelCandidate['routeUnitMembers'][number],
|
||||
): RouteChannelCandidate {
|
||||
return {
|
||||
...outerCandidate,
|
||||
account: memberCandidate.account,
|
||||
site: memberCandidate.site,
|
||||
token: null,
|
||||
};
|
||||
}
|
||||
|
||||
private getRouteUnitMemberEligibilityReasons(
|
||||
outerCandidate: RouteChannelCandidate,
|
||||
memberCandidate: RouteChannelCandidate['routeUnitMembers'][number],
|
||||
options: CandidateEligibilityOptions,
|
||||
): string[] {
|
||||
const reasonParts: string[] = [];
|
||||
const bypassSourceModelCheck = options.bypassSourceModelCheck ?? false;
|
||||
const nowIso = options.nowIso ?? new Date().toISOString();
|
||||
|
||||
if (!bypassSourceModelCheck && !channelSupportsRequestedModel(outerCandidate.channel.sourceModel, options.requestedModel)) {
|
||||
reasonParts.push(`来源模型不匹配=${outerCandidate.channel.sourceModel || ''}`);
|
||||
}
|
||||
|
||||
if (!outerCandidate.channel.enabled) reasonParts.push('通道禁用');
|
||||
|
||||
if (memberCandidate.account.status !== 'active') {
|
||||
reasonParts.push(`账号状态=${memberCandidate.account.status}`);
|
||||
}
|
||||
|
||||
if (isSiteDisabled(memberCandidate.site.status)) {
|
||||
reasonParts.push(`站点状态=${memberCandidate.site.status || 'disabled'}`);
|
||||
}
|
||||
|
||||
const downstreamExclusionReason = this.resolveDownstreamExclusionReason(
|
||||
this.buildRouteUnitMemberDispatchCandidate(outerCandidate, memberCandidate),
|
||||
options.downstreamPolicy,
|
||||
);
|
||||
if (downstreamExclusionReason) {
|
||||
reasonParts.push(downstreamExclusionReason);
|
||||
}
|
||||
|
||||
const tokenValue = this.resolveRouteUnitMemberTokenValue(memberCandidate);
|
||||
if (!tokenValue) reasonParts.push('令牌不可用');
|
||||
|
||||
if (isOauthRouteUnitMemberCoolingDown(memberCandidate.member, nowIso)) {
|
||||
reasonParts.push('冷却中');
|
||||
}
|
||||
|
||||
return reasonParts;
|
||||
}
|
||||
|
||||
private getEligibleRouteUnitMembers(
|
||||
candidate: RouteChannelCandidate,
|
||||
options: CandidateEligibilityOptions,
|
||||
): RouteChannelCandidate['routeUnitMembers'] {
|
||||
if (!isOauthRouteUnitCandidate(candidate)) return [];
|
||||
return candidate.routeUnitMembers.filter((memberCandidate) => (
|
||||
this.getRouteUnitMemberEligibilityReasons(candidate, memberCandidate, options).length === 0
|
||||
));
|
||||
}
|
||||
|
||||
private getRoundRobinRouteUnitMembers(
|
||||
members: RouteChannelCandidate['routeUnitMembers'],
|
||||
): RouteChannelCandidate['routeUnitMembers'] {
|
||||
return [...members].sort((left, right) => {
|
||||
const selectionOrder = compareNullableTimeAsc(
|
||||
left.member.lastSelectedAt || left.member.lastUsedAt,
|
||||
right.member.lastSelectedAt || right.member.lastUsedAt,
|
||||
);
|
||||
if (selectionOrder !== 0) return selectionOrder;
|
||||
|
||||
const usedOrder = compareNullableTimeAsc(left.member.lastUsedAt, right.member.lastUsedAt);
|
||||
if (usedOrder !== 0) return usedOrder;
|
||||
|
||||
const sortOrder = (left.member.sortOrder ?? 0) - (right.member.sortOrder ?? 0);
|
||||
if (sortOrder !== 0) return sortOrder;
|
||||
|
||||
return left.account.id - right.account.id;
|
||||
});
|
||||
}
|
||||
|
||||
private getStickyPreferredRouteUnitMember(
|
||||
members: RouteChannelCandidate['routeUnitMembers'],
|
||||
): RouteChannelCandidate['routeUnitMembers'][number] | null {
|
||||
return [...members].sort((left, right) => {
|
||||
const selectionOrder = compareNullableTimeDesc(
|
||||
left.member.lastSelectedAt || left.member.lastUsedAt,
|
||||
right.member.lastSelectedAt || right.member.lastUsedAt,
|
||||
);
|
||||
if (selectionOrder !== 0) return selectionOrder;
|
||||
|
||||
const sortOrder = (left.member.sortOrder ?? 0) - (right.member.sortOrder ?? 0);
|
||||
if (sortOrder !== 0) return sortOrder;
|
||||
|
||||
return left.account.id - right.account.id;
|
||||
})[0] ?? null;
|
||||
}
|
||||
|
||||
private selectRouteUnitMember(
|
||||
candidate: RouteChannelCandidate,
|
||||
requestedModel: string,
|
||||
downstreamPolicy: DownstreamRoutingPolicy,
|
||||
nowIso: string,
|
||||
nowMs: number,
|
||||
excludeChannelIds: number[] = [],
|
||||
): RouteChannelCandidate['routeUnitMembers'][number] | null {
|
||||
if (!isOauthRouteUnitCandidate(candidate)) return null;
|
||||
const eligibleMembers = this.getEligibleRouteUnitMembers(candidate, {
|
||||
requestedModel,
|
||||
bypassSourceModelCheck: true,
|
||||
excludeChannelIds: [],
|
||||
nowIso,
|
||||
downstreamPolicy,
|
||||
});
|
||||
if (eligibleMembers.length === 0) return null;
|
||||
|
||||
const isRouteUnitFailover = excludeChannelIds.includes(candidate.channel.id);
|
||||
const healthyMembers = isRouteUnitFailover
|
||||
? eligibleMembers.filter((memberCandidate) => !isChannelRecentlyFailed(memberCandidate.member, nowMs))
|
||||
: filterRecentlyFailedCandidates(
|
||||
eligibleMembers.map((memberCandidate) => ({
|
||||
memberCandidate,
|
||||
channel: memberCandidate.member,
|
||||
})),
|
||||
nowMs,
|
||||
).map((item) => item.memberCandidate);
|
||||
const candidateMembers = healthyMembers.length > 0
|
||||
? healthyMembers
|
||||
: (isRouteUnitFailover ? [] : eligibleMembers);
|
||||
if (candidate.routeUnit?.strategy === 'stick_until_unavailable') {
|
||||
const sticky = this.getStickyPreferredRouteUnitMember(candidateMembers);
|
||||
if (sticky) return sticky;
|
||||
return this.getRoundRobinRouteUnitMembers(candidateMembers)[0] ?? null;
|
||||
}
|
||||
|
||||
return this.getRoundRobinRouteUnitMembers(candidateMembers)[0] ?? null;
|
||||
}
|
||||
|
||||
private async recordRouteUnitMemberSelection(
|
||||
routeUnitId: number,
|
||||
accountId: number,
|
||||
): Promise<void> {
|
||||
const nowIso = new Date().toISOString();
|
||||
await db.update(schema.oauthRouteUnitMembers).set({
|
||||
lastSelectedAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
}).where(and(
|
||||
eq(schema.oauthRouteUnitMembers.unitId, routeUnitId),
|
||||
eq(schema.oauthRouteUnitMembers.accountId, accountId),
|
||||
)).run();
|
||||
const routeRows = await db.select({
|
||||
routeId: schema.routeChannels.routeId,
|
||||
}).from(schema.routeChannels)
|
||||
.where(eq(schema.routeChannels.oauthRouteUnitId, routeUnitId))
|
||||
.all();
|
||||
const routeIds: number[] = Array.from(new Set<number>(
|
||||
routeRows
|
||||
.map((row) => Number(row.routeId))
|
||||
.filter((routeId): routeId is number => Number.isFinite(routeId) && routeId > 0),
|
||||
));
|
||||
for (const routeId of routeIds) {
|
||||
invalidateRouteScopedCache(routeId);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveChannelTokenValue(candidate: {
|
||||
channel: typeof schema.routeChannels.$inferSelect;
|
||||
account: typeof schema.accounts.$inferSelect;
|
||||
@@ -2961,6 +3335,18 @@ export class TokenRouter {
|
||||
|
||||
if (!candidate.channel.enabled) reasonParts.push('通道禁用');
|
||||
|
||||
if (isOauthRouteUnitCandidate(candidate)) {
|
||||
if (excludeChannelIds.includes(candidate.channel.id)) {
|
||||
// Route-unit failover should stay inside the same outer channel and switch members instead of
|
||||
// excluding the entire pool after one member fails.
|
||||
}
|
||||
|
||||
if (this.getEligibleRouteUnitMembers(candidate, options).length === 0) {
|
||||
reasonParts.push(`路由池成员不可用(${candidate.routeUnit?.name || getOauthRouteUnitStrategyLabel(candidate.routeUnit?.strategy || 'round_robin')})`);
|
||||
}
|
||||
return reasonParts;
|
||||
}
|
||||
|
||||
if (isExplicitTokenChannel(candidate)) {
|
||||
if (candidate.account.status === 'disabled') {
|
||||
reasonParts.push(`账号状态=${candidate.account.status}`);
|
||||
@@ -3056,6 +3442,73 @@ export class TokenRouter {
|
||||
});
|
||||
}
|
||||
|
||||
private async finalizeSelectedCandidateForDispatch(
|
||||
selected: RouteChannelCandidate,
|
||||
match: RouteMatch,
|
||||
requestedModel: string,
|
||||
mappedModel: string,
|
||||
downstreamPolicy: DownstreamRoutingPolicy,
|
||||
recordSelection: boolean,
|
||||
nowIso: string,
|
||||
nowMs: number,
|
||||
stableFirstRotationKey?: string,
|
||||
stableFirstObservationKey?: string,
|
||||
usedObservation = false,
|
||||
excludeChannelIds: number[] = [],
|
||||
): Promise<SelectedChannel | null> {
|
||||
let dispatchCandidate = selected;
|
||||
let resolvedRouteUnitMemberTokenValue: string | null = null;
|
||||
if (isOauthRouteUnitCandidate(selected)) {
|
||||
const member = this.selectRouteUnitMember(
|
||||
selected,
|
||||
requestedModel,
|
||||
downstreamPolicy,
|
||||
nowIso,
|
||||
nowMs,
|
||||
excludeChannelIds,
|
||||
);
|
||||
if (!member || !selected.routeUnit) return null;
|
||||
resolvedRouteUnitMemberTokenValue = this.resolveRouteUnitMemberTokenValue(member);
|
||||
dispatchCandidate = this.buildRouteUnitMemberDispatchCandidate(selected, member);
|
||||
if (recordSelection) {
|
||||
await this.recordRouteUnitMemberSelection(selected.routeUnit.id, member.account.id);
|
||||
}
|
||||
}
|
||||
|
||||
const tokenValue = resolvedRouteUnitMemberTokenValue ?? this.resolveChannelTokenValue(dispatchCandidate);
|
||||
if (!tokenValue) return null;
|
||||
|
||||
if (recordSelection) {
|
||||
if (stableFirstRotationKey && stableFirstObservationKey) {
|
||||
rememberStableFirstSiteSelectionForKey(
|
||||
usedObservation ? stableFirstObservationKey : stableFirstRotationKey,
|
||||
dispatchCandidate.site.id,
|
||||
);
|
||||
updateStableFirstObservationProgress(stableFirstRotationKey, {
|
||||
usedObservation,
|
||||
selectedSiteId: dispatchCandidate.site.id,
|
||||
nowMs,
|
||||
});
|
||||
}
|
||||
await this.recordChannelSelection(selected.channel.id);
|
||||
}
|
||||
|
||||
const actualModel = resolveActualModelForSelectedChannel(
|
||||
requestedModel,
|
||||
match.route,
|
||||
mappedModel,
|
||||
selected.channel.sourceModel,
|
||||
);
|
||||
|
||||
return {
|
||||
...dispatchCandidate,
|
||||
channel: selected.channel,
|
||||
tokenValue,
|
||||
tokenName: dispatchCandidate.token?.name || 'default',
|
||||
actualModel,
|
||||
};
|
||||
}
|
||||
|
||||
private weightedRandomSelect(
|
||||
candidates: RouteChannelCandidate[],
|
||||
modelName: string | ((candidate: RouteChannelCandidate) => string),
|
||||
|
||||
+45
-2
@@ -581,6 +581,31 @@ export type OAuthProviderInfo = {
|
||||
supportsNativeProxy: boolean;
|
||||
};
|
||||
|
||||
export type OAuthProvidersResponse = {
|
||||
providers: OAuthProviderInfo[];
|
||||
defaults?: {
|
||||
systemProxyConfigured?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type OAuthRouteUnitStrategy = 'round_robin' | 'stick_until_unavailable';
|
||||
|
||||
export type OAuthRouteUnitSummary = {
|
||||
id?: number;
|
||||
routeUnitId?: number;
|
||||
name: string;
|
||||
strategy: OAuthRouteUnitStrategy;
|
||||
memberCount: number;
|
||||
};
|
||||
|
||||
export type OAuthRouteParticipation =
|
||||
| {
|
||||
kind: 'single';
|
||||
}
|
||||
| ({
|
||||
kind: 'route_unit';
|
||||
} & OAuthRouteUnitSummary);
|
||||
|
||||
export type OAuthStartInstructions = {
|
||||
redirectUri: string;
|
||||
callbackPort: number;
|
||||
@@ -651,6 +676,8 @@ export type OAuthConnectionInfo = {
|
||||
lastModelSyncError?: string | null;
|
||||
proxyUrl?: string | null;
|
||||
useSystemProxy?: boolean;
|
||||
routeUnit?: OAuthRouteUnitSummary | null;
|
||||
routeParticipation?: OAuthRouteParticipation | null;
|
||||
site?: { id: number; name: string; url: string; platform: string } | null;
|
||||
};
|
||||
|
||||
@@ -687,6 +714,11 @@ export type OAuthImportResponse = {
|
||||
}>;
|
||||
};
|
||||
|
||||
export type OAuthRouteUnitMutationResponse = {
|
||||
success: boolean;
|
||||
routeUnit?: OAuthRouteUnitSummary;
|
||||
};
|
||||
|
||||
export type DownstreamApiKeyTrendBucket = {
|
||||
startUtc: string | null;
|
||||
totalRequests: number;
|
||||
@@ -831,7 +863,7 @@ export const api = {
|
||||
search: (query: string) => request('/api/search', { method: 'POST', body: JSON.stringify({ query, limit: 20 }) }),
|
||||
|
||||
// OAuth
|
||||
getOAuthProviders: () => request('/api/oauth/providers') as Promise<{ providers: OAuthProviderInfo[] }>,
|
||||
getOAuthProviders: () => request('/api/oauth/providers') as Promise<OAuthProvidersResponse>,
|
||||
startOAuthProvider: (provider: string, data?: { accountId?: number; projectId?: string; proxyUrl?: string | null; useSystemProxy?: boolean }) => request(`/api/oauth/providers/${encodeURIComponent(provider)}/start`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data || {}),
|
||||
@@ -851,6 +883,10 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ accountIds }),
|
||||
}) as Promise<OAuthQuotaBatchRefreshResponse>,
|
||||
updateOAuthConnectionProxy: (accountId: number, data: { proxyUrl?: string | null; useSystemProxy?: boolean }) => request(`/api/oauth/connections/${accountId}/proxy`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data || {}),
|
||||
}) as Promise<{ success: true }>,
|
||||
rebindOAuthConnection: (accountId: number, data?: { proxyUrl?: string | null; useSystemProxy?: boolean }) => request(`/api/oauth/connections/${accountId}/rebind`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data || {}),
|
||||
@@ -860,8 +896,15 @@ export const api = {
|
||||
}) as Promise<{ success: true }>,
|
||||
importOAuthConnections: (data: Record<string, unknown>) => request('/api/oauth/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ data }),
|
||||
body: JSON.stringify(Array.isArray(data.items) ? data : { data }),
|
||||
}) as Promise<OAuthImportResponse>,
|
||||
createOAuthRouteUnit: (data: { accountIds: number[]; name: string; strategy: OAuthRouteUnitStrategy }) => request('/api/oauth/route-units', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}) as Promise<OAuthRouteUnitMutationResponse>,
|
||||
deleteOAuthRouteUnit: (routeUnitId: number) => request(`/api/oauth/route-units/${routeUnitId}`, {
|
||||
method: 'DELETE',
|
||||
}) as Promise<{ success: true }>,
|
||||
|
||||
// Events
|
||||
getEvents: (params?: string) => request(`/api/events${params ? '?' + params : ''}`),
|
||||
|
||||
@@ -14,8 +14,11 @@ const { apiMock, openMock, focusMock, confirmMock, promptMock } = vi.hoisted(()
|
||||
refreshOAuthConnectionQuota: vi.fn(),
|
||||
refreshOAuthConnectionQuotaBatch: vi.fn(),
|
||||
rebindOAuthConnection: vi.fn(),
|
||||
updateOAuthConnectionProxy: vi.fn(),
|
||||
deleteOAuthConnection: vi.fn(),
|
||||
importOAuthConnections: vi.fn(),
|
||||
createOAuthRouteUnit: vi.fn(),
|
||||
deleteOAuthRouteUnit: vi.fn(),
|
||||
getAccountModels: vi.fn(),
|
||||
checkModels: vi.fn(),
|
||||
},
|
||||
@@ -59,6 +62,13 @@ function findOauthSettingInput(root: WebTestRenderer, key: string) {
|
||||
));
|
||||
}
|
||||
|
||||
function findOauthImportSettingInput(root: WebTestRenderer, key: string) {
|
||||
return root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props['data-oauth-import-setting'] === key
|
||||
));
|
||||
}
|
||||
|
||||
function findAllByClassName(root: WebTestRenderer, className: string) {
|
||||
return root.root.findAll((node) => (
|
||||
typeof node.props?.className === 'string'
|
||||
@@ -393,6 +403,477 @@ describe('OAuthManagement page', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('renders route participation summaries and can batch merge selected oauth accounts into a route pool', async () => {
|
||||
apiMock.getOAuthProviders.mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
provider: 'codex',
|
||||
label: 'Codex',
|
||||
platform: 'codex',
|
||||
enabled: true,
|
||||
loginType: 'oauth',
|
||||
requiresProjectId: false,
|
||||
supportsDirectAccountRouting: true,
|
||||
supportsCloudValidation: true,
|
||||
supportsNativeProxy: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
apiMock.getOAuthConnections
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
accountId: 21,
|
||||
siteId: 8,
|
||||
provider: 'codex',
|
||||
email: 'pool-a@example.com',
|
||||
planType: 'team',
|
||||
modelCount: 2,
|
||||
modelsPreview: ['gpt-5.4'],
|
||||
status: 'healthy',
|
||||
routeParticipation: {
|
||||
kind: 'single',
|
||||
},
|
||||
site: {
|
||||
id: 8,
|
||||
name: 'ChatGPT Codex OAuth',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
},
|
||||
},
|
||||
{
|
||||
accountId: 22,
|
||||
siteId: 8,
|
||||
provider: 'codex',
|
||||
email: 'pool-b@example.com',
|
||||
planType: 'team',
|
||||
modelCount: 2,
|
||||
modelsPreview: ['gpt-5.4'],
|
||||
status: 'healthy',
|
||||
routeParticipation: {
|
||||
kind: 'single',
|
||||
},
|
||||
site: {
|
||||
id: 8,
|
||||
name: 'ChatGPT Codex OAuth',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
accountId: 21,
|
||||
siteId: 8,
|
||||
provider: 'codex',
|
||||
email: 'pool-a@example.com',
|
||||
planType: 'team',
|
||||
modelCount: 2,
|
||||
modelsPreview: ['gpt-5.4'],
|
||||
status: 'healthy',
|
||||
routeParticipation: {
|
||||
kind: 'route_unit',
|
||||
routeUnitId: 90,
|
||||
name: 'Codex Pool',
|
||||
strategy: 'round_robin',
|
||||
memberCount: 2,
|
||||
},
|
||||
site: {
|
||||
id: 8,
|
||||
name: 'ChatGPT Codex OAuth',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
},
|
||||
},
|
||||
{
|
||||
accountId: 22,
|
||||
siteId: 8,
|
||||
provider: 'codex',
|
||||
email: 'pool-b@example.com',
|
||||
planType: 'team',
|
||||
modelCount: 2,
|
||||
modelsPreview: ['gpt-5.4'],
|
||||
status: 'healthy',
|
||||
routeParticipation: {
|
||||
kind: 'route_unit',
|
||||
routeUnitId: 90,
|
||||
name: 'Codex Pool',
|
||||
strategy: 'round_robin',
|
||||
memberCount: 2,
|
||||
},
|
||||
site: {
|
||||
id: 8,
|
||||
name: 'ChatGPT Codex OAuth',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
apiMock.createOAuthRouteUnit.mockResolvedValue({
|
||||
success: true,
|
||||
routeUnit: {
|
||||
id: 90,
|
||||
name: 'Codex Pool',
|
||||
strategy: 'round_robin',
|
||||
memberCount: 2,
|
||||
},
|
||||
});
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<MemoryRouter>
|
||||
<OAuthManagement />
|
||||
</MemoryRouter>
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(collectText(root.root)).toContain('单体');
|
||||
|
||||
const selectAll = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.type === 'checkbox'
|
||||
&& node.props['data-testid'] === 'oauth-select-all'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
selectAll.props.onChange({ target: { checked: true } });
|
||||
});
|
||||
|
||||
await clickButton(root, '合并参与路由');
|
||||
|
||||
const nameInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props['data-testid'] === 'oauth-route-unit-name'
|
||||
));
|
||||
await act(async () => {
|
||||
nameInput.props.onChange({ target: { value: 'Codex Pool' } });
|
||||
});
|
||||
|
||||
await clickButton(root, '创建路由池');
|
||||
|
||||
expect(apiMock.createOAuthRouteUnit).toHaveBeenCalledWith({
|
||||
accountIds: [21, 22],
|
||||
name: 'Codex Pool',
|
||||
strategy: 'round_robin',
|
||||
});
|
||||
expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(2);
|
||||
expect(collectText(root.root)).toContain('路由池:Codex Pool · 2 个成员 · 轮询');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('can batch split selected oauth accounts out of the same route pool', async () => {
|
||||
apiMock.getOAuthProviders.mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
provider: 'codex',
|
||||
label: 'Codex',
|
||||
platform: 'codex',
|
||||
enabled: true,
|
||||
loginType: 'oauth',
|
||||
requiresProjectId: false,
|
||||
supportsDirectAccountRouting: true,
|
||||
supportsCloudValidation: true,
|
||||
supportsNativeProxy: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
apiMock.getOAuthConnections
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
accountId: 31,
|
||||
siteId: 8,
|
||||
provider: 'codex',
|
||||
email: 'split-a@example.com',
|
||||
planType: 'team',
|
||||
modelCount: 2,
|
||||
modelsPreview: ['gpt-5.4'],
|
||||
status: 'healthy',
|
||||
routeParticipation: {
|
||||
kind: 'route_unit',
|
||||
routeUnitId: 95,
|
||||
name: 'Split Pool',
|
||||
strategy: 'stick_until_unavailable',
|
||||
memberCount: 2,
|
||||
},
|
||||
site: {
|
||||
id: 8,
|
||||
name: 'ChatGPT Codex OAuth',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
},
|
||||
},
|
||||
{
|
||||
accountId: 32,
|
||||
siteId: 8,
|
||||
provider: 'codex',
|
||||
email: 'split-b@example.com',
|
||||
planType: 'team',
|
||||
modelCount: 2,
|
||||
modelsPreview: ['gpt-5.4'],
|
||||
status: 'healthy',
|
||||
routeParticipation: {
|
||||
kind: 'route_unit',
|
||||
routeUnitId: 95,
|
||||
name: 'Split Pool',
|
||||
strategy: 'stick_until_unavailable',
|
||||
memberCount: 2,
|
||||
},
|
||||
site: {
|
||||
id: 8,
|
||||
name: 'ChatGPT Codex OAuth',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
accountId: 31,
|
||||
siteId: 8,
|
||||
provider: 'codex',
|
||||
email: 'split-a@example.com',
|
||||
planType: 'team',
|
||||
modelCount: 2,
|
||||
modelsPreview: ['gpt-5.4'],
|
||||
status: 'healthy',
|
||||
routeParticipation: {
|
||||
kind: 'single',
|
||||
},
|
||||
site: {
|
||||
id: 8,
|
||||
name: 'ChatGPT Codex OAuth',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
},
|
||||
},
|
||||
{
|
||||
accountId: 32,
|
||||
siteId: 8,
|
||||
provider: 'codex',
|
||||
email: 'split-b@example.com',
|
||||
planType: 'team',
|
||||
modelCount: 2,
|
||||
modelsPreview: ['gpt-5.4'],
|
||||
status: 'healthy',
|
||||
routeParticipation: {
|
||||
kind: 'single',
|
||||
},
|
||||
site: {
|
||||
id: 8,
|
||||
name: 'ChatGPT Codex OAuth',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
apiMock.deleteOAuthRouteUnit.mockResolvedValue({ success: true });
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<MemoryRouter>
|
||||
<OAuthManagement />
|
||||
</MemoryRouter>
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const selectAll = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.type === 'checkbox'
|
||||
&& node.props['data-testid'] === 'oauth-select-all'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
selectAll.props.onChange({ target: { checked: true } });
|
||||
});
|
||||
|
||||
await clickButton(root, '拆回单体');
|
||||
|
||||
expect(apiMock.deleteOAuthRouteUnit).toHaveBeenCalledWith(95);
|
||||
expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(2);
|
||||
expect(collectText(root.root)).toContain('单体');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('only enables splitting when the full route pool is selected and falls back to routeUnit ids', async () => {
|
||||
apiMock.getOAuthProviders.mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
provider: 'codex',
|
||||
label: 'Codex',
|
||||
platform: 'codex',
|
||||
enabled: true,
|
||||
loginType: 'oauth',
|
||||
requiresProjectId: false,
|
||||
supportsDirectAccountRouting: true,
|
||||
supportsCloudValidation: true,
|
||||
supportsNativeProxy: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
apiMock.getOAuthConnections.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
accountId: 41,
|
||||
siteId: 8,
|
||||
provider: 'codex',
|
||||
email: 'partial-a@example.com',
|
||||
planType: 'team',
|
||||
modelCount: 2,
|
||||
modelsPreview: ['gpt-5.4'],
|
||||
status: 'healthy',
|
||||
routeParticipation: {
|
||||
kind: 'route_unit',
|
||||
name: 'Large Pool',
|
||||
strategy: 'round_robin',
|
||||
memberCount: 3,
|
||||
},
|
||||
routeUnit: {
|
||||
id: 97,
|
||||
name: 'Large Pool',
|
||||
strategy: 'round_robin',
|
||||
memberCount: 3,
|
||||
},
|
||||
site: {
|
||||
id: 8,
|
||||
name: 'ChatGPT Codex OAuth',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
},
|
||||
},
|
||||
{
|
||||
accountId: 42,
|
||||
siteId: 8,
|
||||
provider: 'codex',
|
||||
email: 'partial-b@example.com',
|
||||
planType: 'team',
|
||||
modelCount: 2,
|
||||
modelsPreview: ['gpt-5.4'],
|
||||
status: 'healthy',
|
||||
routeParticipation: {
|
||||
kind: 'route_unit',
|
||||
name: 'Large Pool',
|
||||
strategy: 'round_robin',
|
||||
memberCount: 3,
|
||||
},
|
||||
routeUnit: {
|
||||
id: 97,
|
||||
name: 'Large Pool',
|
||||
strategy: 'round_robin',
|
||||
memberCount: 3,
|
||||
},
|
||||
site: {
|
||||
id: 8,
|
||||
name: 'ChatGPT Codex OAuth',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
},
|
||||
},
|
||||
{
|
||||
accountId: 43,
|
||||
siteId: 8,
|
||||
provider: 'codex',
|
||||
email: 'partial-c@example.com',
|
||||
planType: 'team',
|
||||
modelCount: 2,
|
||||
modelsPreview: ['gpt-5.4'],
|
||||
status: 'healthy',
|
||||
routeParticipation: {
|
||||
kind: 'route_unit',
|
||||
name: 'Large Pool',
|
||||
strategy: 'round_robin',
|
||||
memberCount: 3,
|
||||
},
|
||||
routeUnit: {
|
||||
id: 97,
|
||||
name: 'Large Pool',
|
||||
strategy: 'round_robin',
|
||||
memberCount: 3,
|
||||
},
|
||||
site: {
|
||||
id: 8,
|
||||
name: 'ChatGPT Codex OAuth',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 3,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
apiMock.deleteOAuthRouteUnit.mockResolvedValue({ success: true });
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<MemoryRouter>
|
||||
<OAuthManagement />
|
||||
</MemoryRouter>
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const checkboxes = root.root.findAll((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.type === 'checkbox'
|
||||
));
|
||||
expect(checkboxes).toHaveLength(4);
|
||||
|
||||
await act(async () => {
|
||||
checkboxes[1]?.props.onChange({ target: { checked: true } });
|
||||
checkboxes[2]?.props.onChange({ target: { checked: true } });
|
||||
});
|
||||
|
||||
expect(collectText(root.root)).not.toContain('拆回单体');
|
||||
|
||||
await act(async () => {
|
||||
checkboxes[3]?.props.onChange({ target: { checked: true } });
|
||||
});
|
||||
|
||||
await clickButton(root, '拆回单体');
|
||||
|
||||
expect(apiMock.deleteOAuthRouteUnit).toHaveBeenCalledWith(97);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders supported quota windows without duplicating raw counters beside the percent', async () => {
|
||||
apiMock.getOAuthProviders.mockResolvedValue({
|
||||
providers: [
|
||||
@@ -579,8 +1060,11 @@ describe('OAuthManagement page', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('supports selecting multiple json files and imports them sequentially', async () => {
|
||||
it('supports selecting multiple json files, defaults import to system proxy, and sends one batch request', async () => {
|
||||
apiMock.getOAuthProviders.mockResolvedValue({
|
||||
defaults: {
|
||||
systemProxyConfigured: true,
|
||||
},
|
||||
providers: [
|
||||
{
|
||||
provider: 'codex',
|
||||
@@ -613,25 +1097,11 @@ describe('OAuthManagement page', () => {
|
||||
total: 1,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
});
|
||||
apiMock.importOAuthConnections
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
imported: 1,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
items: [
|
||||
{
|
||||
name: 'Workspace A',
|
||||
status: 'imported',
|
||||
accountId: 7,
|
||||
provider: 'codex',
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
imported: 1,
|
||||
imported: 2,
|
||||
skipped: 1,
|
||||
failed: 0,
|
||||
items: [
|
||||
@@ -641,6 +1111,12 @@ describe('OAuthManagement page', () => {
|
||||
accountId: 9,
|
||||
provider: 'codex',
|
||||
},
|
||||
{
|
||||
name: 'Workspace A',
|
||||
status: 'imported',
|
||||
accountId: 7,
|
||||
provider: 'codex',
|
||||
},
|
||||
{
|
||||
name: 'Skipped API Key',
|
||||
status: 'skipped',
|
||||
@@ -703,19 +1179,25 @@ describe('OAuthManagement page', () => {
|
||||
expect(importText).toContain('workspace-b.json');
|
||||
expect(importText).toContain('已识别 2 份 JSON');
|
||||
expect(importText).toContain('结构有效');
|
||||
expect(findOauthImportSettingInput(root, 'use-system-proxy').props.checked).toBe(true);
|
||||
|
||||
await clickButton(root, '添加');
|
||||
|
||||
expect(apiMock.importOAuthConnections).toHaveBeenCalledTimes(2);
|
||||
expect(apiMock.importOAuthConnections).toHaveBeenNthCalledWith(1, {
|
||||
type: 'codex',
|
||||
access_token: 'oauth-access-token-a',
|
||||
email: 'workspace-a@example.com',
|
||||
});
|
||||
expect(apiMock.importOAuthConnections).toHaveBeenNthCalledWith(2, {
|
||||
type: 'codex',
|
||||
access_token: 'oauth-access-token-b',
|
||||
email: 'workspace-b@example.com',
|
||||
expect(apiMock.importOAuthConnections).toHaveBeenCalledTimes(1);
|
||||
expect(apiMock.importOAuthConnections).toHaveBeenCalledWith({
|
||||
items: [
|
||||
{
|
||||
type: 'codex',
|
||||
access_token: 'oauth-access-token-a',
|
||||
email: 'workspace-a@example.com',
|
||||
},
|
||||
{
|
||||
type: 'codex',
|
||||
access_token: 'oauth-access-token-b',
|
||||
email: 'workspace-b@example.com',
|
||||
},
|
||||
],
|
||||
useSystemProxy: true,
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300);
|
||||
@@ -1477,7 +1959,76 @@ describe('OAuthManagement page', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('can clear the stored account proxy by turning off both proxy options', async () => {
|
||||
it('can clear the stored account proxy without forcing reauthorization', async () => {
|
||||
apiMock.getOAuthProviders.mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
provider: 'gemini-cli',
|
||||
label: 'Gemini CLI',
|
||||
platform: 'gemini-cli',
|
||||
enabled: true,
|
||||
loginType: 'oauth',
|
||||
requiresProjectId: true,
|
||||
supportsDirectAccountRouting: true,
|
||||
supportsCloudValidation: true,
|
||||
supportsNativeProxy: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
apiMock.getOAuthConnections.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
accountId: 11,
|
||||
provider: 'gemini-cli',
|
||||
email: 'gemini@example.com',
|
||||
projectId: 'project-demo',
|
||||
modelCount: 5,
|
||||
modelsPreview: ['gemini-2.5-pro'],
|
||||
status: 'healthy',
|
||||
proxyUrl: 'http://127.0.0.1:7890',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
apiMock.updateOAuthConnectionProxy.mockResolvedValue({ success: true });
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<MemoryRouter>
|
||||
<OAuthManagement />
|
||||
</MemoryRouter>
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
await clickButton(root!, '代理设置');
|
||||
const customProxyToggle = findOauthSettingInput(root!, 'use-custom-proxy');
|
||||
|
||||
await act(async () => {
|
||||
customProxyToggle.props.onChange({ target: { checked: false } });
|
||||
});
|
||||
|
||||
await clickButton(root!, '保存代理');
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.updateOAuthConnectionProxy).toHaveBeenCalledWith(11, {
|
||||
proxyUrl: null,
|
||||
useSystemProxy: false,
|
||||
});
|
||||
expect(apiMock.rebindOAuthConnection).not.toHaveBeenCalled();
|
||||
expect(openMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a secondary save-and-reauthorize action in the proxy drawer', async () => {
|
||||
apiMock.getOAuthProviders.mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
@@ -1512,8 +2063,8 @@ describe('OAuthManagement page', () => {
|
||||
});
|
||||
apiMock.rebindOAuthConnection.mockResolvedValue({
|
||||
provider: 'gemini-cli',
|
||||
state: 'gemini-rebind-clear',
|
||||
authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-rebind-clear',
|
||||
state: 'gemini-rebind-save-and-reauthorize',
|
||||
authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-rebind-save-and-reauthorize',
|
||||
instructions: {
|
||||
redirectUri: 'http://localhost:8085/oauth2callback',
|
||||
callbackPort: 8085,
|
||||
@@ -1523,7 +2074,7 @@ describe('OAuthManagement page', () => {
|
||||
});
|
||||
apiMock.getOAuthSession.mockResolvedValue({
|
||||
provider: 'gemini-cli',
|
||||
state: 'gemini-rebind-clear',
|
||||
state: 'gemini-rebind-save-and-reauthorize',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
@@ -1541,19 +2092,21 @@ describe('OAuthManagement page', () => {
|
||||
await flushMicrotasks();
|
||||
|
||||
await clickButton(root!, '代理设置');
|
||||
const customProxyToggle = findOauthSettingInput(root!, 'use-custom-proxy');
|
||||
expect(collectText(root!.root)).toContain('保存代理');
|
||||
expect(collectText(root!.root)).toContain('保存并重新授权');
|
||||
|
||||
await act(async () => {
|
||||
customProxyToggle.props.onChange({ target: { checked: false } });
|
||||
});
|
||||
|
||||
await clickButton(root!, '保存代理并重新授权');
|
||||
await clickButton(root!, '保存并重新授权');
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.rebindOAuthConnection).toHaveBeenCalledWith(11, {
|
||||
proxyUrl: null,
|
||||
proxyUrl: 'http://127.0.0.1:7890',
|
||||
useSystemProxy: false,
|
||||
});
|
||||
expect(openMock).toHaveBeenCalledWith(
|
||||
'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-rebind-save-and-reauthorize',
|
||||
'oauth-gemini-cli',
|
||||
expect.stringContaining('width=540'),
|
||||
);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
api,
|
||||
type OAuthConnectionInfo,
|
||||
type OAuthProviderInfo,
|
||||
type OAuthRouteParticipation,
|
||||
type OAuthRouteUnitStrategy,
|
||||
type OAuthQuotaInfo,
|
||||
type OAuthQuotaWindowInfo,
|
||||
type OAuthStartInstructions,
|
||||
@@ -94,6 +96,12 @@ type OAuthModelsModalState = {
|
||||
siteName: string;
|
||||
};
|
||||
|
||||
type OAuthRouteUnitModalState = {
|
||||
open: boolean;
|
||||
name: string;
|
||||
strategy: OAuthRouteUnitStrategy;
|
||||
};
|
||||
|
||||
const COLUMN_OPTIONS: Array<{ key: ColumnKey; label: string }> = [
|
||||
{ key: 'identity', label: '账号 / Provider' },
|
||||
{ key: 'site', label: '站点' },
|
||||
@@ -450,6 +458,47 @@ function hasOauthProxySelection(connection: OAuthConnectionInfo): boolean {
|
||||
return !!connection.useSystemProxy || !!asTrimmedString(connection.proxyUrl);
|
||||
}
|
||||
|
||||
function resolveRouteUnitStrategyLabel(strategy?: OAuthRouteUnitStrategy | null): string {
|
||||
return strategy === 'stick_until_unavailable' ? '单个用到不可用再切' : '轮询';
|
||||
}
|
||||
|
||||
function resolveConnectionRouteParticipation(
|
||||
connection: OAuthConnectionInfo,
|
||||
): OAuthRouteParticipation {
|
||||
if (connection.routeParticipation?.kind === 'route_unit') {
|
||||
return {
|
||||
...connection.routeParticipation,
|
||||
id: connection.routeParticipation.id ?? connection.routeUnit?.id,
|
||||
routeUnitId: connection.routeParticipation.routeUnitId
|
||||
?? connection.routeParticipation.id
|
||||
?? connection.routeUnit?.routeUnitId
|
||||
?? connection.routeUnit?.id,
|
||||
};
|
||||
}
|
||||
if (connection.routeParticipation?.kind === 'single') {
|
||||
return connection.routeParticipation;
|
||||
}
|
||||
if (connection.routeUnit) {
|
||||
return {
|
||||
kind: 'route_unit',
|
||||
routeUnitId: connection.routeUnit.routeUnitId ?? connection.routeUnit.id,
|
||||
id: connection.routeUnit.id,
|
||||
name: connection.routeUnit.name,
|
||||
strategy: connection.routeUnit.strategy,
|
||||
memberCount: connection.routeUnit.memberCount,
|
||||
};
|
||||
}
|
||||
return { kind: 'single' };
|
||||
}
|
||||
|
||||
function resolveRouteParticipationSummary(connection: OAuthConnectionInfo): string {
|
||||
const participation = resolveConnectionRouteParticipation(connection);
|
||||
if (participation.kind !== 'route_unit') {
|
||||
return '单体';
|
||||
}
|
||||
return `路由池:${participation.name} · ${participation.memberCount} 个成员 · ${resolveRouteUnitStrategyLabel(participation.strategy)}`;
|
||||
}
|
||||
|
||||
function renderCodeBlock(value: string) {
|
||||
return (
|
||||
<code className="oauth-code-block">{value}</code>
|
||||
@@ -588,12 +637,16 @@ export default function OAuthManagement() {
|
||||
const [showColumnMenu, setShowColumnMenu] = useState(false);
|
||||
const [autoRefreshSeconds, setAutoRefreshSeconds] = useState<number>(0);
|
||||
const [autoRefreshCountdown, setAutoRefreshCountdown] = useState<number>(0);
|
||||
const [runtimeSystemProxyConfigured, setRuntimeSystemProxyConfigured] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const importFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [importJsonText, setImportJsonText] = useState('');
|
||||
const [importDrafts, setImportDrafts] = useState<OAuthImportDraft[]>([]);
|
||||
const [importDragOver, setImportDragOver] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importCustomProxyEnabled, setImportCustomProxyEnabled] = useState(false);
|
||||
const [importSystemProxyEnabled, setImportSystemProxyEnabled] = useState(false);
|
||||
const [importProxyUrl, setImportProxyUrl] = useState('');
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [drawerIntent, setDrawerIntent] = useState<DrawerIntent>({ mode: 'create' });
|
||||
const [selectedProviderKey, setSelectedProviderKey] = useState('');
|
||||
@@ -615,6 +668,11 @@ export default function OAuthManagement() {
|
||||
disabledCount: 0,
|
||||
siteName: '',
|
||||
});
|
||||
const [routeUnitModal, setRouteUnitModal] = useState<OAuthRouteUnitModalState>({
|
||||
open: false,
|
||||
name: '',
|
||||
strategy: 'round_robin',
|
||||
});
|
||||
|
||||
const resetOauthProxySettings = useCallback(() => {
|
||||
setOauthCustomProxyEnabled(false);
|
||||
@@ -622,6 +680,12 @@ export default function OAuthManagement() {
|
||||
setOauthProxyUrl('');
|
||||
}, []);
|
||||
|
||||
const resetImportProxySettings = useCallback((defaultToSystem = false) => {
|
||||
setImportCustomProxyEnabled(false);
|
||||
setImportSystemProxyEnabled(defaultToSystem);
|
||||
setImportProxyUrl('');
|
||||
}, []);
|
||||
|
||||
const resetImportState = useCallback(() => {
|
||||
setImportJsonText('');
|
||||
setImportDrafts([]);
|
||||
@@ -631,12 +695,14 @@ export default function OAuthManagement() {
|
||||
const closeImportModal = useCallback(() => {
|
||||
setImportOpen(false);
|
||||
resetImportState();
|
||||
}, [resetImportState]);
|
||||
resetImportProxySettings(false);
|
||||
}, [resetImportProxySettings, resetImportState]);
|
||||
|
||||
const openImportModal = useCallback(() => {
|
||||
resetImportState();
|
||||
resetImportProxySettings(runtimeSystemProxyConfigured);
|
||||
setImportOpen(true);
|
||||
}, [resetImportState]);
|
||||
}, [resetImportProxySettings, resetImportState, runtimeSystemProxyConfigured]);
|
||||
|
||||
const loadConnections = useCallback(async () => {
|
||||
const response = await api.getOAuthConnections({
|
||||
@@ -656,6 +722,7 @@ export default function OAuthManagement() {
|
||||
loadConnections(),
|
||||
]);
|
||||
const nextProviders = Array.isArray(providersResponse?.providers) ? providersResponse.providers : [];
|
||||
setRuntimeSystemProxyConfigured(providersResponse?.defaults?.systemProxyConfigured === true);
|
||||
setProviders(nextProviders);
|
||||
setSelectedProviderKey((current) => current || nextProviders[0]?.provider || '');
|
||||
} catch (error: any) {
|
||||
@@ -815,6 +882,53 @@ export default function OAuthManagement() {
|
||||
const allVisibleSelected = filteredConnections.length > 0
|
||||
&& filteredConnections.every((connection) => selectedConnectionIds.includes(connection.accountId));
|
||||
|
||||
const selectedConnections = useMemo(
|
||||
() => connections.filter((connection) => selectedConnectionIds.includes(connection.accountId)),
|
||||
[connections, selectedConnectionIds],
|
||||
);
|
||||
|
||||
const selectedRouteUnitParticipation = useMemo(() => {
|
||||
if (selectedConnections.length <= 0) return null;
|
||||
const participations = selectedConnections.map(resolveConnectionRouteParticipation);
|
||||
if (participations.some((item) => item.kind !== 'route_unit')) return null;
|
||||
const first = participations[0];
|
||||
if (!first || first.kind !== 'route_unit') return null;
|
||||
const routeUnitId = first.routeUnitId ?? first.id;
|
||||
if (!routeUnitId) return null;
|
||||
const allSameRouteUnit = participations.every((item) => (
|
||||
item.kind === 'route_unit'
|
||||
&& (item.routeUnitId ?? item.id) === routeUnitId
|
||||
));
|
||||
if (!allSameRouteUnit) return null;
|
||||
|
||||
const totalRouteUnitMembers = connections.filter((connection) => {
|
||||
const participation = resolveConnectionRouteParticipation(connection);
|
||||
return participation.kind === 'route_unit'
|
||||
&& (participation.routeUnitId ?? participation.id) === routeUnitId;
|
||||
}).length;
|
||||
if (totalRouteUnitMembers !== selectedConnections.length) return null;
|
||||
|
||||
return {
|
||||
...first,
|
||||
memberCount: Math.max(first.memberCount, totalRouteUnitMembers),
|
||||
};
|
||||
}, [connections, selectedConnections]);
|
||||
|
||||
const canMergeSelectedIntoRouteUnit = useMemo(() => {
|
||||
if (selectedConnections.length < 2) return false;
|
||||
const first = selectedConnections[0];
|
||||
if (!first) return false;
|
||||
const firstSiteId = first.siteId;
|
||||
const firstProvider = first.provider;
|
||||
return selectedConnections.every((connection) => (
|
||||
connection.siteId === firstSiteId
|
||||
&& connection.provider === firstProvider
|
||||
&& resolveConnectionRouteParticipation(connection).kind === 'single'
|
||||
));
|
||||
}, [selectedConnections]);
|
||||
|
||||
const canSplitSelectedRouteUnit = selectedRouteUnitParticipation != null;
|
||||
|
||||
const openCreateDrawer = (provider?: string) => {
|
||||
setDrawerIntent({ mode: 'create', provider });
|
||||
setSelectedProviderKey(provider || providers[0]?.provider || '');
|
||||
@@ -842,7 +956,100 @@ export default function OAuthManagement() {
|
||||
setOauthProxyUrl(connection.useSystemProxy ? '' : asTrimmedString(connection.proxyUrl));
|
||||
setDrawerOpen(true);
|
||||
setShowColumnMenu(false);
|
||||
setSessionMessage('已打开 OAuth 代理设置,修改后点击“保存代理并重新授权”。');
|
||||
setSessionMessage('已打开 OAuth 代理设置,修改后可直接保存代理,或保存后重新授权。');
|
||||
};
|
||||
|
||||
const openRouteUnitModal = () => {
|
||||
setRouteUnitModal({
|
||||
open: true,
|
||||
name: '',
|
||||
strategy: 'round_robin',
|
||||
});
|
||||
};
|
||||
|
||||
const closeRouteUnitModal = () => {
|
||||
setRouteUnitModal((current) => ({
|
||||
...current,
|
||||
open: false,
|
||||
}));
|
||||
};
|
||||
|
||||
const resolveProxySettingsPayload = ({
|
||||
customEnabled,
|
||||
systemEnabled,
|
||||
proxyValue,
|
||||
fallbackAccount,
|
||||
clearToSiteFallback = false,
|
||||
}: {
|
||||
customEnabled: boolean;
|
||||
systemEnabled: boolean;
|
||||
proxyValue: string;
|
||||
fallbackAccount?: OAuthConnectionInfo | null;
|
||||
clearToSiteFallback?: boolean;
|
||||
}): { proxyUrl?: string | null; useSystemProxy?: boolean } => {
|
||||
const customProxyUrl = asTrimmedString(proxyValue);
|
||||
if (customEnabled) {
|
||||
return {
|
||||
proxyUrl: customProxyUrl,
|
||||
useSystemProxy: false,
|
||||
};
|
||||
}
|
||||
if (systemEnabled) {
|
||||
return {
|
||||
proxyUrl: null,
|
||||
useSystemProxy: true,
|
||||
};
|
||||
}
|
||||
if (clearToSiteFallback) {
|
||||
return {
|
||||
proxyUrl: null,
|
||||
useSystemProxy: false,
|
||||
};
|
||||
}
|
||||
if (fallbackAccount?.useSystemProxy) {
|
||||
return {
|
||||
proxyUrl: null,
|
||||
useSystemProxy: true,
|
||||
};
|
||||
}
|
||||
if (fallbackAccount?.proxyUrl !== undefined) {
|
||||
return {
|
||||
proxyUrl: asTrimmedString(fallbackAccount.proxyUrl) || null,
|
||||
useSystemProxy: false,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
const handleSaveProxy = async () => {
|
||||
if (drawerIntent.mode !== 'proxy') return;
|
||||
const customProxyUrl = asTrimmedString(oauthProxyUrl);
|
||||
if (oauthCustomProxyEnabled && !customProxyUrl) {
|
||||
setSessionMessage('已开启代理,请先输入完整代理地址');
|
||||
return;
|
||||
}
|
||||
|
||||
const actionKey = `save-proxy:${drawerIntent.account.accountId}`;
|
||||
setActionLoadingKey(actionKey);
|
||||
try {
|
||||
await api.updateOAuthConnectionProxy(
|
||||
drawerIntent.account.accountId,
|
||||
resolveProxySettingsPayload({
|
||||
customEnabled: oauthCustomProxyEnabled,
|
||||
systemEnabled: oauthSystemProxyEnabled,
|
||||
proxyValue: oauthProxyUrl,
|
||||
clearToSiteFallback: true,
|
||||
}),
|
||||
);
|
||||
await loadConnections();
|
||||
setDrawerOpen(false);
|
||||
resetOauthProxySettings();
|
||||
setSessionMessage('代理已保存');
|
||||
} catch (error: any) {
|
||||
setSessionMessage(error?.message || '保存代理失败');
|
||||
} finally {
|
||||
setActionLoadingKey('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = async () => {
|
||||
@@ -869,24 +1076,13 @@ export default function OAuthManagement() {
|
||||
return;
|
||||
}
|
||||
|
||||
let resolvedProxyUrl: string | null | undefined;
|
||||
let resolvedUseSystemProxy: boolean | undefined;
|
||||
if (oauthCustomProxyEnabled) {
|
||||
resolvedProxyUrl = customProxyUrl;
|
||||
resolvedUseSystemProxy = false;
|
||||
} else if (oauthSystemProxyEnabled) {
|
||||
resolvedProxyUrl = null;
|
||||
resolvedUseSystemProxy = true;
|
||||
} else if (drawerIntent.mode === 'proxy') {
|
||||
resolvedProxyUrl = null;
|
||||
resolvedUseSystemProxy = false;
|
||||
} else if (rebindAccount?.useSystemProxy) {
|
||||
resolvedProxyUrl = null;
|
||||
resolvedUseSystemProxy = true;
|
||||
} else if (rebindAccount?.proxyUrl !== undefined) {
|
||||
resolvedProxyUrl = asTrimmedString(rebindAccount.proxyUrl) || null;
|
||||
resolvedUseSystemProxy = false;
|
||||
}
|
||||
const proxySettings = resolveProxySettingsPayload({
|
||||
customEnabled: oauthCustomProxyEnabled,
|
||||
systemEnabled: oauthSystemProxyEnabled,
|
||||
proxyValue: oauthProxyUrl,
|
||||
fallbackAccount: rebindAccount,
|
||||
clearToSiteFallback: drawerIntent.mode === 'proxy',
|
||||
});
|
||||
|
||||
const actionKey = `start:${provider.provider}:${accountId || 0}`;
|
||||
setActionLoadingKey(actionKey);
|
||||
@@ -898,14 +1094,12 @@ export default function OAuthManagement() {
|
||||
? await api.rebindOAuthConnection(
|
||||
accountId,
|
||||
{
|
||||
...(resolvedProxyUrl !== undefined ? { proxyUrl: resolvedProxyUrl } : {}),
|
||||
...(resolvedUseSystemProxy !== undefined ? { useSystemProxy: resolvedUseSystemProxy } : {}),
|
||||
...proxySettings,
|
||||
},
|
||||
)
|
||||
: await api.startOAuthProvider(provider.provider, {
|
||||
projectId,
|
||||
...(resolvedProxyUrl !== undefined ? { proxyUrl: resolvedProxyUrl } : {}),
|
||||
...(resolvedUseSystemProxy !== undefined ? { useSystemProxy: resolvedUseSystemProxy } : {}),
|
||||
...proxySettings,
|
||||
});
|
||||
|
||||
setSessionMessage('等待授权完成');
|
||||
@@ -1155,21 +1349,42 @@ export default function OAuthManagement() {
|
||||
setSessionMessage('请先修正无效的 OAuth JSON');
|
||||
return;
|
||||
}
|
||||
if (importCustomProxyEnabled && !asTrimmedString(importProxyUrl)) {
|
||||
setSessionMessage('已开启代理,请先输入完整代理地址');
|
||||
return;
|
||||
}
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
let importedCount = 0;
|
||||
|
||||
for (const item of importPreviewSummary.items) {
|
||||
if (!item.valid || !item.parsedData) continue;
|
||||
const result = await api.importOAuthConnections(item.parsedData);
|
||||
importedCount += result.imported;
|
||||
}
|
||||
const parsedItems = importPreviewSummary.items
|
||||
.filter((item) => item.valid && item.parsedData)
|
||||
.map((item) => item.parsedData as Record<string, unknown>);
|
||||
const importProxySettings = importSystemProxyEnabled && !importCustomProxyEnabled
|
||||
? { useSystemProxy: true as const }
|
||||
: resolveProxySettingsPayload({
|
||||
customEnabled: importCustomProxyEnabled,
|
||||
systemEnabled: importSystemProxyEnabled,
|
||||
proxyValue: importProxyUrl,
|
||||
});
|
||||
const result = parsedItems.length === 1
|
||||
&& !('proxyUrl' in importProxySettings)
|
||||
&& !('useSystemProxy' in importProxySettings)
|
||||
? await api.importOAuthConnections(parsedItems[0]!)
|
||||
: await api.importOAuthConnections({
|
||||
items: parsedItems,
|
||||
...importProxySettings,
|
||||
});
|
||||
|
||||
await loadConnections();
|
||||
const successMessage = `已添加 ${importedCount} 个 OAuth 连接`;
|
||||
toast.success(successMessage);
|
||||
setSessionMessage(successMessage);
|
||||
const importMessage = result.failed > 0
|
||||
? `批量导入完成,成功 ${result.imported} 个,失败 ${result.failed} 个`
|
||||
: `已添加 ${result.imported} 个 OAuth 连接`;
|
||||
if (result.failed > 0) {
|
||||
toast.info(importMessage);
|
||||
} else {
|
||||
toast.success(importMessage);
|
||||
}
|
||||
setSessionMessage(importMessage);
|
||||
closeImportModal();
|
||||
} catch (error: any) {
|
||||
const message = error?.message || '导入 OAuth JSON 失败';
|
||||
@@ -1180,6 +1395,50 @@ export default function OAuthManagement() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateRouteUnit = async () => {
|
||||
const name = asTrimmedString(routeUnitModal.name);
|
||||
if (!canMergeSelectedIntoRouteUnit || selectedConnections.length < 2) return;
|
||||
if (!name) {
|
||||
setSessionMessage('请先输入路由池名称');
|
||||
return;
|
||||
}
|
||||
|
||||
setActionLoadingKey('route-unit:create');
|
||||
try {
|
||||
await api.createOAuthRouteUnit({
|
||||
accountIds: selectedConnections.map((connection) => connection.accountId),
|
||||
name,
|
||||
strategy: routeUnitModal.strategy,
|
||||
});
|
||||
await loadConnections();
|
||||
setSelectedConnectionIds([]);
|
||||
closeRouteUnitModal();
|
||||
setSessionMessage('已合并为路由池');
|
||||
} catch (error: any) {
|
||||
setSessionMessage(error?.message || '创建路由池失败');
|
||||
} finally {
|
||||
setActionLoadingKey('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSelectedRouteUnit = async () => {
|
||||
if (!selectedRouteUnitParticipation) return;
|
||||
const routeUnitId = selectedRouteUnitParticipation.routeUnitId ?? selectedRouteUnitParticipation.id;
|
||||
if (!routeUnitId) return;
|
||||
|
||||
setActionLoadingKey('route-unit:delete');
|
||||
try {
|
||||
await api.deleteOAuthRouteUnit(routeUnitId);
|
||||
await loadConnections();
|
||||
setSelectedConnectionIds([]);
|
||||
setSessionMessage('已拆回单体');
|
||||
} catch (error: any) {
|
||||
setSessionMessage(error?.message || '拆回单体失败');
|
||||
} finally {
|
||||
setActionLoadingKey('');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleColumn = (key: ColumnKey) => {
|
||||
setVisibleColumns((current) => ({
|
||||
...current,
|
||||
@@ -1489,6 +1748,7 @@ export default function OAuthManagement() {
|
||||
<td className="oauth-col-proxy">
|
||||
<div className="oauth-cell-stack">
|
||||
<div className="oauth-cell-secondary">{resolveProxyProjectSummary(connection)}</div>
|
||||
<div className="oauth-cell-secondary">{resolveRouteParticipationSummary(connection)}</div>
|
||||
<div className="oauth-cell-tertiary">{resolveProxyDisplayText(connection)}</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1566,6 +1826,7 @@ export default function OAuthManagement() {
|
||||
<MobileField label="站点" value={connection.site?.name || '--'} />
|
||||
<MobileField label="邮箱" value={resolveConnectionEmailLabel(connection) || '--'} />
|
||||
<MobileField label="计划 / 项目" value={connection.projectId ? `${connection.planType || '--'} · ${connection.projectId}` : (connection.planType || '--')} />
|
||||
<MobileField label="路由参与" value={resolveRouteParticipationSummary(connection)} />
|
||||
<MobileField
|
||||
label="账号代理"
|
||||
value={(
|
||||
@@ -1719,6 +1980,25 @@ export default function OAuthManagement() {
|
||||
>
|
||||
{actionLoadingKey === 'quota:selected' ? '刷新中...' : '批量刷新额度'}
|
||||
</button>
|
||||
{canMergeSelectedIntoRouteUnit ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost oauth-outline-button"
|
||||
onClick={openRouteUnitModal}
|
||||
>
|
||||
合并参与路由
|
||||
</button>
|
||||
) : null}
|
||||
{canSplitSelectedRouteUnit ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost oauth-outline-button"
|
||||
onClick={handleDeleteSelectedRouteUnit}
|
||||
disabled={actionLoadingKey === 'route-unit:delete'}
|
||||
>
|
||||
{actionLoadingKey === 'route-unit:delete' ? '拆分中...' : '拆回单体'}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link btn-link-danger"
|
||||
@@ -1806,7 +2086,7 @@ export default function OAuthManagement() {
|
||||
|
||||
<div className="oauth-form-note">
|
||||
{drawerIntent.mode === 'proxy'
|
||||
? '这里修改的是账号级 OAuth 代理。保存后会立刻按当前设置重新走一次授权,后续生成的账号也会沿用这份代理配置。若两项都不勾选,则回退到站点代理配置。'
|
||||
? '这里修改的是账号级 OAuth 代理。点击“保存代理”会立即落库并刷新列表;只有“保存并重新授权”才会重新走授权流程。若两项都不勾选,则回退到站点代理配置。'
|
||||
: '这里的设置会作用于下一次“连接”或“重新授权”。填写代理地址后,本次 OAuth 换 token 和后续生成的账号都会直接带上这份账号级代理配置;若不勾选,则回退到站点代理配置。'}
|
||||
</div>
|
||||
|
||||
@@ -1855,20 +2135,47 @@ export default function OAuthManagement() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleStart}
|
||||
disabled={!selectedProvider || !selectedProvider.enabled || actionLoadingKey.startsWith('start:')}
|
||||
>
|
||||
{actionLoadingKey.startsWith('start:')
|
||||
? '启动中...'
|
||||
: drawerIntent.mode === 'proxy'
|
||||
? '保存代理并重新授权'
|
||||
{drawerIntent.mode === 'proxy' ? (
|
||||
<div className="oauth-inline-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleSaveProxy}
|
||||
disabled={
|
||||
actionLoadingKey === `save-proxy:${drawerIntent.account.accountId}`
|
||||
|| actionLoadingKey.startsWith('start:')
|
||||
}
|
||||
>
|
||||
{actionLoadingKey === `save-proxy:${drawerIntent.account.accountId}` ? '保存中...' : '保存代理'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={handleStart}
|
||||
disabled={
|
||||
!selectedProvider
|
||||
|| !selectedProvider.enabled
|
||||
|| actionLoadingKey.startsWith('start:')
|
||||
|| actionLoadingKey === `save-proxy:${drawerIntent.account.accountId}`
|
||||
}
|
||||
>
|
||||
{actionLoadingKey.startsWith('start:') ? '启动中...' : '保存并重新授权'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleStart}
|
||||
disabled={!selectedProvider || !selectedProvider.enabled || actionLoadingKey.startsWith('start:')}
|
||||
>
|
||||
{actionLoadingKey.startsWith('start:')
|
||||
? '启动中...'
|
||||
: drawerIntent.mode === 'rebind'
|
||||
? `重新授权 ${selectedProvider?.label || ''}`.trim()
|
||||
: `连接 ${selectedProvider?.label || ''}`.trim()}
|
||||
</button>
|
||||
? `重新授权 ${selectedProvider?.label || ''}`.trim()
|
||||
: `连接 ${selectedProvider?.label || ''}`.trim()}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2025,6 +2332,52 @@ export default function OAuthManagement() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="oauth-form-note">
|
||||
导入后的账号代理可在这里一次性指定;如果当前运行时已配置系统代理,会默认预选“使用系统级代理”。
|
||||
</div>
|
||||
<div className="oauth-toggle-group">
|
||||
<label className="oauth-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={importSystemProxyEnabled}
|
||||
data-oauth-import-setting="use-system-proxy"
|
||||
onChange={(event) => {
|
||||
const checked = !!event.target.checked;
|
||||
setImportSystemProxyEnabled(checked);
|
||||
if (checked) {
|
||||
setImportCustomProxyEnabled(false);
|
||||
setImportProxyUrl('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>使用系统级代理</span>
|
||||
</label>
|
||||
<label className="oauth-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={importCustomProxyEnabled}
|
||||
data-oauth-import-setting="use-custom-proxy"
|
||||
onChange={(event) => {
|
||||
const checked = !!event.target.checked;
|
||||
setImportCustomProxyEnabled(checked);
|
||||
if (checked) setImportSystemProxyEnabled(false);
|
||||
}}
|
||||
/>
|
||||
<span>使用自定义代理</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="oauth-form-field">
|
||||
<div className="oauth-field-label">代理地址</div>
|
||||
<input
|
||||
type="text"
|
||||
className="oauth-input"
|
||||
value={importProxyUrl}
|
||||
data-oauth-import-setting="proxy-url"
|
||||
onChange={(event) => setImportProxyUrl(event.target.value)}
|
||||
placeholder="如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080"
|
||||
disabled={!importCustomProxyEnabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="oauth-import-copy">或者手动粘贴单个 JSON 内容:</div>
|
||||
<textarea
|
||||
className="oauth-textarea oauth-mono"
|
||||
@@ -2070,6 +2423,56 @@ export default function OAuthManagement() {
|
||||
</div>
|
||||
) : null}
|
||||
</CenteredModal>
|
||||
|
||||
<CenteredModal
|
||||
open={routeUnitModal.open}
|
||||
onClose={closeRouteUnitModal}
|
||||
title="创建路由池"
|
||||
maxWidth={520}
|
||||
bodyStyle={{ display: 'grid', gap: 16 }}
|
||||
footer={(
|
||||
<>
|
||||
<button type="button" className="btn btn-ghost" onClick={closeRouteUnitModal}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleCreateRouteUnit}
|
||||
disabled={actionLoadingKey === 'route-unit:create' || !asTrimmedString(routeUnitModal.name)}
|
||||
>
|
||||
{actionLoadingKey === 'route-unit:create' ? '创建中...' : '创建路由池'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="oauth-form-field">
|
||||
<div className="oauth-field-label">路由池名称</div>
|
||||
<input
|
||||
type="text"
|
||||
className="oauth-input"
|
||||
data-testid="oauth-route-unit-name"
|
||||
value={routeUnitModal.name}
|
||||
onChange={(event) => setRouteUnitModal((current) => ({ ...current, name: event.target.value }))}
|
||||
placeholder="例如 Codex Pool"
|
||||
/>
|
||||
</div>
|
||||
<div className="oauth-form-field">
|
||||
<div className="oauth-field-label">策略</div>
|
||||
<ModernSelect
|
||||
value={routeUnitModal.strategy}
|
||||
onChange={(value) => setRouteUnitModal((current) => ({
|
||||
...current,
|
||||
strategy: String(value || 'round_robin') as OAuthRouteUnitStrategy,
|
||||
}))}
|
||||
options={[
|
||||
{ value: 'round_robin', label: '轮询' },
|
||||
{ value: 'stick_until_unavailable', label: '单个用到不可用再切' },
|
||||
]}
|
||||
placeholder="选择路由池策略"
|
||||
/>
|
||||
</div>
|
||||
</CenteredModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,6 +78,73 @@ function buildChannel(overrides: Partial<RouteChannel> = {}): RouteChannel {
|
||||
}
|
||||
|
||||
describe('RouteCard', () => {
|
||||
it('renders oauth route unit summary and member labels on expanded channels', () => {
|
||||
const root = create(
|
||||
<RouteCard
|
||||
route={buildRoute({
|
||||
modelPattern: 'gpt-4.1',
|
||||
displayName: 'gpt-4.1',
|
||||
channelCount: 1,
|
||||
enabledChannelCount: 1,
|
||||
})}
|
||||
brand={null}
|
||||
expanded
|
||||
onToggleExpand={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onToggleEnabled={vi.fn()}
|
||||
onClearCooldown={vi.fn()}
|
||||
clearingCooldown={false}
|
||||
onRoutingStrategyChange={vi.fn()}
|
||||
updatingRoutingStrategy={false}
|
||||
channels={[
|
||||
buildChannel({
|
||||
account: { username: 'route-unit-anchor' },
|
||||
routeUnit: {
|
||||
id: 'pool-1',
|
||||
name: 'Codex Pool A',
|
||||
strategy: 'round_robin',
|
||||
memberCount: 3,
|
||||
members: [
|
||||
{ accountId: 101, username: 'route-unit-anchor', siteName: 'site-a' },
|
||||
{ accountId: 102, username: 'route-unit-backup', siteName: 'site-b' },
|
||||
{ accountId: 103, username: 'route-unit-third', siteName: 'site-c' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
]}
|
||||
loadingChannels={false}
|
||||
routeDecision={null}
|
||||
loadingDecision={false}
|
||||
candidateView={{ routeCandidates: [], accountOptions: [], tokenOptionsByAccountId: {} }}
|
||||
channelTokenDraft={{}}
|
||||
updatingChannel={{}}
|
||||
savingPriority={false}
|
||||
onTokenDraftChange={vi.fn()}
|
||||
onSaveToken={vi.fn()}
|
||||
onDeleteChannel={vi.fn()}
|
||||
onToggleChannelEnabled={vi.fn()}
|
||||
onChannelDragEnd={vi.fn()}
|
||||
missingTokenSiteItems={[]}
|
||||
missingTokenGroupItems={[]}
|
||||
onCreateTokenForMissing={vi.fn()}
|
||||
onAddChannel={vi.fn()}
|
||||
onSiteBlockModel={vi.fn()}
|
||||
expandedSourceGroupMap={{}}
|
||||
onToggleSourceGroup={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('Codex Pool A');
|
||||
expect(text).toContain('3 个成员');
|
||||
expect(text).toContain('轮询');
|
||||
expect(text).toContain('成员摘要');
|
||||
expect(text).toContain('route-unit-anchor');
|
||||
expect(text).toContain('route-unit-backup');
|
||||
expect(text).toContain('route-unit-third');
|
||||
});
|
||||
|
||||
it('truncates the collapsed regex badge while keeping the group name primary', () => {
|
||||
const root = create(
|
||||
<RouteCard
|
||||
@@ -371,6 +438,70 @@ describe('RouteCard', () => {
|
||||
expect(p0RailNode.props.style.color).not.toBe(p1RailNode.props.style.color);
|
||||
});
|
||||
|
||||
it('renders oauth route unit summary badges on expanded cards', () => {
|
||||
const root = create(
|
||||
<RouteCard
|
||||
route={buildRoute()}
|
||||
brand={null}
|
||||
expanded
|
||||
onToggleExpand={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onToggleEnabled={vi.fn()}
|
||||
onClearCooldown={vi.fn()}
|
||||
clearingCooldown={false}
|
||||
onRoutingStrategyChange={vi.fn()}
|
||||
updatingRoutingStrategy={false}
|
||||
channels={[
|
||||
buildChannel({
|
||||
id: 11,
|
||||
account: { username: 'pool-representative' },
|
||||
site: { id: 1, name: 'site-a', platform: 'openai' },
|
||||
...( {
|
||||
routeUnit: {
|
||||
id: 7,
|
||||
name: 'Codex 池',
|
||||
memberCount: 3,
|
||||
strategy: 'round_robin',
|
||||
members: [
|
||||
{ accountId: 101, username: 'user_a', siteName: 'site-a' },
|
||||
{ accountId: 102, username: 'user_b', siteName: 'site-b' },
|
||||
{ accountId: 103, username: 'user_c', siteName: 'site-c' },
|
||||
],
|
||||
},
|
||||
} as any ),
|
||||
}),
|
||||
]}
|
||||
loadingChannels={false}
|
||||
routeDecision={null}
|
||||
loadingDecision={false}
|
||||
candidateView={{ routeCandidates: [], accountOptions: [], tokenOptionsByAccountId: {} }}
|
||||
channelTokenDraft={{}}
|
||||
updatingChannel={{}}
|
||||
savingPriority={false}
|
||||
onTokenDraftChange={vi.fn()}
|
||||
onSaveToken={vi.fn()}
|
||||
onDeleteChannel={vi.fn()}
|
||||
onToggleChannelEnabled={vi.fn()}
|
||||
onChannelDragEnd={vi.fn()}
|
||||
missingTokenSiteItems={[]}
|
||||
missingTokenGroupItems={[]}
|
||||
onCreateTokenForMissing={vi.fn()}
|
||||
onAddChannel={vi.fn()}
|
||||
onSiteBlockModel={vi.fn()}
|
||||
expandedSourceGroupMap={{}}
|
||||
onToggleSourceGroup={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('OAuth 路由池');
|
||||
expect(text).toContain('Codex 池');
|
||||
expect(text).toContain('3 个成员');
|
||||
expect(text).toContain('轮询');
|
||||
expect(text).toContain('成员摘要');
|
||||
});
|
||||
|
||||
it('uses translate-only rect sorting for flat channel shell rows', () => {
|
||||
const root = create(
|
||||
<RouteCard
|
||||
|
||||
@@ -25,6 +25,7 @@ import { formatDateTimeMinuteLocal } from '../helpers/checkinLogTime.js';
|
||||
import type {
|
||||
RouteSummaryRow,
|
||||
RouteChannel,
|
||||
RouteChannelRouteUnit,
|
||||
RouteDecision,
|
||||
RouteDecisionCandidate,
|
||||
MissingTokenRouteSiteActionItem,
|
||||
@@ -100,6 +101,24 @@ type RouteCardProps = {
|
||||
onToggleSourceGroup: (groupKey: string) => void;
|
||||
};
|
||||
|
||||
function getRouteUnitStrategyLabel(strategy: string | null | undefined): string {
|
||||
return strategy === 'stick_until_unavailable' ? '单个用到不可用再切' : '轮询';
|
||||
}
|
||||
|
||||
function collectRouteUnits(channels: RouteChannel[] | undefined): RouteChannelRouteUnit[] {
|
||||
if (!Array.isArray(channels) || channels.length === 0) return [];
|
||||
const unitsById = new Map<string, RouteChannelRouteUnit>();
|
||||
for (const channel of channels) {
|
||||
const routeUnit = channel.routeUnit;
|
||||
if (!routeUnit) continue;
|
||||
const key = String(routeUnit.id);
|
||||
if (!unitsById.has(key)) {
|
||||
unitsById.set(key, routeUnit);
|
||||
}
|
||||
}
|
||||
return Array.from(unitsById.values());
|
||||
}
|
||||
|
||||
function PriorityRailNewLayerRow({
|
||||
id,
|
||||
highlighted,
|
||||
@@ -566,6 +585,7 @@ function RouteCardInner({
|
||||
: undefined;
|
||||
const showAddChannelButton = !readOnlyRoute && !channelManagementDisabled;
|
||||
const showMissingTokenHints = !channelManagementDisabled && (missingTokenSiteItems.length > 0 || missingTokenGroupItems.length > 0);
|
||||
const routeUnits = collectRouteUnits(channels);
|
||||
const routingStrategyOptions = [
|
||||
{
|
||||
value: 'weighted',
|
||||
@@ -975,6 +995,32 @@ function RouteCardInner({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{routeUnits.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--color-text-secondary)' }}>
|
||||
OAuth 路由池
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
{routeUnits.map((routeUnit) => (
|
||||
<span
|
||||
key={`route-unit-${String(routeUnit.id)}`}
|
||||
className="badge badge-info"
|
||||
style={{
|
||||
fontSize: 10.5,
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={`${routeUnit.name?.trim() || 'OAuth 路由池'} · ${routeUnit.memberCount} 个成员 · ${getRouteUnitStrategyLabel(routeUnit.strategy)}`}
|
||||
>
|
||||
{(routeUnit.name?.trim() || 'OAuth 路由池')} · {routeUnit.memberCount} 个成员 · {getRouteUnitStrategyLabel(routeUnit.strategy)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!readOnlyRoute && (
|
||||
<div
|
||||
data-testid={compact ? 'compact-route-action-row' : undefined}
|
||||
|
||||
@@ -9,6 +9,16 @@ import {
|
||||
} from './tokenBindingPresentation.js';
|
||||
import { getChannelDecisionState, getPriorityTagStyle, getProbabilityColor } from './utils.js';
|
||||
|
||||
function getRouteUnitStrategyLabel(strategy: string | null | undefined): string {
|
||||
return strategy === 'stick_until_unavailable' ? '单个用到不可用再切' : '轮询';
|
||||
}
|
||||
|
||||
function formatRouteUnitMemberLabel(member: { accountId: number; username: string | null; siteName: string | null }): string {
|
||||
const accountLabel = member.username?.trim() || `account-${member.accountId}`;
|
||||
const siteLabel = member.siteName?.trim();
|
||||
return siteLabel ? `${accountLabel} @ ${siteLabel}` : accountLabel;
|
||||
}
|
||||
|
||||
export function SortableChannelRow({
|
||||
channel,
|
||||
displayPriority,
|
||||
@@ -87,6 +97,13 @@ export function SortableChannelRow({
|
||||
accountName: channel.account?.username || `account-${channel.accountId}`,
|
||||
},
|
||||
);
|
||||
const routeUnit = channel.routeUnit ?? null;
|
||||
const routeUnitName = routeUnit?.name?.trim() || 'OAuth 路由池';
|
||||
const routeUnitStrategyLabel = routeUnit ? getRouteUnitStrategyLabel(routeUnit.strategy) : '';
|
||||
const routeUnitMemberSummary = routeUnit?.members?.length
|
||||
? routeUnit.members.map((member) => formatRouteUnitMemberLabel(member)).join('、')
|
||||
: null;
|
||||
const routeUnitMemberSummaryText = routeUnitMemberSummary ? `成员:${routeUnitMemberSummary}` : null;
|
||||
|
||||
const [mobileDetailsOpen, setMobileDetailsOpen] = useState(false);
|
||||
|
||||
@@ -193,8 +210,36 @@ export function SortableChannelRow({
|
||||
手动配置
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{routeUnit ? (
|
||||
<>
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
OAuth 路由池
|
||||
</span>
|
||||
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
||||
{routeUnitName}
|
||||
</span>
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
{routeUnit.memberCount} 成员
|
||||
</span>
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
{routeUnitStrategyLabel}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{routeUnitMemberSummaryText ? (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
|
||||
成员摘要({routeUnit?.memberCount || 0} 个成员 · {routeUnitStrategyLabel})
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-secondary)', lineHeight: 1.45 }}>
|
||||
{routeUnitMemberSummaryText}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>选中概率</span>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, minWidth: 96 }}>
|
||||
@@ -404,6 +449,34 @@ export function SortableChannelRow({
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>已禁用</span>
|
||||
) : null}
|
||||
|
||||
{routeUnit ? (
|
||||
<>
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
OAuth 路由池
|
||||
</span>
|
||||
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
||||
{routeUnitName}
|
||||
</span>
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
{routeUnit.memberCount} 成员
|
||||
</span>
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
{routeUnitStrategyLabel}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{routeUnitMemberSummaryText ? (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 6, width: '100%', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
|
||||
成员摘要({routeUnit?.memberCount || 0} 个成员 · {routeUnitStrategyLabel})
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-secondary)', lineHeight: 1.45 }}>
|
||||
{routeUnitMemberSummaryText}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', marginTop: mobile ? 0 : 1, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>选中概率</span>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, minWidth: 96 }}>
|
||||
|
||||
@@ -7,6 +7,7 @@ export type RouteSortBy = 'modelPattern' | 'channelCount';
|
||||
export type RouteSortDir = 'asc' | 'desc';
|
||||
export type GroupFilter = null | '__all__' | number;
|
||||
export type RouteRoutingStrategy = 'weighted' | 'round_robin' | 'stable_first';
|
||||
export type OAuthRouteUnitStrategy = 'round_robin' | 'stick_until_unavailable';
|
||||
export type RouteRowKind = 'persisted' | 'zero_channel';
|
||||
export type RouteChannelDraft = {
|
||||
accountId: number;
|
||||
@@ -14,6 +15,20 @@ export type RouteChannelDraft = {
|
||||
sourceModel: string;
|
||||
};
|
||||
|
||||
export type RouteChannelRouteUnitMember = {
|
||||
accountId: number;
|
||||
username: string | null;
|
||||
siteName: string | null;
|
||||
};
|
||||
|
||||
export type RouteChannelRouteUnit = {
|
||||
id: number | string;
|
||||
name: string | null;
|
||||
strategy: OAuthRouteUnitStrategy;
|
||||
memberCount: number;
|
||||
members?: RouteChannelRouteUnitMember[];
|
||||
};
|
||||
|
||||
export type RouteChannel = {
|
||||
id: number;
|
||||
routeId?: number;
|
||||
@@ -45,6 +60,8 @@ export type RouteChannel = {
|
||||
enabled: boolean;
|
||||
isDefault: boolean;
|
||||
} | null;
|
||||
oauthRouteUnitId?: number | null;
|
||||
routeUnit?: RouteChannelRouteUnit | null;
|
||||
};
|
||||
|
||||
export type RouteRow = {
|
||||
|
||||
@@ -226,6 +226,137 @@ describe('TokenRoutes grouped source models', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('renders oauth route unit summary and members after expanding a pooled route', async () => {
|
||||
apiMock.getRoutesSummary.mockResolvedValue([
|
||||
{
|
||||
id: 31, modelPattern: 'gpt-4.1', displayName: null,
|
||||
displayIcon: null, modelMapping: null, enabled: true,
|
||||
channelCount: 1, enabledChannelCount: 1, siteNames: ['codex-oauth'],
|
||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||
},
|
||||
]);
|
||||
apiMock.getRouteChannels.mockResolvedValue([
|
||||
{
|
||||
id: 511, routeId: 31, accountId: 901, tokenId: null, sourceModel: 'gpt-4.1',
|
||||
priority: 0, weight: 1, enabled: true, manualOverride: false,
|
||||
successCount: 5, failCount: 1,
|
||||
account: { username: 'route-unit-anchor', credentialMode: 'oauth' },
|
||||
site: { id: 41, name: 'codex-oauth', platform: 'openai' },
|
||||
token: null,
|
||||
routeUnit: {
|
||||
id: 'pool-31',
|
||||
name: 'Codex Pool A',
|
||||
strategy: 'stick_until_unavailable',
|
||||
memberCount: 3,
|
||||
members: [
|
||||
{ accountId: 901, username: 'route-unit-anchor', siteName: 'codex-oauth' },
|
||||
{ accountId: 902, username: 'route-unit-backup', siteName: 'codex-oauth' },
|
||||
{ accountId: 903, username: 'route-unit-third', siteName: 'codex-oauth' },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const expandBtn = root.root.find((node) =>
|
||||
node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
|
||||
);
|
||||
await act(async () => {
|
||||
expandBtn.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('Codex Pool A');
|
||||
expect(text).toContain('3 个成员');
|
||||
expect(text).toContain('单个用到不可用再切');
|
||||
expect(text).toContain('成员摘要');
|
||||
expect(text).toContain('route-unit-anchor');
|
||||
expect(text).toContain('route-unit-backup');
|
||||
expect(text).toContain('route-unit-third');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows oauth route unit summary and member details after expanding a pooled route', async () => {
|
||||
apiMock.getRoutesSummary.mockResolvedValue([
|
||||
{
|
||||
id: 1, modelPattern: 'gpt-5-codex', displayName: 'gpt-5-codex',
|
||||
displayIcon: null, modelMapping: null, enabled: true,
|
||||
channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'],
|
||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||
},
|
||||
]);
|
||||
apiMock.getRouteChannels.mockResolvedValue([
|
||||
{
|
||||
id: 11, routeId: 1, accountId: 101, tokenId: null, sourceModel: 'gpt-5-codex',
|
||||
priority: 0, weight: 1, enabled: true, manualOverride: false,
|
||||
successCount: 0, failCount: 0,
|
||||
account: { username: 'pool-representative', credentialMode: 'oauth' },
|
||||
site: { id: 1, name: 'site-a', platform: 'openai' },
|
||||
token: null,
|
||||
routeUnit: {
|
||||
id: 17,
|
||||
name: 'Codex 池',
|
||||
memberCount: 3,
|
||||
strategy: 'stick_until_unavailable',
|
||||
members: [
|
||||
{ accountId: 101, username: 'user_a', siteName: 'site-a' },
|
||||
{ accountId: 102, username: 'user_b', siteName: 'site-b' },
|
||||
{ accountId: 103, username: 'user_c', siteName: 'site-c' },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const expandBtn = root.root.find((node) =>
|
||||
node.type === 'div'
|
||||
&& String(node.props.className || '').includes('route-card-collapsed')
|
||||
&& collectText(node).includes('gpt-5-codex'),
|
||||
);
|
||||
await act(async () => {
|
||||
expandBtn.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const expandedText = collectText(root.root);
|
||||
expect(expandedText).toContain('OAuth 路由池');
|
||||
expect(expandedText).toContain('Codex 池');
|
||||
expect(expandedText).toContain('3 个成员');
|
||||
expect(expandedText).toContain('单个用到不可用再切');
|
||||
expect(expandedText).toContain('成员摘要');
|
||||
expect(expandedText).toContain('user_a @ site-a、user_b @ site-b、user_c @ site-c');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('writes explicit-group priority edits back to source channels and confirms shared-source impact', async () => {
|
||||
apiMock.getRoutesSummary.mockResolvedValue([
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user