1
0
forked from boos/BoosAPI

Merge branch 'main' into feat/account-level-proxy

# Conflicts:
#	src/server/routes/api/accountTokens.ts
#	src/server/routes/proxy/chat.ts
#	src/server/routes/proxy/completions.ts
#	src/server/routes/proxy/embeddings.ts
#	src/server/routes/proxy/endpointFlow.ts
#	src/server/routes/proxy/images.ts
#	src/server/routes/proxy/responses.ts
#	src/server/services/accountExtraConfig.test.ts
#	src/server/services/modelService.ts
This commit is contained in:
Hureru
2026-03-18 23:42:21 +08:00
199 changed files with 27415 additions and 1901 deletions
+24
View File
@@ -0,0 +1,24 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('desktop icon assets', () => {
it('keeps runtime icon paths aligned to the generated desktop icon', async () => {
const {
DESKTOP_BUILD_ICON_RELATIVE_PATH,
DESKTOP_RUNTIME_ICON_RELATIVE_PATH,
getDesktopRuntimeIconPath,
} = await import('./iconAssets.js');
expect(DESKTOP_RUNTIME_ICON_RELATIVE_PATH).toBe(join('dist', 'web', 'desktop-icon.png'));
expect(DESKTOP_BUILD_ICON_RELATIVE_PATH).toBe(join('build', 'desktop-icon.png'));
expect(getDesktopRuntimeIconPath('/app')).toBe(join('/app', 'dist', 'web', 'desktop-icon.png'));
});
it('points electron-builder at the generated desktop package icon', async () => {
const configPath = join(process.cwd(), 'electron-builder.yml');
const config = await readFile(configPath, 'utf8');
expect(config).toContain('icon: build/desktop-icon.png');
});
});
+9
View File
@@ -0,0 +1,9 @@
import { join } from 'node:path';
export const DESKTOP_RUNTIME_ICON_RELATIVE_PATH = join('dist', 'web', 'desktop-icon.png');
export const DESKTOP_BUILD_ICON_RELATIVE_PATH = join('build', 'desktop-icon.png');
export const DESKTOP_ICON_PUBLIC_PATH = '/desktop-icon.png';
export function getDesktopRuntimeIconPath(appPath: string) {
return join(appPath, DESKTOP_RUNTIME_ICON_RELATIVE_PATH);
}
+4 -9
View File
@@ -9,7 +9,6 @@ import {
} from 'electron';
import log from 'electron-log';
import electronUpdater from 'electron-updater';
import getPort, { portNumbers } from 'get-port';
import { spawn, type ChildProcess } from 'node:child_process';
import { existsSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
@@ -18,9 +17,11 @@ import {
createDesktopHealthUrl,
createDesktopServerUrl,
isFatalServerExit,
resolveDesktopServerPort,
resolveDesktopServerWorkingDir,
waitForServerReady,
} from './runtime.js';
import { getDesktopRuntimeIconPath } from './iconAssets.js';
const { autoUpdater } = electronUpdater;
@@ -51,7 +52,7 @@ function getServerEntryPath() {
}
function getTrayIconPath() {
return join(app.getAppPath(), 'dist', 'web', 'logo.png');
return getDesktopRuntimeIconPath(app.getAppPath());
}
function getWindowIconPath() {
@@ -68,12 +69,6 @@ function resolveExternalServerUrl() {
return (process.env.METAPI_DESKTOP_EXTERNAL_SERVER_URL || '').trim();
}
async function resolveServerPort() {
const forcedPort = Number.parseInt(process.env.METAPI_DESKTOP_SERVER_PORT || '', 10);
if (Number.isFinite(forcedPort) && forcedPort > 0) return forcedPort;
return getPort({ port: portNumbers(4310, 4399) });
}
function showMainWindow() {
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
@@ -185,7 +180,7 @@ async function waitForManagedServerReady(url: string) {
async function startManagedBackend() {
ensureDesktopDirs();
const serverEntryPath = getServerEntryPath();
const port = await resolveServerPort();
const port = resolveDesktopServerPort(process.env);
serverUrl = createDesktopServerUrl(port);
const env = buildDesktopServerEnv({
+13 -2
View File
@@ -3,12 +3,13 @@ import {
buildDesktopServerEnv,
createDesktopServerUrl,
isFatalServerExit,
resolveDesktopServerPort,
resolveDesktopServerWorkingDir,
waitForServerReady,
} from './runtime.js';
describe('desktop runtime helpers', () => {
it('builds desktop server env with loopback host and app directories', () => {
it('builds desktop server env with external listen host and app directories', () => {
const env = buildDesktopServerEnv({
inheritedEnv: {
AUTH_TOKEN: 'admin-token',
@@ -19,7 +20,7 @@ describe('desktop runtime helpers', () => {
port: 4312,
});
expect(env.HOST).toBe('127.0.0.1');
expect(env.HOST).toBe('0.0.0.0');
expect(env.PORT).toBe('4312');
expect(env.DATA_DIR).toBe('/tmp/metapi-data');
expect(env.METAPI_LOG_DIR).toBe('/tmp/metapi-logs');
@@ -31,6 +32,16 @@ describe('desktop runtime helpers', () => {
expect(createDesktopServerUrl(4312)).toBe('http://127.0.0.1:4312');
});
it('defaults desktop backend port to 4000', () => {
expect(resolveDesktopServerPort({})).toBe(4000);
});
it('honors explicit desktop backend port override', () => {
expect(resolveDesktopServerPort({
METAPI_DESKTOP_SERVER_PORT: '4312',
})).toBe(4312);
});
it('uses resources path as backend cwd for packaged desktop builds', () => {
expect(resolveDesktopServerWorkingDir({
appPath: 'C:/Users/test/AppData/Local/Programs/Metapi/resources/app.asar',
+10 -1
View File
@@ -23,6 +23,7 @@ type DesktopServerWorkingDirInput = {
isPackaged: boolean;
};
const DEFAULT_DESKTOP_SERVER_PORT = 4000;
const DEFAULT_READY_TIMEOUT_MS = 30_000;
const DEFAULT_READY_INTERVAL_MS = 250;
@@ -31,9 +32,11 @@ function delay(ms: number) {
}
export function buildDesktopServerEnv(input: DesktopServerEnvInput): NodeJS.ProcessEnv {
const host = (input.inheritedEnv?.HOST || '0.0.0.0').trim() || '0.0.0.0';
return {
...(input.inheritedEnv || {}),
HOST: '127.0.0.1',
HOST: host,
PORT: String(input.port),
DATA_DIR: input.userDataDir,
METAPI_DESKTOP: '1',
@@ -49,6 +52,12 @@ export function createDesktopHealthUrl(port: number): string {
return `${createDesktopServerUrl(port)}/api/desktop/health`;
}
export function resolveDesktopServerPort(env?: NodeJS.ProcessEnv): number {
const forcedPort = Number.parseInt(env?.METAPI_DESKTOP_SERVER_PORT || '', 10);
if (Number.isFinite(forcedPort) && forcedPort > 0) return forcedPort;
return DEFAULT_DESKTOP_SERVER_PORT;
}
export function resolveDesktopServerWorkingDir(input: DesktopServerWorkingDirInput): string {
return input.isPackaged ? input.resourcesPath : input.appPath;
}
+12 -2
View File
@@ -11,7 +11,7 @@ describe('buildConfig', () => {
expect(config.dataDir).toBe('./data');
});
it('keeps desktop deployments bound to loopback', () => {
it('aligns desktop deployments with server deployments for listen host', () => {
const config = buildConfig({
HOST: '0.0.0.0',
METAPI_DESKTOP: '1',
@@ -19,7 +19,7 @@ describe('buildConfig', () => {
DATA_DIR: '/tmp/metapi-data',
});
expect(config.listenHost).toBe('127.0.0.1');
expect(config.listenHost).toBe('0.0.0.0');
expect(config.port).toBe(4312);
expect(config.dataDir).toBe('/tmp/metapi-data');
});
@@ -38,6 +38,16 @@ describe('buildConfig', () => {
expect(config.telegramApiBaseUrl).toBe('https://api.telegram.org');
});
it('ships CLI-aligned OAuth defaults', () => {
const config = buildConfig({});
expect(config.codexClientId).toBe('app_EMoamEEZ73f0CkXaXp7hrann');
expect(config.claudeClientId).toBe('9d1c250a-e61b-44d9-88ed-5944d1962f5e');
expect(config.claudeClientSecret).toBe('');
expect(config.geminiCliClientId).toBe('681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com');
expect(config.geminiCliClientSecret).toBe('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');
});
it('accepts JSON request bodies larger than Fastify default 1 MiB', async () => {
const app = Fastify(buildFastifyOptions(buildConfig({})));
const largeText = 'a'.repeat(2 * 1024 * 1024);
+14 -5
View File
@@ -2,6 +2,10 @@ import 'dotenv/config';
import type { FastifyServerOptions } from 'fastify';
const DEFAULT_REQUEST_BODY_LIMIT = 20 * 1024 * 1024;
const DEFAULT_CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
const DEFAULT_CLAUDE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
const DEFAULT_GEMINI_CLI_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com';
const DEFAULT_GEMINI_CLI_CLIENT_SECRET = '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';
function parseBoolean(value: string | undefined, fallback = false): boolean {
if (value === undefined) return fallback;
@@ -24,6 +28,10 @@ function parseCsvList(value: string | undefined): string[] {
.filter((item) => item.length > 0);
}
function parseOptionalSecret(value: string | undefined): string {
return (value || '').trim();
}
function parseDbType(value: string | undefined): 'sqlite' | 'mysql' | 'postgres' {
const normalized = (value || 'sqlite').trim().toLowerCase();
if (normalized === 'mysql') return 'mysql';
@@ -32,11 +40,7 @@ function parseDbType(value: string | undefined): 'sqlite' | 'mysql' | 'postgres'
}
function parseListenHost(env: NodeJS.ProcessEnv): string {
const requestedHost = (env.HOST || '0.0.0.0').trim() || '0.0.0.0';
if (parseBoolean(env.METAPI_DESKTOP, false)) {
return '127.0.0.1';
}
return requestedHost;
return (env.HOST || '0.0.0.0').trim() || '0.0.0.0';
}
export function buildConfig(env: NodeJS.ProcessEnv) {
@@ -45,6 +49,11 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
return {
authToken: env.AUTH_TOKEN || 'change-me-admin-token',
proxyToken: env.PROXY_TOKEN || 'change-me-proxy-sk-token',
codexClientId: parseOptionalSecret(env.CODEX_CLIENT_ID) || DEFAULT_CODEX_CLIENT_ID,
claudeClientId: parseOptionalSecret(env.CLAUDE_CLIENT_ID) || DEFAULT_CLAUDE_CLIENT_ID,
claudeClientSecret: parseOptionalSecret(env.CLAUDE_CLIENT_SECRET),
geminiCliClientId: parseOptionalSecret(env.GEMINI_CLI_CLIENT_ID) || DEFAULT_GEMINI_CLI_CLIENT_ID,
geminiCliClientSecret: parseOptionalSecret(env.GEMINI_CLI_CLIENT_SECRET) || DEFAULT_GEMINI_CLI_CLIENT_SECRET,
systemProxyUrl: env.SYSTEM_PROXY_URL || '',
accountCredentialSecret: env.ACCOUNT_CREDENTIAL_SECRET || env.AUTH_TOKEN || 'change-me-admin-token',
checkinCron: env.CHECKIN_CRON || '0 8 * * *',
@@ -23,6 +23,15 @@ const ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS: AccountTokenColumnCompatibilityS
postgres: 'ALTER TABLE "account_tokens" ADD COLUMN "token_group" TEXT',
},
},
{
table: 'account_tokens',
column: 'value_status',
addSql: {
sqlite: "ALTER TABLE account_tokens ADD COLUMN value_status text NOT NULL DEFAULT 'ready';",
mysql: "ALTER TABLE `account_tokens` ADD COLUMN `value_status` VARCHAR(191) NOT NULL DEFAULT 'ready'",
postgres: "ALTER TABLE \"account_tokens\" ADD COLUMN \"value_status\" TEXT NOT NULL DEFAULT 'ready'",
},
},
];
function normalizeSchemaErrorMessage(error: unknown): string {
+12 -5
View File
@@ -1,15 +1,16 @@
CREATE TABLE IF NOT EXISTS `sites` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `platform` TEXT NOT NULL, `status` VARCHAR(191) NOT NULL DEFAULT 'active', `api_key` TEXT, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, `proxy_url` TEXT, `use_system_proxy` BOOLEAN DEFAULT false, `custom_headers` JSON, `external_checkin_url` TEXT, `global_weight` DOUBLE DEFAULT 1);
CREATE TABLE IF NOT EXISTS `accounts` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `username` TEXT, `access_token` TEXT NOT NULL, `api_token` TEXT, `balance` DOUBLE DEFAULT 0, `balance_used` DOUBLE DEFAULT 0, `quota` DOUBLE DEFAULT 0, `unit_cost` DOUBLE, `value_score` DOUBLE DEFAULT 0, `status` VARCHAR(191) DEFAULT 'active', `checkin_enabled` BOOLEAN DEFAULT true, `last_checkin_at` DATETIME, `last_balance_refresh` DATETIME, `extra_config` JSON, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS `account_tokens` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `name` TEXT NOT NULL, `token` TEXT NOT NULL, `source` VARCHAR(191) DEFAULT 'manual', `enabled` BOOLEAN DEFAULT true, `is_default` BOOLEAN DEFAULT false, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `token_group` TEXT, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS `accounts` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `username` TEXT, `access_token` TEXT NOT NULL, `api_token` TEXT, `balance` DOUBLE DEFAULT 0, `balance_used` DOUBLE DEFAULT 0, `quota` DOUBLE DEFAULT 0, `unit_cost` DOUBLE, `value_score` DOUBLE DEFAULT 0, `status` VARCHAR(191) DEFAULT 'active', `checkin_enabled` BOOLEAN DEFAULT true, `last_checkin_at` DATETIME, `last_balance_refresh` DATETIME, `extra_config` JSON, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, `oauth_provider` TEXT, `oauth_account_key` TEXT, `oauth_project_id` TEXT, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS `account_tokens` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `name` TEXT NOT NULL, `token` TEXT NOT NULL, `source` VARCHAR(191) DEFAULT 'manual', `enabled` BOOLEAN DEFAULT true, `is_default` BOOLEAN DEFAULT false, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `token_group` TEXT, `value_status` VARCHAR(191) NOT NULL DEFAULT 'ready', FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS `checkin_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `status` TEXT NOT NULL, `message` TEXT, `reward` TEXT, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS `downstream_api_keys` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `key` TEXT NOT NULL, `description` TEXT, `enabled` BOOLEAN DEFAULT true, `expires_at` DATETIME, `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` DATETIME, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP);
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` DATETIME, `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` DATETIME, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `group_name` TEXT, `tags` TEXT);
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` DATETIME DEFAULT CURRENT_TIMESTAMP);
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` DATETIME DEFAULT CURRENT_TIMESTAMP, `is_manual` BOOLEAN DEFAULT false, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`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` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `deleted_at` DATETIME);
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` DATETIME DEFAULT CURRENT_TIMESTAMP, `billing_details` JSON);
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` DATETIME DEFAULT CURRENT_TIMESTAMP, `billing_details` JSON, `downstream_api_key_id` 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` DATETIME, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP);
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` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `display_name` TEXT, `display_icon` TEXT, `decision_snapshot` JSON, `decision_refreshed_at` DATETIME, `routing_strategy` VARCHAR(191) DEFAULT 'weighted');
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` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `display_name` TEXT, `display_icon` TEXT, `decision_snapshot` JSON, `decision_refreshed_at` DATETIME, `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` DATETIME, `last_selected_at` DATETIME, `last_fail_at` DATETIME, `consecutive_fail_count` INT NOT NULL DEFAULT 0, `cooldown_level` INT NOT NULL DEFAULT 0, `cooldown_until` DATETIME, 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_disabled_models` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `model_name` TEXT NOT NULL, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
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` DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON DELETE CASCADE);
@@ -17,11 +18,15 @@ CREATE UNIQUE INDEX `downstream_api_keys_key_unique` ON `downstream_api_keys` (`
CREATE UNIQUE INDEX `model_availability_account_model_unique` ON `model_availability` (`account_id`, `model_name`(191));
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));
CREATE UNIQUE INDEX `route_group_sources_group_source_unique` ON `route_group_sources` (`group_route_id`, `source_route_id`);
CREATE UNIQUE INDEX `site_disabled_models_site_model_unique` ON `site_disabled_models` (`site_id`, `model_name`(191));
CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`(191), `url`(191));
CREATE UNIQUE INDEX `token_model_availability_token_model_unique` ON `token_model_availability` (`token_id`, `model_name`(191));
CREATE INDEX `account_tokens_account_enabled_idx` ON `account_tokens` (`account_id`, `enabled`);
CREATE INDEX `account_tokens_account_id_idx` ON `account_tokens` (`account_id`);
CREATE INDEX `account_tokens_enabled_idx` ON `account_tokens` (`enabled`);
CREATE INDEX `accounts_oauth_identity_idx` ON `accounts` (`oauth_provider`(191), `oauth_account_key`(191), `oauth_project_id`(191));
CREATE INDEX `accounts_oauth_provider_idx` ON `accounts` (`oauth_provider`(191));
CREATE INDEX `accounts_site_id_idx` ON `accounts` (`site_id`);
CREATE INDEX `accounts_site_status_idx` ON `accounts` (`site_id`, `status`(191));
CREATE INDEX `accounts_status_idx` ON `accounts` (`status`(191));
@@ -39,6 +44,7 @@ CREATE INDEX `model_availability_model_name_idx` ON `model_availability` (`model
CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(191), `owner_id`(191), `deleted_at`);
CREATE INDEX `proxy_logs_account_created_at_idx` ON `proxy_logs` (`account_id`, `created_at`);
CREATE INDEX `proxy_logs_created_at_idx` ON `proxy_logs` (`created_at`);
CREATE INDEX `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`);
CREATE INDEX `proxy_logs_model_actual_created_at_idx` ON `proxy_logs` (`model_actual`(191), `created_at`);
CREATE INDEX `proxy_logs_status_created_at_idx` ON `proxy_logs` (`status`(191), `created_at`);
CREATE INDEX `proxy_video_tasks_created_at_idx` ON `proxy_video_tasks` (`created_at`);
@@ -48,6 +54,7 @@ CREATE INDEX `route_channels_route_enabled_idx` ON `route_channels` (`route_id`,
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`);
CREATE INDEX `route_channels_token_id_idx` ON `route_channels` (`token_id`);
CREATE INDEX `route_group_sources_source_route_id_idx` ON `route_group_sources` (`source_route_id`);
CREATE INDEX `site_disabled_models_site_id_idx` ON `site_disabled_models` (`site_id`);
CREATE INDEX `sites_status_idx` ON `sites` (`status`(191));
CREATE INDEX `token_model_availability_available_idx` ON `token_model_availability` (`available`);
+4 -1
View File
@@ -1 +1,4 @@
ALTER TABLE `model_availability` ADD COLUMN `is_manual` BOOLEAN DEFAULT false;
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);
ALTER TABLE `token_routes` ADD COLUMN `route_mode` VARCHAR(191) DEFAULT 'pattern';
CREATE UNIQUE INDEX `route_group_sources_group_source_unique` ON `route_group_sources` (`group_route_id`, `source_route_id`);
CREATE INDEX `route_group_sources_source_route_id_idx` ON `route_group_sources` (`source_route_id`);
+12 -5
View File
@@ -1,15 +1,16 @@
CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "platform" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'active', "api_key" TEXT, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, "proxy_url" TEXT, "use_system_proxy" BOOLEAN DEFAULT false, "custom_headers" JSONB, "external_checkin_url" TEXT, "global_weight" DOUBLE PRECISION DEFAULT 1);
CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" DOUBLE PRECISION DEFAULT 0, "balance_used" DOUBLE PRECISION DEFAULT 0, "quota" DOUBLE PRECISION DEFAULT 0, "unit_cost" DOUBLE PRECISION, "value_score" DOUBLE PRECISION DEFAULT 0, "status" TEXT DEFAULT 'active', "checkin_enabled" BOOLEAN DEFAULT true, "last_checkin_at" TIMESTAMP, "last_balance_refresh" TIMESTAMP, "extra_config" JSONB, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT true, "is_default" BOOLEAN DEFAULT false, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "token_group" TEXT, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" DOUBLE PRECISION DEFAULT 0, "balance_used" DOUBLE PRECISION DEFAULT 0, "quota" DOUBLE PRECISION DEFAULT 0, "unit_cost" DOUBLE PRECISION, "value_score" DOUBLE PRECISION DEFAULT 0, "status" TEXT DEFAULT 'active', "checkin_enabled" BOOLEAN DEFAULT true, "last_checkin_at" TIMESTAMP, "last_balance_refresh" TIMESTAMP, "extra_config" JSONB, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, "oauth_provider" TEXT, "oauth_account_key" TEXT, "oauth_project_id" TEXT, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT true, "is_default" BOOLEAN DEFAULT false, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "token_group" TEXT, "value_status" TEXT NOT NULL DEFAULT 'ready', FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL, "description" TEXT, "enabled" BOOLEAN DEFAULT true, "expires_at" TIMESTAMP, "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" TIMESTAMP, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
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" TIMESTAMP, "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" TIMESTAMP, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "group_name" TEXT, "tags" TEXT);
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" TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
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" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "is_manual" BOOLEAN DEFAULT false, FOREIGN KEY ("account_id") REFERENCES "accounts"("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" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "deleted_at" TIMESTAMP);
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" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "billing_details" JSONB);
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" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "billing_details" JSONB, "downstream_api_key_id" 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" TIMESTAMP, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
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" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "display_name" TEXT, "display_icon" TEXT, "decision_snapshot" JSONB, "decision_refreshed_at" TIMESTAMP, "routing_strategy" TEXT DEFAULT 'weighted');
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" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "display_name" TEXT, "display_icon" TEXT, "decision_snapshot" JSONB, "decision_refreshed_at" TIMESTAMP, "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" TIMESTAMP, "last_selected_at" TIMESTAMP, "last_fail_at" TIMESTAMP, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" TIMESTAMP, 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_disabled_models" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
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" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY ("token_id") REFERENCES "account_tokens"("id") ON DELETE CASCADE);
@@ -17,11 +18,15 @@ CREATE UNIQUE INDEX "downstream_api_keys_key_unique" ON "downstream_api_keys" ("
CREATE UNIQUE INDEX "model_availability_account_model_unique" ON "model_availability" ("account_id", "model_name");
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");
CREATE UNIQUE INDEX "route_group_sources_group_source_unique" ON "route_group_sources" ("group_route_id", "source_route_id");
CREATE UNIQUE INDEX "site_disabled_models_site_model_unique" ON "site_disabled_models" ("site_id", "model_name");
CREATE UNIQUE INDEX "sites_platform_url_unique" ON "sites" ("platform", "url");
CREATE UNIQUE INDEX "token_model_availability_token_model_unique" ON "token_model_availability" ("token_id", "model_name");
CREATE INDEX "account_tokens_account_enabled_idx" ON "account_tokens" ("account_id", "enabled");
CREATE INDEX "account_tokens_account_id_idx" ON "account_tokens" ("account_id");
CREATE INDEX "account_tokens_enabled_idx" ON "account_tokens" ("enabled");
CREATE INDEX "accounts_oauth_identity_idx" ON "accounts" ("oauth_provider", "oauth_account_key", "oauth_project_id");
CREATE INDEX "accounts_oauth_provider_idx" ON "accounts" ("oauth_provider");
CREATE INDEX "accounts_site_id_idx" ON "accounts" ("site_id");
CREATE INDEX "accounts_site_status_idx" ON "accounts" ("site_id", "status");
CREATE INDEX "accounts_status_idx" ON "accounts" ("status");
@@ -39,6 +44,7 @@ CREATE INDEX "model_availability_model_name_idx" ON "model_availability" ("model
CREATE INDEX "proxy_files_owner_lookup_idx" ON "proxy_files" ("owner_type", "owner_id", "deleted_at");
CREATE INDEX "proxy_logs_account_created_at_idx" ON "proxy_logs" ("account_id", "created_at");
CREATE INDEX "proxy_logs_created_at_idx" ON "proxy_logs" ("created_at");
CREATE INDEX "proxy_logs_downstream_api_key_created_at_idx" ON "proxy_logs" ("downstream_api_key_id", "created_at");
CREATE INDEX "proxy_logs_model_actual_created_at_idx" ON "proxy_logs" ("model_actual", "created_at");
CREATE INDEX "proxy_logs_status_created_at_idx" ON "proxy_logs" ("status", "created_at");
CREATE INDEX "proxy_video_tasks_created_at_idx" ON "proxy_video_tasks" ("created_at");
@@ -48,6 +54,7 @@ CREATE INDEX "route_channels_route_enabled_idx" ON "route_channels" ("route_id",
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");
CREATE INDEX "route_channels_token_id_idx" ON "route_channels" ("token_id");
CREATE INDEX "route_group_sources_source_route_id_idx" ON "route_group_sources" ("source_route_id");
CREATE INDEX "site_disabled_models_site_id_idx" ON "site_disabled_models" ("site_id");
CREATE INDEX "sites_status_idx" ON "sites" ("status");
CREATE INDEX "token_model_availability_available_idx" ON "token_model_availability" ("available");
+4 -1
View File
@@ -1 +1,4 @@
ALTER TABLE "model_availability" ADD COLUMN "is_manual" BOOLEAN DEFAULT false;
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);
ALTER TABLE "token_routes" ADD COLUMN "route_mode" TEXT DEFAULT 'pattern';
CREATE UNIQUE INDEX "route_group_sources_group_source_unique" ON "route_group_sources" ("group_route_id", "source_route_id");
CREATE INDEX "route_group_sources_source_route_id_idx" ON "route_group_sources" ("source_route_id");
+161
View File
@@ -61,6 +61,12 @@
"notNull": false,
"defaultValue": null,
"primaryKey": false
},
"value_status": {
"logicalType": "text",
"notNull": true,
"defaultValue": "'ready'",
"primaryKey": false
}
}
},
@@ -179,6 +185,24 @@
"notNull": false,
"defaultValue": "0",
"primaryKey": false
},
"oauth_provider": {
"logicalType": "text",
"notNull": false,
"defaultValue": null,
"primaryKey": false
},
"oauth_account_key": {
"logicalType": "text",
"notNull": false,
"defaultValue": null,
"primaryKey": false
},
"oauth_project_id": {
"logicalType": "text",
"notNull": false,
"defaultValue": null,
"primaryKey": false
}
}
},
@@ -319,6 +343,18 @@
"notNull": false,
"defaultValue": "datetime('now')",
"primaryKey": false
},
"group_name": {
"logicalType": "text",
"notNull": false,
"defaultValue": null,
"primaryKey": false
},
"tags": {
"logicalType": "text",
"notNull": false,
"defaultValue": null,
"primaryKey": false
}
}
},
@@ -611,6 +647,12 @@
"notNull": false,
"defaultValue": null,
"primaryKey": false
},
"downstream_api_key_id": {
"logicalType": "integer",
"notNull": false,
"defaultValue": null,
"primaryKey": false
}
}
},
@@ -826,6 +868,28 @@
}
}
},
"route_group_sources": {
"columns": {
"id": {
"logicalType": "integer",
"notNull": true,
"defaultValue": null,
"primaryKey": true
},
"group_route_id": {
"logicalType": "integer",
"notNull": true,
"defaultValue": null,
"primaryKey": false
},
"source_route_id": {
"logicalType": "integer",
"notNull": true,
"defaultValue": null,
"primaryKey": false
}
}
},
"settings": {
"columns": {
"key": {
@@ -1071,6 +1135,12 @@
"notNull": false,
"defaultValue": "'weighted'",
"primaryKey": false
},
"route_mode": {
"logicalType": "text",
"notNull": false,
"defaultValue": "'pattern'",
"primaryKey": false
}
}
}
@@ -1101,6 +1171,24 @@
],
"unique": false
},
{
"name": "accounts_oauth_identity_idx",
"table": "accounts",
"columns": [
"oauth_provider",
"oauth_account_key",
"oauth_project_id"
],
"unique": false
},
{
"name": "accounts_oauth_provider_idx",
"table": "accounts",
"columns": [
"oauth_provider"
],
"unique": false
},
{
"name": "accounts_site_id_idx",
"table": "accounts",
@@ -1270,6 +1358,15 @@
],
"unique": false
},
{
"name": "proxy_logs_downstream_api_key_created_at_idx",
"table": "proxy_logs",
"columns": [
"downstream_api_key_id",
"created_at"
],
"unique": false
},
{
"name": "proxy_logs_model_actual_created_at_idx",
"table": "proxy_logs",
@@ -1354,6 +1451,23 @@
],
"unique": false
},
{
"name": "route_group_sources_group_source_unique",
"table": "route_group_sources",
"columns": [
"group_route_id",
"source_route_id"
],
"unique": true
},
{
"name": "route_group_sources_source_route_id_idx",
"table": "route_group_sources",
"columns": [
"source_route_id"
],
"unique": false
},
{
"name": "site_disabled_models_site_id_idx",
"table": "site_disabled_models",
@@ -1371,6 +1485,15 @@
],
"unique": true
},
{
"name": "sites_platform_url_unique",
"table": "sites",
"columns": [
"platform",
"url"
],
"unique": true
},
{
"name": "sites_status_idx",
"table": "sites",
@@ -1460,6 +1583,14 @@
"public_id"
]
},
{
"name": "route_group_sources_group_source_unique",
"table": "route_group_sources",
"columns": [
"group_route_id",
"source_route_id"
]
},
{
"name": "site_disabled_models_site_model_unique",
"table": "site_disabled_models",
@@ -1468,6 +1599,14 @@
"model_name"
]
},
{
"name": "sites_platform_url_unique",
"table": "sites",
"columns": [
"platform",
"url"
]
},
{
"name": "token_model_availability_token_model_unique",
"table": "token_model_availability",
@@ -1555,6 +1694,28 @@
],
"onDelete": "SET NULL"
},
{
"table": "route_group_sources",
"columns": [
"group_route_id"
],
"referencedTable": "token_routes",
"referencedColumns": [
"id"
],
"onDelete": "CASCADE"
},
{
"table": "route_group_sources",
"columns": [
"source_route_id"
],
"referencedTable": "token_routes",
"referencedColumns": [
"id"
],
"onDelete": "CASCADE"
},
{
"table": "site_disabled_models",
"columns": [
+115 -7
View File
@@ -8,7 +8,6 @@ import * as schema from './schema.js';
import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteSchemaCompatibility.js';
import { ensureRouteGroupingSchemaCompatibility } from './routeGroupingSchemaCompatibility.js';
import { ensureProxyFileSchemaCompatibility } from './proxyFileSchemaCompatibility.js';
import { ensureSharedIndexSchemaCompatibility } from './sharedIndexSchemaCompatibility.js';
import { executeLegacyCompat, executeLegacyCompatSync } from './legacySchemaCompat.js';
import { config } from '../config.js';
import { ensureRuntimeDatabaseReady } from '../runtimeDatabaseBootstrap.js';
@@ -28,6 +27,7 @@ const TABLES_WITH_NUMERIC_ID = new Set([
'model_availability',
'token_model_availability',
'token_routes',
'route_group_sources',
'route_channels',
'proxy_logs',
'proxy_video_tasks',
@@ -42,6 +42,7 @@ let sqliteConnection: Database.Database | null = null;
let mysqlPool: mysql.Pool | null = null;
let pgPool: pg.Pool | null = null;
let proxyLogBillingDetailsColumnAvailable: boolean | null = null;
let proxyLogDownstreamApiKeyIdColumnAvailable: boolean | null = null;
function resolveSqlitePath(): string {
const raw = (config.dbUrl || '').trim();
@@ -137,6 +138,7 @@ function ensureTokenManagementSchema() {
name text NOT NULL,
token text NOT NULL,
token_group text,
value_status text NOT NULL DEFAULT 'ready',
source text DEFAULT 'manual',
enabled integer DEFAULT true,
is_default integer DEFAULT false,
@@ -152,6 +154,9 @@ function ensureTokenManagementSchema() {
if (!tableColumnExists('account_tokens', 'token_group')) {
execSqliteLegacyCompat('ALTER TABLE account_tokens ADD COLUMN token_group text;');
}
if (!tableColumnExists('account_tokens', 'value_status')) {
execSqliteLegacyCompat("ALTER TABLE account_tokens ADD COLUMN value_status text NOT NULL DEFAULT 'ready';");
}
execSqliteStatement(`
INSERT INTO account_tokens (account_id, name, token, source, enabled, is_default, created_at, updated_at)
@@ -446,12 +451,6 @@ export async function ensureProxyFileCompatibilityColumns(): Promise<void> {
await ensureProxyFileSchemaCompatibility(inspector);
}
export async function ensureSharedIndexCompatibility(): Promise<void> {
const inspector = createRuntimeSchemaInspector();
if (!inspector) return;
await ensureSharedIndexSchemaCompatibility(inspector);
}
function ensureRouteGroupingSchema() {
if (!tableExists('token_routes') || !tableExists('route_channels')) {
return;
@@ -465,6 +464,10 @@ function ensureRouteGroupingSchema() {
execSqliteLegacyCompat(`ALTER TABLE token_routes ADD COLUMN display_icon text;`);
}
if (!tableColumnExists('token_routes', 'route_mode')) {
execSqliteLegacyCompat(`ALTER TABLE token_routes ADD COLUMN route_mode text DEFAULT 'pattern';`);
}
if (!tableColumnExists('token_routes', 'decision_snapshot')) {
execSqliteLegacyCompat(`ALTER TABLE token_routes ADD COLUMN decision_snapshot text;`);
}
@@ -492,6 +495,22 @@ function ensureRouteGroupingSchema() {
if (!tableColumnExists('route_channels', 'cooldown_level')) {
execSqliteLegacyCompat(`ALTER TABLE route_channels ADD COLUMN cooldown_level integer NOT NULL DEFAULT 0;`);
}
execSqliteLegacyCompat(`
CREATE TABLE IF NOT EXISTS route_group_sources (
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
group_route_id integer NOT NULL REFERENCES token_routes(id) ON DELETE cascade,
source_route_id integer NOT NULL REFERENCES token_routes(id) ON DELETE cascade
);
`);
execSqliteLegacyCompat(`
CREATE UNIQUE INDEX IF NOT EXISTS route_group_sources_group_source_unique
ON route_group_sources(group_route_id, source_route_id);
`);
execSqliteLegacyCompat(`
CREATE INDEX IF NOT EXISTS route_group_sources_source_route_id_idx
ON route_group_sources(source_route_id);
`);
}
function ensureDownstreamApiKeySchema() {
@@ -501,6 +520,8 @@ function ensureDownstreamApiKeySchema() {
name text NOT NULL,
key text NOT NULL,
description text,
group_name text,
tags text,
enabled integer DEFAULT true,
expires_at text,
max_cost real,
@@ -532,6 +553,14 @@ function ensureDownstreamApiKeySchema() {
CREATE INDEX IF NOT EXISTS downstream_api_keys_expires_at_idx
ON downstream_api_keys(expires_at);
`);
if (!tableColumnExists('downstream_api_keys', 'group_name')) {
execSqliteLegacyCompat('ALTER TABLE downstream_api_keys ADD COLUMN group_name text;');
}
if (!tableColumnExists('downstream_api_keys', 'tags')) {
execSqliteLegacyCompat('ALTER TABLE downstream_api_keys ADD COLUMN tags text;');
}
}
function ensureProxyLogBillingDetailsSchema() {
@@ -546,6 +575,18 @@ function ensureProxyLogBillingDetailsSchema() {
proxyLogBillingDetailsColumnAvailable = true;
}
function ensureProxyLogDownstreamApiKeyIdSchema() {
if (!tableExists('proxy_logs')) {
return;
}
if (!tableColumnExists('proxy_logs', 'downstream_api_key_id')) {
execSqliteLegacyCompat('ALTER TABLE proxy_logs ADD COLUMN downstream_api_key_id integer;');
}
proxyLogDownstreamApiKeyIdColumnAvailable = true;
}
function normalizeSchemaErrorMessage(error: unknown): string {
if (typeof error === 'object' && error && 'message' in error) {
return String((error as { message?: unknown }).message || '');
@@ -624,8 +665,75 @@ export async function ensureProxyLogBillingDetailsColumn(): Promise<boolean> {
}
}
export async function hasProxyLogDownstreamApiKeyIdColumn(): Promise<boolean> {
if (proxyLogDownstreamApiKeyIdColumnAvailable !== null) {
return proxyLogDownstreamApiKeyIdColumnAvailable;
}
if (runtimeDbDialect === 'sqlite') {
proxyLogDownstreamApiKeyIdColumnAvailable = tableExists('proxy_logs')
&& tableColumnExists('proxy_logs', 'downstream_api_key_id');
return proxyLogDownstreamApiKeyIdColumnAvailable;
}
if (runtimeDbDialect === 'mysql') {
if (!mysqlPool) return false;
const [rows] = await mysqlPool.query('SHOW COLUMNS FROM `proxy_logs` LIKE ?', ['downstream_api_key_id']);
proxyLogDownstreamApiKeyIdColumnAvailable = Array.isArray(rows) && rows.length > 0;
return proxyLogDownstreamApiKeyIdColumnAvailable;
}
if (!pgPool) return false;
const result = await pgPool.query(
'SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2 LIMIT 1',
['proxy_logs', 'downstream_api_key_id'],
);
proxyLogDownstreamApiKeyIdColumnAvailable = Number(result.rowCount || 0) > 0;
return proxyLogDownstreamApiKeyIdColumnAvailable;
}
export async function ensureProxyLogDownstreamApiKeyIdColumn(): Promise<boolean> {
if (runtimeDbDialect === 'sqlite') {
ensureProxyLogDownstreamApiKeyIdSchema();
proxyLogDownstreamApiKeyIdColumnAvailable = tableExists('proxy_logs')
&& tableColumnExists('proxy_logs', 'downstream_api_key_id');
return proxyLogDownstreamApiKeyIdColumnAvailable;
}
if (await hasProxyLogDownstreamApiKeyIdColumn()) {
return true;
}
try {
if (runtimeDbDialect === 'mysql') {
if (!mysqlPool) return false;
await executeLegacyCompat(
(statement) => mysqlPool!.query(statement).then(() => undefined),
'ALTER TABLE `proxy_logs` ADD COLUMN `downstream_api_key_id` INT NULL',
);
} else {
if (!pgPool) return false;
await executeLegacyCompat(
(statement) => pgPool!.query(statement).then(() => undefined),
'ALTER TABLE "proxy_logs" ADD COLUMN "downstream_api_key_id" INTEGER',
);
}
proxyLogDownstreamApiKeyIdColumnAvailable = true;
return true;
} catch (error) {
if (isDuplicateColumnError(error)) {
proxyLogDownstreamApiKeyIdColumnAvailable = true;
return true;
}
proxyLogDownstreamApiKeyIdColumnAvailable = false;
console.warn('[db] failed to ensure proxy_logs.downstream_api_key_id column', error);
return false;
}
}
function resetSchemaCapabilityCache() {
proxyLogBillingDetailsColumnAvailable = null;
proxyLogDownstreamApiKeyIdColumnAvailable = null;
}
async function sqliteProxyQuery(sqlText: string, params: unknown[], method: SqlMethod) {
+9
View File
@@ -32,6 +32,7 @@ export interface LegacySchemaCompatInspector extends
const LEGACY_COMPAT_TABLES = new Set([
'account_tokens',
'token_model_availability',
'route_group_sources',
'proxy_video_tasks',
'proxy_files',
'downstream_api_keys',
@@ -46,8 +47,10 @@ const LEGACY_COMPAT_COLUMNS = new Set([
'sites.external_checkin_url',
'sites.global_weight',
'account_tokens.token_group',
'account_tokens.value_status',
'token_routes.display_name',
'token_routes.display_icon',
'token_routes.route_mode',
'token_routes.decision_snapshot',
'token_routes.decision_refreshed_at',
'token_routes.routing_strategy',
@@ -60,7 +63,10 @@ const LEGACY_COMPAT_COLUMNS = new Set([
'proxy_video_tasks.upstream_response_meta',
'proxy_video_tasks.last_upstream_status',
'proxy_video_tasks.last_polled_at',
'downstream_api_keys.group_name',
'downstream_api_keys.tags',
'proxy_logs.billing_details',
'proxy_logs.downstream_api_key_id',
]);
const LEGACY_COMPAT_INDEXES = new Set([
@@ -75,6 +81,9 @@ const LEGACY_COMPAT_INDEXES = new Set([
'downstream_api_keys_name_idx',
'downstream_api_keys_enabled_idx',
'downstream_api_keys_expires_at_idx',
'proxy_logs_downstream_api_key_created_at_idx',
'route_group_sources_group_source_unique',
'route_group_sources_source_route_id_idx',
...SHARED_INDEX_COMPATIBILITY_SPECS.map((spec) => spec.indexName),
]);
+142
View File
@@ -208,6 +208,64 @@ describe('sqlite migrate bootstrap', () => {
sqlite.close();
});
it('recovers duplicate-column errors when the failed SQL contains quoted literals', async () => {
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-recover-quoted-'));
process.env.DATA_DIR = dataDir;
vi.resetModules();
const migrateModule = await import('./migrate.js');
const { __migrateTestUtils } = migrateModule;
const sqlite = new Database(':memory:');
sqlite.exec(`
CREATE TABLE account_tokens (
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
value_status text DEFAULT 'ready' NOT NULL
);
`);
const tempMigrationsDir = mkdtempSync(join(tmpdir(), 'metapi-migration-files-quoted-'));
mkdirSync(join(tempMigrationsDir, 'meta'), { recursive: true });
writeFileSync(
join(tempMigrationsDir, 'meta', '_journal.json'),
JSON.stringify({
entries: [
{
tag: '0012_account_token_value_status',
when: 1773665311013,
},
],
}),
);
writeFileSync(
join(tempMigrationsDir, '0012_account_token_value_status.sql'),
"ALTER TABLE `account_tokens` ADD `value_status` text DEFAULT 'ready' NOT NULL;\n",
);
const duplicateColumnError = new Error(
"DrizzleError: Failed to run the query 'ALTER TABLE `account_tokens` ADD `value_status` text DEFAULT 'ready' NOT NULL;\n' duplicate column name: value_status",
);
const recovered = __migrateTestUtils.tryRecoverDuplicateColumnMigrationError(
sqlite,
tempMigrationsDir,
duplicateColumnError,
);
expect(recovered).toBe(true);
const applied = sqlite
.prepare('SELECT hash, created_at FROM __drizzle_migrations')
.all() as Array<{ hash: string; created_at: number }>;
expect(applied).toHaveLength(1);
expect(Number(applied[0].created_at)).toBe(1773665311013);
sqlite.close();
});
it('recovers duplicate-column errors inside multi-statement migrations by replaying the full migration', async () => {
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-recover-multi-'));
process.env.DATA_DIR = dataDir;
@@ -283,6 +341,10 @@ describe('sqlite migrate bootstrap', () => {
'0007_account_token_group',
'0008_sqlite_schema_backfill',
'0009_model_availability_is_manual',
'0010_proxy_logs_downstream_api_key',
'0011_downstream_api_key_metadata',
'0012_account_token_value_status',
'0013_oauth_multi_provider',
]);
const appliedEntries = journalEntries.filter((entry) => !missingTags.has(entry.tag));
@@ -322,4 +384,84 @@ describe('sqlite migrate bootstrap', () => {
verified.close();
});
it('deduplicates legacy duplicate sites before applying the oauth site unique index', async () => {
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-duplicate-sites-'));
const dbPath = join(dataDir, 'hub.db');
const sqlite = new Database(dbPath);
const journalEntries = readMigrationJournalEntries();
const appliedEntries = journalEntries.filter((entry) => entry.tag !== '0013_oauth_multi_provider');
for (const entry of appliedEntries) {
const sqlText = readFileSync(join(migrationsDir, `${entry.tag}.sql`), 'utf8');
applyMigrationSql(sqlite, sqlText);
}
recordAppliedMigrations(sqlite, appliedEntries);
sqlite.exec(`
INSERT INTO sites (id, name, url, platform, status, is_pinned, sort_order, global_weight)
VALUES
(101, 'Primary Codex', 'https://chatgpt.com/backend-api/codex', 'codex', 'active', 0, 0, 1),
(202, 'Duplicate Codex', 'https://chatgpt.com/backend-api/codex', 'codex', 'disabled', 1, 9, 3);
INSERT INTO accounts (site_id, username, access_token, status, checkin_enabled)
VALUES
(101, 'first@example.com', 'token-a', 'active', 0),
(202, 'second@example.com', 'token-b', 'disabled', 0);
INSERT INTO site_disabled_models (site_id, model_name)
VALUES
(101, 'gpt-5'),
(202, 'gpt-5'),
(202, 'gpt-5-mini');
`);
sqlite.close();
process.env.DATA_DIR = dataDir;
vi.resetModules();
await expect(import('./migrate.js')).resolves.toMatchObject({
runSqliteMigrations: expect.any(Function),
});
const verified = new Database(dbPath, { readonly: true });
const sites = verified
.prepare('SELECT id, name, url, platform, status, is_pinned, sort_order, global_weight FROM sites ORDER BY id ASC')
.all() as Array<{
id: number;
name: string;
url: string;
platform: string;
status: string;
is_pinned: number;
sort_order: number;
global_weight: number;
}>;
const accounts = verified
.prepare('SELECT username, site_id FROM accounts ORDER BY username ASC')
.all() as Array<{ username: string; site_id: number }>;
const disabledModels = verified
.prepare('SELECT site_id, model_name FROM site_disabled_models ORDER BY site_id ASC, model_name ASC')
.all() as Array<{ site_id: number; model_name: string }>;
expect(sites).toEqual([
expect.objectContaining({
id: 101,
name: 'Primary Codex',
url: 'https://chatgpt.com/backend-api/codex',
platform: 'codex',
}),
]);
expect(accounts).toEqual([
{ username: 'first@example.com', site_id: 101 },
{ username: 'second@example.com', site_id: 101 },
]);
expect(disabledModels).toEqual([
{ site_id: 101, model_name: 'gpt-5' },
{ site_id: 101, model_name: 'gpt-5-mini' },
]);
verified.close();
});
});
+211 -20
View File
@@ -34,7 +34,13 @@ type RecoveryMigration = RecoveryMigrationRecord & {
statements: string[];
};
const VERIFIED_BOOTSTRAP_TAG = '0009_model_availability_is_manual';
type LegacySiteRow = {
id: number;
platform: string;
url: string;
};
const VERIFIED_BOOTSTRAP_TAG = '0012_account_token_value_status';
const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [
{ table: 'sites' },
{ table: 'settings' },
@@ -57,6 +63,13 @@ const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [
{ table: 'account_tokens', column: 'token_group' },
// 0009: is_manual column on model_availability
{ table: 'model_availability', column: 'is_manual' },
// 0010: downstream_api_key_id column on proxy_logs
{ table: 'proxy_logs', column: 'downstream_api_key_id' },
// 0011: downstream key metadata columns
{ table: 'downstream_api_keys', column: 'group_name' },
{ table: 'downstream_api_keys', column: 'tags' },
// 0012: value_status column on account_tokens
{ table: 'account_tokens', column: 'value_status' },
];
@@ -198,6 +211,28 @@ function findMatchingMigrationByStatement(
return null;
}
function findMatchingMigrationByErrorMessage(
migrationsFolder: string,
error: unknown,
): RecoveryMigrationRecord | null {
const normalizedErrorMessage = normalizeSqlForMatch(normalizeSchemaErrorMessage(error));
const migrations = readRecoveryMigrations(migrationsFolder);
for (const migration of migrations) {
if (!migration.statements.some((statement) => normalizedErrorMessage.includes(normalizeSqlForMatch(statement)))) {
continue;
}
return {
tag: migration.tag,
createdAt: migration.createdAt,
hash: migration.hash,
};
}
return null;
}
function readRecoveryMigrations(migrationsFolder: string): RecoveryMigration[] {
const journalPath = resolve(migrationsFolder, 'meta', '_journal.json');
const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as MigrationJournalFile;
@@ -239,6 +274,14 @@ function markMigrationRecordIfMissing(sqlite: Database.Database, record: Migrati
return true;
}
function hasMigrationRecord(sqlite: Database.Database, record: MigrationRecord): boolean {
if (!tableExists(sqlite, '__drizzle_migrations')) return false;
const row = sqlite
.prepare('SELECT 1 FROM "__drizzle_migrations" WHERE "hash" = ? LIMIT 1')
.get(record.hash);
return !!row;
}
function normalizeSchemaErrorMessage(error: unknown): string {
if (!error || typeof error !== 'object') {
return String(error || '');
@@ -282,15 +325,19 @@ function isRecoverableSchemaConflictError(error: unknown): boolean {
|| lowered.includes('already exists');
}
function getLatestRecordedMigrationCreatedAt(sqlite: Database.Database): number | null {
if (!tableExists(sqlite, '__drizzle_migrations')) return null;
const row = sqlite
.prepare('SELECT created_at FROM "__drizzle_migrations" ORDER BY created_at DESC LIMIT 1')
.get() as { created_at?: number } | undefined;
if (!row || row.created_at === undefined || row.created_at === null) {
return null;
function isSitesPlatformUrlUniqueConflictError(error: unknown): boolean {
const lowered = normalizeSchemaErrorMessage(error).toLowerCase();
if (!lowered.includes('unique constraint failed: sites.platform, sites.url')) {
return false;
}
return Number(row.created_at);
const failedSqlText = extractFailedSqlFromError(error);
if (!failedSqlText) {
return true;
}
return normalizeSqlForMatch(failedSqlText)
=== normalizeSqlForMatch('CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`,`url`);');
}
function replayMigrationStatements(sqlite: Database.Database, statements: string[]): void {
@@ -298,9 +345,23 @@ function replayMigrationStatements(sqlite: Database.Database, statements: string
try {
sqlite.exec(statement);
} catch (error) {
if (!isRecoverableSchemaConflictError(error)) {
throw error;
if (isRecoverableSchemaConflictError(error)) {
continue;
}
if (isSitesPlatformUrlUniqueConflictError(error) && deduplicateLegacySitesForUniqueIndex(sqlite)) {
try {
sqlite.exec(statement);
continue;
} catch (retryError) {
if (isRecoverableSchemaConflictError(retryError)) {
continue;
}
throw retryError;
}
}
throw error;
}
}
}
@@ -316,20 +377,40 @@ function recoverMigrationSequence(
return false;
}
let latestRecordedCreatedAt = getLatestRecordedMigrationCreatedAt(sqlite);
for (const migration of migrations.slice(0, failedMigrationIndex + 1)) {
if (latestRecordedCreatedAt !== null && latestRecordedCreatedAt >= migration.createdAt) {
if (hasMigrationRecord(sqlite, migration)) {
continue;
}
replayMigrationStatements(sqlite, migration.statements);
markMigrationRecordIfMissing(sqlite, migration);
latestRecordedCreatedAt = migration.createdAt;
}
return true;
}
function backfillMissingRecordedMigrations(sqlite: Database.Database, migrationsFolder: string): number {
if (!tableExists(sqlite, '__drizzle_migrations')) return 0;
let recoveredCount = 0;
for (const migration of readRecoveryMigrations(migrationsFolder)) {
if (hasMigrationRecord(sqlite, migration)) {
continue;
}
replayMigrationStatements(sqlite, migration.statements);
if (markMigrationRecordIfMissing(sqlite, migration)) {
recoveredCount += 1;
}
}
if (recoveredCount > 0) {
console.warn(`[db] Backfilled ${recoveredCount} missing drizzle migration record(s).`);
}
return recoveredCount;
}
function tryRecoverDuplicateColumnMigrationError(
sqlite: Database.Database,
migrationsFolder: string,
@@ -340,11 +421,10 @@ function tryRecoverDuplicateColumnMigrationError(
}
const failedSqlText = extractFailedSqlFromError(error);
if (!failedSqlText) {
return false;
}
const matchedMigration = findMatchingMigrationByStatement(migrationsFolder, failedSqlText);
const matchedMigration = failedSqlText
? findMatchingMigrationByStatement(migrationsFolder, failedSqlText)
?? findMatchingMigrationByErrorMessage(migrationsFolder, error)
: findMatchingMigrationByErrorMessage(migrationsFolder, error);
if (!matchedMigration) {
return false;
}
@@ -356,16 +436,120 @@ function tryRecoverDuplicateColumnMigrationError(
return recovered;
}
function rewriteDownstreamSiteWeightMultipliers(
sqlite: Database.Database,
siteIdMapping: Map<number, number>,
): void {
if (siteIdMapping.size <= 0) return;
if (!tableExists(sqlite, 'downstream_api_keys')) return;
if (!columnExists(sqlite, 'downstream_api_keys', 'site_weight_multipliers')) return;
const rows = sqlite.prepare(`
SELECT id, site_weight_multipliers
FROM downstream_api_keys
WHERE site_weight_multipliers IS NOT NULL
AND TRIM(site_weight_multipliers) <> ''
`).all() as Array<{ id: number; site_weight_multipliers: string | null }>;
const update = sqlite.prepare('UPDATE downstream_api_keys SET site_weight_multipliers = ? WHERE id = ?');
for (const row of rows) {
if (!row.site_weight_multipliers) continue;
let parsed: unknown;
try {
parsed = JSON.parse(row.site_weight_multipliers);
} catch {
continue;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue;
const nextValue = { ...(parsed as Record<string, unknown>) };
let changed = false;
for (const [fromSiteId, toSiteId] of siteIdMapping.entries()) {
const fromKey = String(fromSiteId);
const toKey = String(toSiteId);
if (!(fromKey in nextValue)) continue;
if (!(toKey in nextValue)) {
nextValue[toKey] = nextValue[fromKey];
}
delete nextValue[fromKey];
changed = true;
}
if (!changed) continue;
update.run(JSON.stringify(nextValue), row.id);
}
}
function deduplicateLegacySitesForUniqueIndex(sqlite: Database.Database): boolean {
const duplicateGroups = sqlite.prepare(`
SELECT platform, url
FROM sites
GROUP BY platform, url
HAVING COUNT(*) > 1
`).all() as Array<{ platform: string; url: string }>;
if (duplicateGroups.length <= 0) {
return false;
}
const selectSitesByIdentity = sqlite.prepare(`
SELECT id, platform, url
FROM sites
WHERE platform = ? AND url = ?
ORDER BY id ASC
`);
const rebindAccounts = sqlite.prepare('UPDATE accounts SET site_id = ? WHERE site_id = ?');
const mergeDisabledModels = sqlite.prepare(`
INSERT OR IGNORE INTO site_disabled_models (site_id, model_name, created_at)
SELECT ?, model_name, created_at
FROM site_disabled_models
WHERE site_id = ?
`);
const deleteDisabledModels = sqlite.prepare('DELETE FROM site_disabled_models WHERE site_id = ?');
const deleteSite = sqlite.prepare('DELETE FROM sites WHERE id = ?');
const siteIdMapping = new Map<number, number>();
const transaction = sqlite.transaction(() => {
for (const group of duplicateGroups) {
const sites = selectSitesByIdentity.all(group.platform, group.url) as LegacySiteRow[];
if (sites.length <= 1) continue;
const canonicalSiteId = sites[0]!.id;
for (const site of sites.slice(1)) {
mergeDisabledModels.run(canonicalSiteId, site.id);
deleteDisabledModels.run(site.id);
rebindAccounts.run(canonicalSiteId, site.id);
siteIdMapping.set(site.id, canonicalSiteId);
deleteSite.run(site.id);
}
}
rewriteDownstreamSiteWeightMultipliers(sqlite, siteIdMapping);
});
transaction();
if (siteIdMapping.size > 0) {
console.warn(`[db] Deduplicated ${siteIdMapping.size} legacy site entries before applying sites_platform_url_unique.`);
}
return siteIdMapping.size > 0;
}
export const __migrateTestUtils = {
splitMigrationStatements,
normalizeSqlForMatch,
extractFailedSqlFromError,
findMatchingSingleStatementMigration,
findMatchingMigrationByStatement,
findMatchingMigrationByErrorMessage,
readRecoveryMigrations,
markMigrationRecordIfMissing,
recoverMigrationSequence,
tryRecoverDuplicateColumnMigrationError,
isSitesPlatformUrlUniqueConflictError,
deduplicateLegacySitesForUniqueIndex,
};
function bootstrapLegacyDrizzleMigrations(sqlite: Database.Database, migrationsFolder: string): boolean {
@@ -404,11 +588,18 @@ export function runSqliteMigrations(): void {
const sqlite = new Database(dbPath);
bootstrapLegacyDrizzleMigrations(sqlite, migrationsFolder);
backfillMissingRecordedMigrations(sqlite, migrationsFolder);
try {
migrate(drizzle(sqlite), { migrationsFolder });
} catch (error) {
if (!tryRecoverDuplicateColumnMigrationError(sqlite, migrationsFolder, error)) {
const recoveredDuplicateColumns = tryRecoverDuplicateColumnMigrationError(sqlite, migrationsFolder, error);
const recoveredDuplicateSites = (
!recoveredDuplicateColumns
&& isSitesPlatformUrlUniqueConflictError(error)
&& deduplicateLegacySitesForUniqueIndex(sqlite)
);
if (!recoveredDuplicateColumns && !recoveredDuplicateSites) {
sqlite.close();
throw error;
}
@@ -8,9 +8,11 @@ function createInspector(
dialect: RouteGroupingSchemaInspector['dialect'],
options?: {
existingColumnsByTable?: Partial<Record<'token_routes' | 'route_channels', string[]>>;
existingTables?: Array<'token_routes' | 'route_channels' | 'route_group_sources'>;
},
) {
const executedSql: string[] = [];
const existingTables = new Set(options?.existingTables ?? ['token_routes', 'route_channels']);
const existingColumnsByTable = {
token_routes: new Set(options?.existingColumnsByTable?.token_routes ?? []),
route_channels: new Set(options?.existingColumnsByTable?.route_channels ?? []),
@@ -19,7 +21,7 @@ function createInspector(
const inspector: RouteGroupingSchemaInspector = {
dialect,
async tableExists(table) {
return table === 'token_routes' || table === 'route_channels';
return existingTables.has(table as 'token_routes' | 'route_channels' | 'route_group_sources');
},
async columnExists(table, column) {
if (table === 'token_routes' || table === 'route_channels') {
@@ -40,8 +42,12 @@ describe('ensureRouteGroupingSchemaCompatibility', () => {
{
dialect: 'sqlite' as const,
expectedSql: [
'CREATE TABLE IF NOT EXISTS route_group_sources (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, group_route_id integer NOT NULL REFERENCES token_routes(id) ON DELETE cascade, source_route_id integer NOT NULL REFERENCES token_routes(id) ON DELETE cascade);',
'CREATE UNIQUE INDEX IF NOT EXISTS route_group_sources_group_source_unique ON route_group_sources(group_route_id, source_route_id);',
'CREATE INDEX IF NOT EXISTS route_group_sources_source_route_id_idx ON route_group_sources(source_route_id);',
'ALTER TABLE token_routes ADD COLUMN display_name text;',
'ALTER TABLE token_routes ADD COLUMN display_icon text;',
'ALTER TABLE token_routes ADD COLUMN route_mode text DEFAULT \'pattern\';',
'ALTER TABLE token_routes ADD COLUMN decision_snapshot text;',
'ALTER TABLE token_routes ADD COLUMN decision_refreshed_at text;',
'ALTER TABLE token_routes ADD COLUMN routing_strategy text DEFAULT \'weighted\';',
@@ -54,8 +60,12 @@ describe('ensureRouteGroupingSchemaCompatibility', () => {
{
dialect: 'postgres' as const,
expectedSql: [
'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, CONSTRAINT "route_group_sources_group_route_id_token_routes_id_fk" FOREIGN KEY ("group_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, CONSTRAINT "route_group_sources_source_route_id_token_routes_id_fk" FOREIGN KEY ("source_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE)',
'CREATE UNIQUE INDEX IF NOT EXISTS "route_group_sources_group_source_unique" ON "route_group_sources" ("group_route_id", "source_route_id")',
'CREATE INDEX IF NOT EXISTS "route_group_sources_source_route_id_idx" ON "route_group_sources" ("source_route_id")',
'ALTER TABLE "token_routes" ADD COLUMN "display_name" TEXT',
'ALTER TABLE "token_routes" ADD COLUMN "display_icon" TEXT',
'ALTER TABLE "token_routes" ADD COLUMN "route_mode" TEXT DEFAULT \'pattern\'',
'ALTER TABLE "token_routes" ADD COLUMN "decision_snapshot" TEXT',
'ALTER TABLE "token_routes" ADD COLUMN "decision_refreshed_at" TEXT',
'ALTER TABLE "token_routes" ADD COLUMN "routing_strategy" TEXT DEFAULT \'weighted\'',
@@ -68,8 +78,12 @@ describe('ensureRouteGroupingSchemaCompatibility', () => {
{
dialect: 'mysql' as const,
expectedSql: [
'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, CONSTRAINT `route_group_sources_group_route_id_token_routes_id_fk` FOREIGN KEY (`group_route_id`) REFERENCES `token_routes`(`id`) ON DELETE cascade, CONSTRAINT `route_group_sources_source_route_id_token_routes_id_fk` FOREIGN KEY (`source_route_id`) REFERENCES `token_routes`(`id`) ON DELETE cascade)',
'CREATE UNIQUE INDEX IF NOT EXISTS `route_group_sources_group_source_unique` ON `route_group_sources` (`group_route_id`,`source_route_id`)',
'CREATE INDEX IF NOT EXISTS `route_group_sources_source_route_id_idx` ON `route_group_sources` (`source_route_id`)',
'ALTER TABLE `token_routes` ADD COLUMN `display_name` TEXT NULL',
'ALTER TABLE `token_routes` ADD COLUMN `display_icon` TEXT NULL',
'ALTER TABLE `token_routes` ADD COLUMN `route_mode` VARCHAR(32) NULL DEFAULT \'pattern\'',
'ALTER TABLE `token_routes` ADD COLUMN `decision_snapshot` TEXT NULL',
'ALTER TABLE `token_routes` ADD COLUMN `decision_refreshed_at` TEXT NULL',
'ALTER TABLE `token_routes` ADD COLUMN `routing_strategy` VARCHAR(32) NULL DEFAULT \'weighted\'',
@@ -90,9 +104,10 @@ describe('ensureRouteGroupingSchemaCompatibility', () => {
it('skips existing columns', async () => {
const { inspector, executedSql } = createInspector('postgres', {
existingColumnsByTable: {
token_routes: ['display_name', 'display_icon', 'decision_snapshot', 'decision_refreshed_at', 'routing_strategy'],
token_routes: ['display_name', 'display_icon', 'route_mode', 'decision_snapshot', 'decision_refreshed_at', 'routing_strategy'],
route_channels: ['source_model', 'last_selected_at', 'consecutive_fail_count', 'cooldown_level'],
},
existingTables: ['token_routes', 'route_channels', 'route_group_sources'],
});
await ensureRouteGroupingSchemaCompatibility(inspector);
@@ -13,6 +13,11 @@ type RouteGroupingColumnCompatibilitySpec = {
addSql: Record<RouteGroupingSchemaDialect, string>;
};
type RouteGroupingTableCompatibilitySpec = {
table: 'route_group_sources';
createSql: Record<RouteGroupingSchemaDialect, string[]>;
};
const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilitySpec[] = [
{
table: 'token_routes',
@@ -32,6 +37,15 @@ const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilit
postgres: 'ALTER TABLE "token_routes" ADD COLUMN "display_icon" TEXT',
},
},
{
table: 'token_routes',
column: 'route_mode',
addSql: {
sqlite: 'ALTER TABLE token_routes ADD COLUMN route_mode text DEFAULT \'pattern\';',
mysql: 'ALTER TABLE `token_routes` ADD COLUMN `route_mode` VARCHAR(32) NULL DEFAULT \'pattern\'',
postgres: 'ALTER TABLE "token_routes" ADD COLUMN "route_mode" TEXT DEFAULT \'pattern\'',
},
},
{
table: 'token_routes',
column: 'decision_snapshot',
@@ -97,6 +111,29 @@ const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilit
},
];
const ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS: RouteGroupingTableCompatibilitySpec[] = [
{
table: 'route_group_sources',
createSql: {
sqlite: [
'CREATE TABLE IF NOT EXISTS route_group_sources (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, group_route_id integer NOT NULL REFERENCES token_routes(id) ON DELETE cascade, source_route_id integer NOT NULL REFERENCES token_routes(id) ON DELETE cascade);',
'CREATE UNIQUE INDEX IF NOT EXISTS route_group_sources_group_source_unique ON route_group_sources(group_route_id, source_route_id);',
'CREATE INDEX IF NOT EXISTS route_group_sources_source_route_id_idx ON route_group_sources(source_route_id);',
],
mysql: [
'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, CONSTRAINT `route_group_sources_group_route_id_token_routes_id_fk` FOREIGN KEY (`group_route_id`) REFERENCES `token_routes`(`id`) ON DELETE cascade, CONSTRAINT `route_group_sources_source_route_id_token_routes_id_fk` FOREIGN KEY (`source_route_id`) REFERENCES `token_routes`(`id`) ON DELETE cascade)',
'CREATE UNIQUE INDEX IF NOT EXISTS `route_group_sources_group_source_unique` ON `route_group_sources` (`group_route_id`,`source_route_id`)',
'CREATE INDEX IF NOT EXISTS `route_group_sources_source_route_id_idx` ON `route_group_sources` (`source_route_id`)',
],
postgres: [
'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, CONSTRAINT "route_group_sources_group_route_id_token_routes_id_fk" FOREIGN KEY ("group_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, CONSTRAINT "route_group_sources_source_route_id_token_routes_id_fk" FOREIGN KEY ("source_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE)',
'CREATE UNIQUE INDEX IF NOT EXISTS "route_group_sources_group_source_unique" ON "route_group_sources" ("group_route_id", "source_route_id")',
'CREATE INDEX IF NOT EXISTS "route_group_sources_source_route_id_idx" ON "route_group_sources" ("source_route_id")',
],
},
},
];
function normalizeSchemaErrorMessage(error: unknown): string {
if (typeof error === 'object' && error && 'message' in error) {
return String((error as { message?: unknown }).message || '');
@@ -124,6 +161,18 @@ async function executeAddColumn(inspector: RouteGroupingSchemaInspector, sqlText
export async function ensureRouteGroupingSchemaCompatibility(inspector: RouteGroupingSchemaInspector): Promise<void> {
const tableExistsCache = new Map<string, boolean>();
for (const spec of ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS) {
const hasTable = await inspector.tableExists(spec.table);
tableExistsCache.set(spec.table, hasTable);
if (hasTable) {
continue;
}
for (const sqlText of spec.createSql[inspector.dialect]) {
await executeAddColumn(inspector, sqlText);
}
tableExistsCache.set(spec.table, true);
}
for (const spec of ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS) {
let hasTable = tableExistsCache.get(spec.table);
if (hasTable === undefined) {
@@ -2,6 +2,7 @@ import baselineContract from './generated/fixtures/2026-03-14-baseline.schemaCon
import currentContract from './generated/schemaContract.json' with { type: 'json' };
import { classifyLegacyCompatMutation } from './legacySchemaCompat.js';
import { generateUpgradeSql } from './schemaArtifactGenerator.js';
import type { SchemaContract, SchemaContractColumn } from './schemaContract.js';
import { describe, expect, it } from 'vitest';
import {
__runtimeSchemaBootstrapTestUtils,
@@ -17,6 +18,9 @@ function createStubClient(dialect: RuntimeSchemaDialect, executedSql: string[]):
commit: async () => {},
rollback: async () => {},
execute: async (sqlText: string) => {
if (sqlText.trim().toLowerCase().startsWith('select')) {
return [];
}
executedSql.push(sqlText);
return [];
},
@@ -33,6 +37,16 @@ function createStubClient(dialect: RuntimeSchemaDialect, executedSql: string[]):
};
}
function makeColumn(overrides: Partial<SchemaContractColumn> = {}): SchemaContractColumn {
return {
logicalType: 'text',
notNull: false,
defaultValue: null,
primaryKey: false,
...overrides,
};
}
describe('runtime schema bootstrap', () => {
it.each(['mysql', 'postgres'] as const)('executes live-schema upgrade statements for %s', async (dialect) => {
const executedSql: string[] = [];
@@ -149,4 +163,142 @@ describe('runtime schema bootstrap', () => {
expect(executedSql).toContain(targetSql);
});
it('adds mysql text prefixes for new indexes when live datetime-like columns are still stored as text', async () => {
const executedSql: string[] = [];
const minimalContract: SchemaContract = {
tables: {
proxy_logs: {
columns: {
downstream_api_key_id: makeColumn({ logicalType: 'integer' }),
created_at: makeColumn({ logicalType: 'datetime', defaultValue: "datetime('now')" }),
},
},
},
indexes: [
{
name: 'proxy_logs_downstream_api_key_created_at_idx',
table: 'proxy_logs',
columns: ['downstream_api_key_id', 'created_at'],
unique: false,
},
],
uniques: [],
foreignKeys: [],
};
await ensureRuntimeDatabaseSchema({
...createStubClient('mysql', executedSql),
execute: async (sqlText: string) => {
if (sqlText.includes('FROM information_schema.columns')) {
return [[
{
table_name: 'proxy_logs',
column_name: 'downstream_api_key_id',
data_type: 'int',
column_type: 'int',
},
{
table_name: 'proxy_logs',
column_name: 'created_at',
data_type: 'text',
column_type: 'text',
},
]];
}
executedSql.push(sqlText);
return [];
},
queryScalar: async (sqlText: string, params: unknown[] = []) => {
if (sqlText.includes('information_schema.tables')) {
return params[0] === 'proxy_logs' ? 1 : 0;
}
if (sqlText.includes('information_schema.columns')) {
return 1;
}
return 0;
},
}, {
currentContract: minimalContract,
liveContract: {
...minimalContract,
indexes: [],
},
});
expect(executedSql).toContain(
'CREATE INDEX `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`(191))',
);
});
it('does not add mysql text prefixes when live indexed text columns are varchar-backed', async () => {
const executedSql: string[] = [];
const minimalContract: SchemaContract = {
tables: {
sites: {
columns: {
platform: makeColumn({ logicalType: 'text', notNull: true }),
url: makeColumn({ logicalType: 'text', notNull: true }),
},
},
},
indexes: [],
uniques: [
{
name: 'sites_platform_url_unique',
table: 'sites',
columns: ['platform', 'url'],
},
],
foreignKeys: [],
};
await ensureRuntimeDatabaseSchema({
...createStubClient('mysql', executedSql),
execute: async (sqlText: string) => {
if (sqlText.includes('FROM information_schema.columns')) {
return [[
{
table_name: 'sites',
column_name: 'platform',
data_type: 'varchar',
column_type: 'varchar(32)',
},
{
table_name: 'sites',
column_name: 'url',
data_type: 'varchar',
column_type: 'varchar(255)',
},
]];
}
executedSql.push(sqlText);
return [];
},
queryScalar: async (sqlText: string, params: unknown[] = []) => {
if (sqlText.includes('information_schema.tables')) {
return params[0] === 'sites' ? 1 : 0;
}
if (sqlText.includes('information_schema.columns')) {
return 1;
}
return 0;
},
}, {
currentContract: minimalContract,
liveContract: {
...minimalContract,
uniques: [],
},
});
expect(executedSql).toContain(
'CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`, `url`)',
);
expect(executedSql).not.toContain(
'CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`(191), `url`(191))',
);
});
});
+105 -6
View File
@@ -7,7 +7,11 @@ import {
ensureLegacySchemaCompatibility,
type LegacySchemaCompatInspector,
} from './legacySchemaCompat.js';
import { generateBootstrapSql, generateUpgradeSql } from './schemaArtifactGenerator.js';
import {
generateBootstrapSql,
generateUpgradeSql,
type MysqlIndexPrefixRequirementMap,
} from './schemaArtifactGenerator.js';
import { introspectLiveSchema } from './schemaIntrospection.js';
import { resolveGeneratedSchemaContractPath, type SchemaContract } from './schemaContract.js';
@@ -233,6 +237,88 @@ function buildCompatibleRuntimeBaseline(
return baseline;
}
function collectIndexedColumns(contract: SchemaContract): Map<string, Set<string>> {
const indexedColumns = new Map<string, Set<string>>();
for (const index of [...contract.indexes, ...contract.uniques]) {
let columns = indexedColumns.get(index.table);
if (!columns) {
columns = new Set<string>();
indexedColumns.set(index.table, columns);
}
for (const columnName of index.columns) {
columns.add(columnName);
}
}
return indexedColumns;
}
function requiresMysqlIndexPrefixForColumnType(columnType: string): boolean {
const normalizedType = columnType.trim().toLowerCase();
return normalizedType.includes('text') || normalizedType.includes('blob');
}
async function queryRuntimeRows(
client: RuntimeSchemaClient,
sqlText: string,
params: unknown[] = [],
): Promise<Array<Record<string, unknown>>> {
const result = await client.execute(sqlText, params);
if (!Array.isArray(result)) {
return [];
}
const [first] = result;
if (Array.isArray(first)) {
return first as Array<Record<string, unknown>>;
}
if (result.every((item) => typeof item === 'object' && item !== null && !Array.isArray(item))) {
return result as Array<Record<string, unknown>>;
}
return [];
}
async function resolveMySqlIndexPrefixRequirements(
client: RuntimeSchemaClient,
currentContract: SchemaContract,
): Promise<MysqlIndexPrefixRequirementMap> {
const indexedColumns = collectIndexedColumns(currentContract);
if (indexedColumns.size === 0) {
return {};
}
const rows = await queryRuntimeRows(client, `
SELECT
table_name AS table_name,
column_name AS column_name,
data_type AS data_type,
column_type AS column_type
FROM information_schema.columns
WHERE table_schema = DATABASE()
`);
const requirements: MysqlIndexPrefixRequirementMap = {};
for (const row of rows) {
const tableName = String(row.table_name || '');
const columnName = String(row.column_name || '');
const trackedColumns = indexedColumns.get(tableName);
if (!trackedColumns || !trackedColumns.has(columnName)) {
continue;
}
const declaredType = String(row.column_type || row.data_type || '');
requirements[tableName] ??= {};
requirements[tableName][columnName] = requiresMysqlIndexPrefixForColumnType(declaredType);
}
return requirements;
}
async function createPostgresClient(connectionString: string, ssl: boolean): Promise<RuntimeSchemaClient> {
const clientOptions: pg.ClientConfig = { connectionString };
if (ssl) {
@@ -346,9 +432,12 @@ function buildExternalUpgradeStatements(
dialect: Exclude<RuntimeSchemaDialect, 'sqlite'>,
currentContract: SchemaContract,
liveContract: SchemaContract,
mysqlIndexPrefixRequirements?: MysqlIndexPrefixRequirementMap,
): string[] {
const compatibleBaseline = buildCompatibleRuntimeBaseline(currentContract, liveContract);
return splitSqlStatements(generateUpgradeSql(dialect, currentContract, compatibleBaseline));
return splitSqlStatements(generateUpgradeSql(dialect, currentContract, compatibleBaseline, {
mysqlIndexPrefixRequirements,
}));
}
export async function ensureRuntimeDatabaseSchema(
@@ -356,13 +445,23 @@ export async function ensureRuntimeDatabaseSchema(
options: EnsureRuntimeDatabaseSchemaOptions = {},
): Promise<void> {
const currentContract = options.currentContract ?? readSchemaContract();
const statements = client.dialect === 'sqlite'
? splitSqlStatements(generateBootstrapSql('sqlite', currentContract))
: buildExternalUpgradeStatements(
let statements: string[];
if (client.dialect === 'sqlite') {
statements = splitSqlStatements(generateBootstrapSql('sqlite', currentContract));
} else {
const liveContract = await resolveLiveContract(client, options.liveContract);
const mysqlIndexPrefixRequirements = client.dialect === 'mysql'
? await resolveMySqlIndexPrefixRequirements(client, currentContract)
: undefined;
statements = buildExternalUpgradeStatements(
client.dialect,
currentContract,
await resolveLiveContract(client, options.liveContract),
liveContract,
mysqlIndexPrefixRequirements,
);
}
for (const sqlText of statements) {
await executeBootstrapStatement(client, sqlText);
+22 -1
View File
@@ -6,7 +6,7 @@ export const sites = sqliteTable('sites', {
name: text('name').notNull(),
url: text('url').notNull(),
externalCheckinUrl: text('external_checkin_url'),
platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api' | 'openai' | 'claude' | 'gemini'
platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api' | 'openai' | 'claude' | 'gemini' | 'codex' | 'gemini-cli' | 'antigravity'
proxyUrl: text('proxy_url'),
useSystemProxy: integer('use_system_proxy', { mode: 'boolean' }).default(false),
customHeaders: text('custom_headers'),
@@ -19,6 +19,7 @@ export const sites = sqliteTable('sites', {
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
}, (table) => ({
statusIdx: index('sites_status_idx').on(table.status),
platformUrlUnique: uniqueIndex('sites_platform_url_unique').on(table.platform, table.url),
}));
export const siteDisabledModels = sqliteTable('site_disabled_models', {
@@ -48,6 +49,9 @@ export const accounts = sqliteTable('accounts', {
checkinEnabled: integer('checkin_enabled', { mode: 'boolean' }).default(true),
lastCheckinAt: text('last_checkin_at'),
lastBalanceRefresh: text('last_balance_refresh'),
oauthProvider: text('oauth_provider'),
oauthAccountKey: text('oauth_account_key'),
oauthProjectId: text('oauth_project_id'),
extraConfig: text('extra_config'), // JSON string
createdAt: text('created_at').default(sql`(datetime('now'))`),
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
@@ -55,6 +59,8 @@ export const accounts = sqliteTable('accounts', {
siteIdIdx: index('accounts_site_id_idx').on(table.siteId),
statusIdx: index('accounts_status_idx').on(table.status),
siteStatusIdx: index('accounts_site_status_idx').on(table.siteId, table.status),
oauthProviderIdx: index('accounts_oauth_provider_idx').on(table.oauthProvider),
oauthIdentityIdx: index('accounts_oauth_identity_idx').on(table.oauthProvider, table.oauthAccountKey, table.oauthProjectId),
}));
export const accountTokens = sqliteTable('account_tokens', {
@@ -63,6 +69,7 @@ export const accountTokens = sqliteTable('account_tokens', {
name: text('name').notNull(),
token: text('token').notNull(),
tokenGroup: text('token_group'),
valueStatus: text('value_status').notNull().default('ready'),
source: text('source').default('manual'), // 'manual' | 'sync' | 'legacy'
enabled: integer('enabled', { mode: 'boolean' }).default(true),
isDefault: integer('is_default', { mode: 'boolean' }).default(false),
@@ -120,6 +127,7 @@ export const tokenRoutes = sqliteTable('token_routes', {
modelPattern: text('model_pattern').notNull(),
displayName: text('display_name'),
displayIcon: text('display_icon'),
routeMode: text('route_mode').default('pattern'),
modelMapping: text('model_mapping'), // JSON
decisionSnapshot: text('decision_snapshot'), // JSON
decisionRefreshedAt: text('decision_refreshed_at'),
@@ -132,6 +140,15 @@ export const tokenRoutes = sqliteTable('token_routes', {
enabledIdx: index('token_routes_enabled_idx').on(table.enabled),
}));
export const routeGroupSources = sqliteTable('route_group_sources', {
id: integer('id').primaryKey({ autoIncrement: true }),
groupRouteId: integer('group_route_id').notNull().references(() => tokenRoutes.id, { onDelete: 'cascade' }),
sourceRouteId: integer('source_route_id').notNull().references(() => tokenRoutes.id, { onDelete: 'cascade' }),
}, (table) => ({
groupSourceUnique: uniqueIndex('route_group_sources_group_source_unique').on(table.groupRouteId, table.sourceRouteId),
sourceRouteIdx: index('route_group_sources_source_route_id_idx').on(table.sourceRouteId),
}));
export const routeChannels = sqliteTable('route_channels', {
id: integer('id').primaryKey({ autoIncrement: true }),
routeId: integer('route_id').notNull().references(() => tokenRoutes.id, { onDelete: 'cascade' }),
@@ -165,6 +182,7 @@ export const proxyLogs = sqliteTable('proxy_logs', {
routeId: integer('route_id'),
channelId: integer('channel_id'),
accountId: integer('account_id'),
downstreamApiKeyId: integer('downstream_api_key_id'),
modelRequested: text('model_requested'),
modelActual: text('model_actual'),
status: text('status'), // 'success' | 'failed' | 'retried'
@@ -183,6 +201,7 @@ export const proxyLogs = sqliteTable('proxy_logs', {
accountCreatedIdx: index('proxy_logs_account_created_at_idx').on(table.accountId, table.createdAt),
statusCreatedIdx: index('proxy_logs_status_created_at_idx').on(table.status, table.createdAt),
modelActualCreatedIdx: index('proxy_logs_model_actual_created_at_idx').on(table.modelActual, table.createdAt),
downstreamKeyCreatedIdx: index('proxy_logs_downstream_api_key_created_at_idx').on(table.downstreamApiKeyId, table.createdAt),
}));
export const proxyVideoTasks = sqliteTable('proxy_video_tasks', {
@@ -236,6 +255,8 @@ export const downstreamApiKeys = sqliteTable('downstream_api_keys', {
name: text('name').notNull(),
key: text('key').notNull(),
description: text('description'),
groupName: text('group_name'),
tags: text('tags'), // JSON array<string>
enabled: integer('enabled', { mode: 'boolean' }).default(true),
expiresAt: text('expires_at'),
maxCost: real('max_cost'),
+40 -1
View File
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { generateDialectArtifacts, type GeneratedDialectArtifacts } from './schemaArtifactGenerator.js';
import { generateDialectArtifacts, generateUpgradeSql, type GeneratedDialectArtifacts } from './schemaArtifactGenerator.js';
import type { SchemaContract, SchemaContractColumn } from './schemaContract.js';
const dbDir = dirname(fileURLToPath(import.meta.url));
@@ -123,6 +123,45 @@ describe('schema artifact generator', () => {
expect(artifacts.mysqlBootstrap).not.toContain('`read`(191)');
});
it('allows mysql upgrade generation to force prefix lengths for live text-backed columns', () => {
const previousContract: SchemaContract = {
tables: {
proxy_logs: {
columns: {
downstream_api_key_id: makeColumn({ logicalType: 'integer' }),
created_at: makeColumn({ logicalType: 'datetime', defaultValue: "datetime('now')" }),
},
},
},
indexes: [],
uniques: [],
foreignKeys: [],
};
const currentContract: SchemaContract = {
...previousContract,
indexes: [
{
name: 'proxy_logs_downstream_api_key_created_at_idx',
table: 'proxy_logs',
columns: ['downstream_api_key_id', 'created_at'],
unique: false,
},
],
};
const mysqlUpgrade = generateUpgradeSql('mysql', currentContract, previousContract, {
mysqlIndexPrefixRequirements: {
proxy_logs: {
created_at: true,
},
},
});
expect(mysqlUpgrade).toContain(
'CREATE INDEX `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`(191))',
);
});
it('rejects destructive diffs when generating additive upgrades', () => {
const current = readSchemaContract();
const previous = structuredClone(current);
+41 -8
View File
@@ -17,8 +17,13 @@ export interface GeneratedDialectArtifacts {
postgresUpgrade: string;
}
export type MysqlIndexPrefixRequirementMap = Record<string, Record<string, boolean>>;
type Dialect = 'mysql' | 'postgres';
type SqlDialect = 'sqlite' | Dialect;
type SqlGenerationOptions = {
mysqlIndexPrefixRequirements?: MysqlIndexPrefixRequirementMap;
};
function resolveDbDir(): string {
return dirname(fileURLToPath(import.meta.url));
@@ -36,6 +41,21 @@ function escapeMysqlTextPrefix(columnType: LogicalColumnType): string {
return columnType === 'text' ? '(191)' : '';
}
function resolveMysqlIndexPrefix(
tableName: string,
columnName: string,
contract: SchemaContract,
options?: SqlGenerationOptions,
): string {
const override = options?.mysqlIndexPrefixRequirements?.[tableName]?.[columnName];
if (override !== undefined) {
return override ? '(191)' : '';
}
const column = contract.tables[tableName]?.columns[columnName];
return column ? escapeMysqlTextPrefix(column.logicalType) : '';
}
function mapColumnType(dialect: SqlDialect, columnName: string, column: SchemaContractColumn): string {
if (dialect === 'sqlite') {
switch (column.logicalType) {
@@ -177,22 +197,34 @@ function buildCreateTableStatement(
return `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(dialect, tableName)} (${parts.join(', ')})`;
}
function buildUniqueIndexStatement(dialect: SqlDialect, uniqueIndex: SchemaContractUnique, contract: SchemaContract): string {
function buildUniqueIndexStatement(
dialect: SqlDialect,
uniqueIndex: SchemaContractUnique,
contract: SchemaContract,
options?: SqlGenerationOptions,
): string {
const columns = uniqueIndex.columns
.map((columnName) => {
const column = contract.tables[uniqueIndex.table]?.columns[columnName];
const suffix = dialect === 'mysql' && column ? escapeMysqlTextPrefix(column.logicalType) : '';
const suffix = dialect === 'mysql'
? resolveMysqlIndexPrefix(uniqueIndex.table, columnName, contract, options)
: '';
return `${quoteIdentifier(dialect, columnName)}${suffix}`;
})
.join(', ');
return `CREATE UNIQUE INDEX ${quoteIdentifier(dialect, uniqueIndex.name)} ON ${quoteIdentifier(dialect, uniqueIndex.table)} (${columns})`;
}
function buildIndexStatement(dialect: SqlDialect, index: SchemaContractIndex, contract: SchemaContract): string {
function buildIndexStatement(
dialect: SqlDialect,
index: SchemaContractIndex,
contract: SchemaContract,
options?: SqlGenerationOptions,
): string {
const columns = index.columns
.map((columnName) => {
const column = contract.tables[index.table]?.columns[columnName];
const suffix = dialect === 'mysql' && column ? escapeMysqlTextPrefix(column.logicalType) : '';
const suffix = dialect === 'mysql'
? resolveMysqlIndexPrefix(index.table, columnName, contract, options)
: '';
return `${quoteIdentifier(dialect, columnName)}${suffix}`;
})
.join(', ');
@@ -317,6 +349,7 @@ export function generateUpgradeSql(
dialect: SqlDialect,
currentContract: SchemaContract,
previousContract?: SchemaContract | null,
options?: SqlGenerationOptions,
): string {
if (!previousContract) {
return `-- no previous schema contract available for ${dialect} additive upgrade generation\n`;
@@ -357,7 +390,7 @@ export function generateUpgradeSql(
.filter((unique) => !previousUniqueNames.has(unique.name))
.slice()
.sort((left, right) => left.name.localeCompare(right.name, 'en'))
.map((unique) => buildUniqueIndexStatement(dialect, unique, currentContract));
.map((unique) => buildUniqueIndexStatement(dialect, unique, currentContract, options));
const currentUniqueNames = new Set(currentContract.uniques.map((unique) => unique.name));
const previousIndexNames = new Set(previousContract.indexes.map((index) => index.name));
@@ -366,7 +399,7 @@ export function generateUpgradeSql(
.filter((index) => !previousIndexNames.has(index.name))
.slice()
.sort((left, right) => left.name.localeCompare(right.name, 'en'))
.map((index) => buildIndexStatement(dialect, index, currentContract));
.map((index) => buildIndexStatement(dialect, index, currentContract, options));
const statements = [...addedTableStatements, ...addColumnStatements, ...uniqueStatements, ...indexStatements];
if (statements.length === 0) {
+5
View File
@@ -11,6 +11,11 @@ describe('schema contract generation', () => {
primaryKey: false,
});
expect(contract.tables.account_tokens.columns.token_group).toBeDefined();
expect(contract.tables.account_tokens.columns.value_status).toMatchObject({
logicalType: 'text',
notNull: true,
defaultValue: "'ready'",
});
expect(contract.tables.site_disabled_models).toBeDefined();
expect(contract.tables.downstream_api_keys).toBeDefined();
expect(contract.tables.proxy_files).toBeDefined();
+28
View File
@@ -3,6 +3,7 @@ import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import type { SchemaContract } from './schemaContract.js';
import { SHARED_INDEX_COMPATIBILITY_SPECS } from './sharedIndexSchemaCompatibility.js';
const dbDir = dirname(fileURLToPath(import.meta.url));
const generatedDir = resolve(dbDir, 'generated');
@@ -63,4 +64,31 @@ describe('database schema parity', () => {
expect(unknownTables).toEqual([]);
expect(unknownIndexes).toEqual([]);
});
it('does not duplicate contract-defined indexes inside shared index compatibility specs', () => {
const contract = JSON.parse(readFileSync(schemaContractPath, 'utf8')) as SchemaContract;
const contractIndexNames = new Set([
...contract.indexes.map((index) => index.name),
...contract.uniques.map((unique) => unique.name),
]);
const duplicatedSpecs = SHARED_INDEX_COMPATIBILITY_SPECS
.map((spec) => spec.indexName)
.filter((indexName) => contractIndexNames.has(indexName));
expect(duplicatedSpecs).toEqual([]);
});
it('keeps proxy_logs downstream api key schema in the generated contract artifacts', () => {
const contract = JSON.parse(readFileSync(schemaContractPath, 'utf8')) as SchemaContract;
const mysqlBootstrap = readFileSync(resolve(generatedDir, 'mysql.bootstrap.sql'), 'utf8');
const postgresBootstrap = readFileSync(resolve(generatedDir, 'postgres.bootstrap.sql'), 'utf8');
expect(contract.tables.proxy_logs?.columns.downstream_api_key_id?.logicalType).toBe('integer');
expect(contract.indexes.some((index) => index.name === 'proxy_logs_downstream_api_key_created_at_idx')).toBe(true);
expect(mysqlBootstrap).toContain('`downstream_api_key_id`');
expect(mysqlBootstrap).toContain('`proxy_logs_downstream_api_key_created_at_idx`');
expect(postgresBootstrap).toContain('"downstream_api_key_id"');
expect(postgresBootstrap).toContain('"proxy_logs_downstream_api_key_created_at_idx"');
});
});
@@ -25,31 +25,10 @@ function createInspector(
describe('ensureSharedIndexSchemaCompatibility', () => {
it.each([
{
dialect: 'sqlite' as const,
expectedSql: [
'CREATE UNIQUE INDEX IF NOT EXISTS model_availability_account_model_unique ON model_availability (account_id, model_name);',
'CREATE INDEX IF NOT EXISTS proxy_logs_status_created_at_idx ON proxy_logs (status, created_at);',
'CREATE INDEX IF NOT EXISTS events_created_at_idx ON events (created_at);',
],
},
{
dialect: 'mysql' as const,
expectedSql: [
'CREATE UNIQUE INDEX `model_availability_account_model_unique` ON `model_availability` (`account_id`, `model_name`(191))',
'CREATE INDEX `proxy_logs_status_created_at_idx` ON `proxy_logs` (`status`, `created_at`(191))',
'CREATE INDEX `events_created_at_idx` ON `events` (`created_at`(191))',
],
},
{
dialect: 'postgres' as const,
expectedSql: [
'CREATE UNIQUE INDEX IF NOT EXISTS "model_availability_account_model_unique" ON "model_availability" ("account_id", "model_name")',
'CREATE INDEX IF NOT EXISTS "proxy_logs_status_created_at_idx" ON "proxy_logs" ("status", "created_at")',
'CREATE INDEX IF NOT EXISTS "events_created_at_idx" ON "events" ("created_at")',
],
},
])('creates shared indexes for $dialect', async ({ dialect, expectedSql }) => {
'sqlite',
'mysql',
'postgres',
] as const)('is a no-op for %s now that contract-defined indexes come from runtime bootstrap', async (dialect) => {
const { inspector, executedSql } = createInspector(dialect, {
existingTables: [
'sites',
@@ -68,10 +47,7 @@ describe('ensureSharedIndexSchemaCompatibility', () => {
});
await ensureSharedIndexSchemaCompatibility(inspector);
for (const sqlText of expectedSql) {
expect(executedSql).toContain(sqlText);
}
expect(executedSql).toEqual([]);
});
it('skips indexes for tables that do not exist', async () => {
@@ -81,8 +57,6 @@ describe('ensureSharedIndexSchemaCompatibility', () => {
await ensureSharedIndexSchemaCompatibility(inspector);
expect(executedSql).toEqual([
'CREATE INDEX IF NOT EXISTS "sites_status_idx" ON "sites" ("status")',
]);
expect(executedSql).toEqual([]);
});
});
+4 -326
View File
@@ -12,332 +12,10 @@ type SharedIndexCompatibilitySpec = {
createSql: Record<SharedIndexSchemaDialect, string>;
};
export const SHARED_INDEX_COMPATIBILITY_SPECS: SharedIndexCompatibilitySpec[] = [
{
table: 'sites',
indexName: 'sites_status_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS sites_status_idx ON sites (status);',
mysql: 'CREATE INDEX `sites_status_idx` ON `sites` (`status`)',
postgres: 'CREATE INDEX IF NOT EXISTS "sites_status_idx" ON "sites" ("status")',
},
},
{
table: 'accounts',
indexName: 'accounts_site_id_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS accounts_site_id_idx ON accounts (site_id);',
mysql: 'CREATE INDEX `accounts_site_id_idx` ON `accounts` (`site_id`)',
postgres: 'CREATE INDEX IF NOT EXISTS "accounts_site_id_idx" ON "accounts" ("site_id")',
},
},
{
table: 'accounts',
indexName: 'accounts_status_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS accounts_status_idx ON accounts (status);',
mysql: 'CREATE INDEX `accounts_status_idx` ON `accounts` (`status`)',
postgres: 'CREATE INDEX IF NOT EXISTS "accounts_status_idx" ON "accounts" ("status")',
},
},
{
table: 'accounts',
indexName: 'accounts_site_status_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS accounts_site_status_idx ON accounts (site_id, status);',
mysql: 'CREATE INDEX `accounts_site_status_idx` ON `accounts` (`site_id`, `status`)',
postgres: 'CREATE INDEX IF NOT EXISTS "accounts_site_status_idx" ON "accounts" ("site_id", "status")',
},
},
{
table: 'account_tokens',
indexName: 'account_tokens_account_id_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS account_tokens_account_id_idx ON account_tokens (account_id);',
mysql: 'CREATE INDEX `account_tokens_account_id_idx` ON `account_tokens` (`account_id`)',
postgres: 'CREATE INDEX IF NOT EXISTS "account_tokens_account_id_idx" ON "account_tokens" ("account_id")',
},
},
{
table: 'account_tokens',
indexName: 'account_tokens_account_enabled_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS account_tokens_account_enabled_idx ON account_tokens (account_id, enabled);',
mysql: 'CREATE INDEX `account_tokens_account_enabled_idx` ON `account_tokens` (`account_id`, `enabled`)',
postgres: 'CREATE INDEX IF NOT EXISTS "account_tokens_account_enabled_idx" ON "account_tokens" ("account_id", "enabled")',
},
},
{
table: 'account_tokens',
indexName: 'account_tokens_enabled_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS account_tokens_enabled_idx ON account_tokens (enabled);',
mysql: 'CREATE INDEX `account_tokens_enabled_idx` ON `account_tokens` (`enabled`)',
postgres: 'CREATE INDEX IF NOT EXISTS "account_tokens_enabled_idx" ON "account_tokens" ("enabled")',
},
},
{
table: 'checkin_logs',
indexName: 'checkin_logs_account_created_at_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS checkin_logs_account_created_at_idx ON checkin_logs (account_id, created_at);',
mysql: 'CREATE INDEX `checkin_logs_account_created_at_idx` ON `checkin_logs` (`account_id`, `created_at`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "checkin_logs_account_created_at_idx" ON "checkin_logs" ("account_id", "created_at")',
},
},
{
table: 'checkin_logs',
indexName: 'checkin_logs_created_at_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS checkin_logs_created_at_idx ON checkin_logs (created_at);',
mysql: 'CREATE INDEX `checkin_logs_created_at_idx` ON `checkin_logs` (`created_at`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "checkin_logs_created_at_idx" ON "checkin_logs" ("created_at")',
},
},
{
table: 'checkin_logs',
indexName: 'checkin_logs_status_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS checkin_logs_status_idx ON checkin_logs (status);',
mysql: 'CREATE INDEX `checkin_logs_status_idx` ON `checkin_logs` (`status`)',
postgres: 'CREATE INDEX IF NOT EXISTS "checkin_logs_status_idx" ON "checkin_logs" ("status")',
},
},
{
table: 'model_availability',
indexName: 'model_availability_account_model_unique',
createSql: {
sqlite: 'CREATE UNIQUE INDEX IF NOT EXISTS model_availability_account_model_unique ON model_availability (account_id, model_name);',
mysql: 'CREATE UNIQUE INDEX `model_availability_account_model_unique` ON `model_availability` (`account_id`, `model_name`(191))',
postgres: 'CREATE UNIQUE INDEX IF NOT EXISTS "model_availability_account_model_unique" ON "model_availability" ("account_id", "model_name")',
},
},
{
table: 'model_availability',
indexName: 'model_availability_account_available_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS model_availability_account_available_idx ON model_availability (account_id, available);',
mysql: 'CREATE INDEX `model_availability_account_available_idx` ON `model_availability` (`account_id`, `available`)',
postgres: 'CREATE INDEX IF NOT EXISTS "model_availability_account_available_idx" ON "model_availability" ("account_id", "available")',
},
},
{
table: 'model_availability',
indexName: 'model_availability_model_name_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS model_availability_model_name_idx ON model_availability (model_name);',
mysql: 'CREATE INDEX `model_availability_model_name_idx` ON `model_availability` (`model_name`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "model_availability_model_name_idx" ON "model_availability" ("model_name")',
},
},
{
table: 'token_model_availability',
indexName: 'token_model_availability_token_model_unique',
createSql: {
sqlite: 'CREATE UNIQUE INDEX IF NOT EXISTS token_model_availability_token_model_unique ON token_model_availability (token_id, model_name);',
mysql: 'CREATE UNIQUE INDEX `token_model_availability_token_model_unique` ON `token_model_availability` (`token_id`, `model_name`(191))',
postgres: 'CREATE UNIQUE INDEX IF NOT EXISTS "token_model_availability_token_model_unique" ON "token_model_availability" ("token_id", "model_name")',
},
},
{
table: 'token_model_availability',
indexName: 'token_model_availability_token_available_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS token_model_availability_token_available_idx ON token_model_availability (token_id, available);',
mysql: 'CREATE INDEX `token_model_availability_token_available_idx` ON `token_model_availability` (`token_id`, `available`)',
postgres: 'CREATE INDEX IF NOT EXISTS "token_model_availability_token_available_idx" ON "token_model_availability" ("token_id", "available")',
},
},
{
table: 'token_model_availability',
indexName: 'token_model_availability_model_name_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS token_model_availability_model_name_idx ON token_model_availability (model_name);',
mysql: 'CREATE INDEX `token_model_availability_model_name_idx` ON `token_model_availability` (`model_name`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "token_model_availability_model_name_idx" ON "token_model_availability" ("model_name")',
},
},
{
table: 'token_model_availability',
indexName: 'token_model_availability_available_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS token_model_availability_available_idx ON token_model_availability (available);',
mysql: 'CREATE INDEX `token_model_availability_available_idx` ON `token_model_availability` (`available`)',
postgres: 'CREATE INDEX IF NOT EXISTS "token_model_availability_available_idx" ON "token_model_availability" ("available")',
},
},
{
table: 'token_routes',
indexName: 'token_routes_model_pattern_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS token_routes_model_pattern_idx ON token_routes (model_pattern);',
mysql: 'CREATE INDEX `token_routes_model_pattern_idx` ON `token_routes` (`model_pattern`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "token_routes_model_pattern_idx" ON "token_routes" ("model_pattern")',
},
},
{
table: 'token_routes',
indexName: 'token_routes_enabled_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS token_routes_enabled_idx ON token_routes (enabled);',
mysql: 'CREATE INDEX `token_routes_enabled_idx` ON `token_routes` (`enabled`)',
postgres: 'CREATE INDEX IF NOT EXISTS "token_routes_enabled_idx" ON "token_routes" ("enabled")',
},
},
{
table: 'route_channels',
indexName: 'route_channels_route_id_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS route_channels_route_id_idx ON route_channels (route_id);',
mysql: 'CREATE INDEX `route_channels_route_id_idx` ON `route_channels` (`route_id`)',
postgres: 'CREATE INDEX IF NOT EXISTS "route_channels_route_id_idx" ON "route_channels" ("route_id")',
},
},
{
table: 'route_channels',
indexName: 'route_channels_account_id_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS route_channels_account_id_idx ON route_channels (account_id);',
mysql: 'CREATE INDEX `route_channels_account_id_idx` ON `route_channels` (`account_id`)',
postgres: 'CREATE INDEX IF NOT EXISTS "route_channels_account_id_idx" ON "route_channels" ("account_id")',
},
},
{
table: 'route_channels',
indexName: 'route_channels_token_id_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS route_channels_token_id_idx ON route_channels (token_id);',
mysql: 'CREATE INDEX `route_channels_token_id_idx` ON `route_channels` (`token_id`)',
postgres: 'CREATE INDEX IF NOT EXISTS "route_channels_token_id_idx" ON "route_channels" ("token_id")',
},
},
{
table: 'route_channels',
indexName: 'route_channels_route_enabled_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS route_channels_route_enabled_idx ON route_channels (route_id, enabled);',
mysql: 'CREATE INDEX `route_channels_route_enabled_idx` ON `route_channels` (`route_id`, `enabled`)',
postgres: 'CREATE INDEX IF NOT EXISTS "route_channels_route_enabled_idx" ON "route_channels" ("route_id", "enabled")',
},
},
{
table: 'route_channels',
indexName: 'route_channels_route_token_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS route_channels_route_token_idx ON route_channels (route_id, token_id);',
mysql: 'CREATE INDEX `route_channels_route_token_idx` ON `route_channels` (`route_id`, `token_id`)',
postgres: 'CREATE INDEX IF NOT EXISTS "route_channels_route_token_idx" ON "route_channels" ("route_id", "token_id")',
},
},
{
table: 'proxy_logs',
indexName: 'proxy_logs_created_at_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS proxy_logs_created_at_idx ON proxy_logs (created_at);',
mysql: 'CREATE INDEX `proxy_logs_created_at_idx` ON `proxy_logs` (`created_at`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "proxy_logs_created_at_idx" ON "proxy_logs" ("created_at")',
},
},
{
table: 'proxy_logs',
indexName: 'proxy_logs_account_created_at_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS proxy_logs_account_created_at_idx ON proxy_logs (account_id, created_at);',
mysql: 'CREATE INDEX `proxy_logs_account_created_at_idx` ON `proxy_logs` (`account_id`, `created_at`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "proxy_logs_account_created_at_idx" ON "proxy_logs" ("account_id", "created_at")',
},
},
{
table: 'proxy_logs',
indexName: 'proxy_logs_status_created_at_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS proxy_logs_status_created_at_idx ON proxy_logs (status, created_at);',
mysql: 'CREATE INDEX `proxy_logs_status_created_at_idx` ON `proxy_logs` (`status`, `created_at`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "proxy_logs_status_created_at_idx" ON "proxy_logs" ("status", "created_at")',
},
},
{
table: 'proxy_logs',
indexName: 'proxy_logs_model_actual_created_at_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS proxy_logs_model_actual_created_at_idx ON proxy_logs (model_actual, created_at);',
mysql: 'CREATE INDEX `proxy_logs_model_actual_created_at_idx` ON `proxy_logs` (`model_actual`(191), `created_at`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "proxy_logs_model_actual_created_at_idx" ON "proxy_logs" ("model_actual", "created_at")',
},
},
{
table: 'proxy_video_tasks',
indexName: 'proxy_video_tasks_upstream_video_id_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS proxy_video_tasks_upstream_video_id_idx ON proxy_video_tasks (upstream_video_id);',
mysql: 'CREATE INDEX `proxy_video_tasks_upstream_video_id_idx` ON `proxy_video_tasks` (`upstream_video_id`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "proxy_video_tasks_upstream_video_id_idx" ON "proxy_video_tasks" ("upstream_video_id")',
},
},
{
table: 'proxy_video_tasks',
indexName: 'proxy_video_tasks_created_at_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS proxy_video_tasks_created_at_idx ON proxy_video_tasks (created_at);',
mysql: 'CREATE INDEX `proxy_video_tasks_created_at_idx` ON `proxy_video_tasks` (`created_at`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "proxy_video_tasks_created_at_idx" ON "proxy_video_tasks" ("created_at")',
},
},
{
table: 'downstream_api_keys',
indexName: 'downstream_api_keys_name_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS downstream_api_keys_name_idx ON downstream_api_keys (name);',
mysql: 'CREATE INDEX `downstream_api_keys_name_idx` ON `downstream_api_keys` (`name`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "downstream_api_keys_name_idx" ON "downstream_api_keys" ("name")',
},
},
{
table: 'downstream_api_keys',
indexName: 'downstream_api_keys_enabled_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS downstream_api_keys_enabled_idx ON downstream_api_keys (enabled);',
mysql: 'CREATE INDEX `downstream_api_keys_enabled_idx` ON `downstream_api_keys` (`enabled`)',
postgres: 'CREATE INDEX IF NOT EXISTS "downstream_api_keys_enabled_idx" ON "downstream_api_keys" ("enabled")',
},
},
{
table: 'downstream_api_keys',
indexName: 'downstream_api_keys_expires_at_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS downstream_api_keys_expires_at_idx ON downstream_api_keys (expires_at);',
mysql: 'CREATE INDEX `downstream_api_keys_expires_at_idx` ON `downstream_api_keys` (`expires_at`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "downstream_api_keys_expires_at_idx" ON "downstream_api_keys" ("expires_at")',
},
},
{
table: 'events',
indexName: 'events_read_created_at_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS events_read_created_at_idx ON events (read, created_at);',
mysql: 'CREATE INDEX `events_read_created_at_idx` ON `events` (`read`, `created_at`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "events_read_created_at_idx" ON "events" ("read", "created_at")',
},
},
{
table: 'events',
indexName: 'events_type_created_at_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS events_type_created_at_idx ON events (type, created_at);',
mysql: 'CREATE INDEX `events_type_created_at_idx` ON `events` (`type`, `created_at`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "events_type_created_at_idx" ON "events" ("type", "created_at")',
},
},
{
table: 'events',
indexName: 'events_created_at_idx',
createSql: {
sqlite: 'CREATE INDEX IF NOT EXISTS events_created_at_idx ON events (created_at);',
mysql: 'CREATE INDEX `events_created_at_idx` ON `events` (`created_at`(191))',
postgres: 'CREATE INDEX IF NOT EXISTS "events_created_at_idx" ON "events" ("created_at")',
},
},
];
// Contract-defined indexes are owned by generated schema artifacts and
// runtimeSchemaBootstrap. This legacy hook is intentionally empty so startup
// does not maintain a second static source of index SQL.
export const SHARED_INDEX_COMPATIBILITY_SPECS: SharedIndexCompatibilitySpec[] = [];
function normalizeSchemaErrorMessage(error: unknown): string {
if (typeof error === 'object' && error && 'message' in error) {
+1 -1
View File
@@ -3,7 +3,7 @@ import type { FastifyInstance } from 'fastify';
const DESKTOP_HEALTH_ROUTE = '/api/desktop/health';
export function isPublicApiRoute(url: string): boolean {
return url === DESKTOP_HEALTH_ROUTE;
return url === DESKTOP_HEALTH_ROUTE || url.startsWith('/api/oauth/callback/');
}
export async function registerDesktopRoutes(app: FastifyInstance) {
+11 -2
View File
@@ -17,6 +17,7 @@ import { taskRoutes } from './routes/api/tasks.js';
import { testRoutes } from './routes/api/test.js';
import { monitorRoutes } from './routes/api/monitor.js';
import { downstreamApiKeysRoutes } from './routes/api/downstreamApiKeys.js';
import { oauthRoutes } from './routes/api/oauth.js';
import { proxyRoutes } from './routes/proxy/router.js';
import { startScheduler } from './services/checkinScheduler.js';
import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
@@ -24,6 +25,7 @@ import { buildStartupSummaryLines } from './services/startupInfo.js';
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js';
import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js';
import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js';
import { startOAuthLoopbackCallbackServers, stopOAuthLoopbackCallbackServers } from './services/oauth/localCallbackServer.js';
import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js';
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
import { existsSync } from 'fs';
@@ -33,9 +35,9 @@ import { normalizeLogCleanupRetentionDays } from './services/logCleanupService.j
import {
db,
ensureProxyFileCompatibilityColumns,
ensureProxyLogDownstreamApiKeyIdColumn,
ensureProxyLogBillingDetailsColumn,
ensureRouteGroupingCompatibilityColumns,
ensureSharedIndexCompatibility,
ensureSiteCompatibilityColumns,
runtimeDbDialect,
schema,
@@ -265,7 +267,7 @@ try {
await ensureSiteCompatibilityColumns();
await ensureRouteGroupingCompatibilityColumns();
await ensureProxyFileCompatibilityColumns();
await ensureSharedIndexCompatibility();
await ensureProxyLogDownstreamApiKeyIdColumn();
const finalRows = await db.select().from(schema.settings).all();
const finalMap = toSettingsMap(finalRows);
applyRuntimeSettings(finalMap);
@@ -312,6 +314,7 @@ await app.register(taskRoutes);
await app.register(testRoutes);
await app.register(monitorRoutes);
await app.register(downstreamApiKeysRoutes);
await app.register(oauthRoutes);
// Register OpenAI-compatible proxy routes
await app.register(proxyRoutes);
@@ -345,9 +348,15 @@ if (existsSync(webDir)) {
// Start scheduler
await startScheduler();
try {
await startOAuthLoopbackCallbackServers();
} catch (error) {
console.warn(`Failed to start OAuth callback listeners: ${(error as Error)?.message || 'unknown error'}`);
}
setLegacyProxyLogRetentionFallbackEnabled(!config.logCleanupConfigured);
app.addHook('onClose', async () => {
stopProxyLogRetentionService();
await stopOAuthLoopbackCallbackServers();
});
// Start server
+79
View File
@@ -0,0 +1,79 @@
import Fastify from 'fastify';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const authorizeDownstreamTokenMock = vi.fn();
const consumeManagedKeyRequestMock = vi.fn();
vi.mock('../services/downstreamApiKeyService.js', () => ({
authorizeDownstreamToken: (...args: unknown[]) => authorizeDownstreamTokenMock(...args),
consumeManagedKeyRequest: (...args: unknown[]) => consumeManagedKeyRequestMock(...args),
}));
describe('proxyAuthMiddleware', () => {
beforeEach(() => {
authorizeDownstreamTokenMock.mockReset();
consumeManagedKeyRequestMock.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
it('rejects missing proxy credentials', async () => {
const { proxyAuthMiddleware } = await import('./auth.js');
const app = Fastify();
app.addHook('onRequest', proxyAuthMiddleware);
app.get('/v1/ping', async () => ({ ok: true }));
const res = await app.inject({ method: 'GET', url: '/v1/ping' });
expect(res.statusCode).toBe(401);
expect(res.json()).toMatchObject({ error: expect.stringContaining('Missing Authorization') });
await app.close();
});
it('stores managed key context and consumes request usage', async () => {
authorizeDownstreamTokenMock.mockResolvedValue({
ok: true,
source: 'managed',
token: 'sk-managed-001',
key: { id: 12, name: 'project-key' },
policy: { supportedModels: ['gpt-5.2'], allowedRouteIds: [3], siteWeightMultipliers: { 1: 1.2 } },
});
consumeManagedKeyRequestMock.mockResolvedValue(undefined);
const { proxyAuthMiddleware, getProxyAuthContext, getProxyResourceOwner } = await import('./auth.js');
const app = Fastify();
app.addHook('onRequest', proxyAuthMiddleware);
app.get('/v1/ping', async (request) => ({
auth: getProxyAuthContext(request),
owner: getProxyResourceOwner(request),
}));
const res = await app.inject({
method: 'GET',
url: '/v1/ping',
headers: { Authorization: 'Bearer sk-managed-001' },
});
expect(res.statusCode).toBe(200);
expect(authorizeDownstreamTokenMock).toHaveBeenCalledWith('sk-managed-001');
expect(consumeManagedKeyRequestMock).toHaveBeenCalledWith(12);
expect(res.json()).toMatchObject({
auth: {
source: 'managed',
keyId: 12,
keyName: 'project-key',
policy: {
supportedModels: ['gpt-5.2'],
allowedRouteIds: [3],
siteWeightMultipliers: { 1: 1.2 },
},
},
owner: {
ownerType: 'managed_key',
ownerId: '12',
},
});
await app.close();
});
});
@@ -3,6 +3,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
import { mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { eq } from 'drizzle-orm';
const { deleteApiTokenMock } = vi.hoisted(() => ({
deleteApiTokenMock: vi.fn(),
@@ -105,6 +106,43 @@ describe('account token batch routes', () => {
expect(rows.every((row) => row.enabled === true)).toBe(true);
});
it('rejects enabling masked_pending placeholders until they are completed', async () => {
await db.update(schema.accountTokens)
.set({
enabled: false,
valueStatus: 'masked_pending' as any,
token: 'sk-mask***tail',
})
.where(eq(schema.accountTokens.id, 1))
.run();
const response = await app.inject({
method: 'POST',
url: '/api/account-tokens/batch',
payload: {
ids: [1, 2],
action: 'enable',
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
successIds?: number[];
failedItems?: Array<{ id: number; message: string }>;
};
expect(body.successIds).toEqual([2]);
expect(body.failedItems).toEqual([
expect.objectContaining({
id: 1,
message: expect.stringContaining('待补全令牌'),
}),
]);
const rows = await db.select().from(schema.accountTokens).all();
expect(rows.find((row) => row.id === 1)?.enabled).toBe(false);
expect(rows.find((row) => row.id === 2)?.enabled).toBe(true);
});
it('deletes selected account tokens through the upstream adapter', async () => {
const response = await app.inject({
method: 'POST',
@@ -0,0 +1,115 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
const refreshModelsForAccountMock = vi.fn();
const rebuildTokenRoutesFromAvailabilityMock = vi.fn();
vi.mock('../../services/modelService.js', () => ({
refreshModelsForAccount: (...args: unknown[]) => refreshModelsForAccountMock(...args),
rebuildTokenRoutesFromAvailability: (...args: unknown[]) => rebuildTokenRoutesFromAvailabilityMock(...args),
}));
type DbModule = typeof import('../../db/index.js');
describe('account token coverage refresh failure handling', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-account-token-coverage-failure-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const accountTokenRoutesModule = await import('./accountTokens.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(accountTokenRoutesModule.accountTokensRoutes);
});
beforeEach(async () => {
refreshModelsForAccountMock.mockReset();
rebuildTokenRoutesFromAvailabilityMock.mockReset();
await db.delete(schema.proxyLogs).run();
await db.delete(schema.checkinLogs).run();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('returns success after token persistence even when coverage rebuild fails', async () => {
const site = await db.insert(schema.sites).values({
name: 'site-1',
url: 'https://site-1.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'user-1',
accessToken: 'acc-token-1',
status: 'active',
}).returning().get();
refreshModelsForAccountMock.mockResolvedValue({
accountId: account.id,
refreshed: true,
status: 'success',
errorCode: null,
errorMessage: '',
modelCount: 1,
modelsPreview: ['gpt-4o-mini'],
tokenScanned: 1,
discoveredByCredential: false,
discoveredApiToken: false,
});
rebuildTokenRoutesFromAvailabilityMock.mockRejectedValue(new Error('rebuild failed'));
const response = await app.inject({
method: 'POST',
url: '/api/account-tokens',
payload: {
accountId: account.id,
name: 'manual-token',
token: 'sk-manual-token',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
coverageRefresh: {
rebuild: {
success: false,
error: 'rebuild failed',
},
},
});
const tokens = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all();
expect(tokens).toHaveLength(1);
expect(tokens[0]?.token).toBe('sk-manual-token');
});
});
@@ -0,0 +1,203 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
const getApiTokensMock = vi.fn();
const getApiTokenMock = vi.fn();
const createApiTokenMock = vi.fn();
const getUserGroupsMock = vi.fn();
const deleteApiTokenMock = vi.fn();
const getModelsMock = vi.fn();
vi.mock('../../services/platforms/index.js', () => ({
getAdapter: () => ({
getApiTokens: (...args: unknown[]) => getApiTokensMock(...args),
getApiToken: (...args: unknown[]) => getApiTokenMock(...args),
createApiToken: (...args: unknown[]) => createApiTokenMock(...args),
getUserGroups: (...args: unknown[]) => getUserGroupsMock(...args),
deleteApiToken: (...args: unknown[]) => deleteApiTokenMock(...args),
getModels: (...args: unknown[]) => getModelsMock(...args),
}),
}));
type DbModule = typeof import('../../db/index.js');
describe('account token coverage refresh', { timeout: 15_000 }, () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
let seedId = 0;
const nextSeed = () => {
seedId += 1;
return seedId;
};
const seedAccount = async (modelName: string) => {
const id = nextSeed();
const site = await db.insert(schema.sites).values({
name: `site-${id}`,
url: `https://site-${id}.example.com`,
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: `user-${id}`,
accessToken: `acc-token-${id}`,
status: 'active',
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName,
available: true,
}).run();
return { site, account };
};
const readTokenCandidates = async () => {
const response = await app.inject({
method: 'GET',
url: '/api/models/token-candidates',
});
expect(response.statusCode).toBe(200);
return response.json() as {
models: Record<string, Array<{ tokenId: number; accountId: number }>>;
modelsWithoutToken: Record<string, Array<{ accountId: number }>>;
};
};
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-account-token-coverage-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const accountTokenRoutesModule = await import('./accountTokens.js');
const statsRoutesModule = await import('./stats.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(accountTokenRoutesModule.accountTokensRoutes);
await app.register(statsRoutesModule.statsRoutes);
});
beforeEach(async () => {
getApiTokensMock.mockReset();
getApiTokenMock.mockReset();
createApiTokenMock.mockReset();
getUserGroupsMock.mockReset();
deleteApiTokenMock.mockReset();
getModelsMock.mockReset();
seedId = 0;
await db.delete(schema.proxyLogs).run();
await db.delete(schema.checkinLogs).run();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('refreshes token coverage after manually adding an account token', async () => {
const modelName = 'gpt-4o-mini';
const { account } = await seedAccount(modelName);
getModelsMock.mockImplementation(async (_url: string, credential: string) => {
if (credential === account.accessToken || credential === 'sk-manual-token') {
return [modelName];
}
return [];
});
const response = await app.inject({
method: 'POST',
url: '/api/account-tokens',
payload: {
accountId: account.id,
name: 'manual-token',
token: 'sk-manual-token',
},
});
expect(response.statusCode).toBe(200);
const token = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.get();
expect(token).toBeDefined();
const tokenAvailability = await db.select()
.from(schema.tokenModelAvailability)
.where(eq(schema.tokenModelAvailability.tokenId, token!.id))
.all();
expect(tokenAvailability.map((row) => row.modelName)).toContain(modelName);
const candidates = await readTokenCandidates();
expect(candidates.modelsWithoutToken[modelName]).toBeUndefined();
expect(candidates.models[modelName]).toEqual([
expect.objectContaining({
accountId: account.id,
tokenId: token!.id,
}),
]);
});
it('refreshes token coverage after syncing account tokens from upstream', async () => {
const modelName = 'claude-sonnet-4-5-20250929';
const { account } = await seedAccount(modelName);
getApiTokensMock.mockResolvedValue([
{ name: 'default', key: 'sk-synced-token', enabled: true },
]);
getModelsMock.mockImplementation(async (_url: string, credential: string) => {
if (credential === account.accessToken || credential === 'sk-synced-token') {
return [modelName];
}
return [];
});
const response = await app.inject({
method: 'POST',
url: `/api/account-tokens/sync/${account.id}`,
});
expect(response.statusCode).toBe(200);
const token = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.get();
expect(token).toBeDefined();
const tokenAvailability = await db.select()
.from(schema.tokenModelAvailability)
.where(eq(schema.tokenModelAvailability.tokenId, token!.id))
.all();
expect(tokenAvailability.map((row) => row.modelName)).toContain(modelName);
const candidates = await readTokenCandidates();
expect(candidates.modelsWithoutToken[modelName]).toBeUndefined();
expect(candidates.models[modelName]).toEqual([
expect.objectContaining({
accountId: account.id,
tokenId: token!.id,
}),
]);
});
});
@@ -138,7 +138,7 @@ describe('account tokens sync routes with site status', () => {
expect(tokenRows.length).toBe(0);
});
it('skips masked upstream token values and does not store them', async () => {
it('stores masked upstream token values as masked_pending placeholders', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
getApiTokensMock.mockResolvedValue([
{ name: 'masked-only', key: 'sk-abc***xyz', enabled: true },
@@ -153,12 +153,13 @@ describe('account tokens sync routes with site status', () => {
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
synced: false,
status: 'skipped',
synced: true,
status: 'synced',
reason: 'upstream_masked_tokens',
maskedSkipped: 1,
total: 0,
created: 0,
maskedPending: 1,
pendingTokenIds: [expect.any(Number)],
total: 1,
created: 1,
updated: 0,
});
@@ -166,7 +167,33 @@ describe('account tokens sync routes with site status', () => {
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all();
expect(tokenRows.length).toBe(0);
expect(tokenRows).toHaveLength(1);
expect(tokenRows[0]).toMatchObject({
name: 'masked-only',
token: 'sk-abc***xyz',
source: 'sync',
enabled: false,
isDefault: false,
});
expect((tokenRows[0] as any).valueStatus).toBe('masked_pending');
const owner = await db.select()
.from(schema.accounts)
.where(eq(schema.accounts.id, account.id))
.get();
expect(owner?.apiToken ?? null).toBeNull();
const listResponse = await app.inject({
method: 'GET',
url: '/api/account-tokens',
});
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json()).toMatchObject([
expect.objectContaining({
id: tokenRows[0].id,
valueStatus: 'masked_pending',
}),
]);
});
it('rejects sync and token management for apikey connections', async () => {
@@ -491,6 +518,7 @@ describe('account tokens sync routes with site status', () => {
source: 'sync',
enabled: true,
isDefault: false,
valueStatus: 'masked_pending' as any,
}).returning().get();
const response = await app.inject({
@@ -503,4 +531,139 @@ describe('account tokens sync routes with site status', () => {
success: false,
});
});
it('upgrades an existing masked_pending placeholder when upstream later returns the full token', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-only',
token: 'sk-abc***xyz',
source: 'sync',
enabled: false,
isDefault: false,
tokenGroup: 'default',
valueStatus: 'masked_pending' as any,
}).run();
getApiTokensMock.mockResolvedValue([
{ name: 'masked-only', key: 'sk-real-token-1234', enabled: true, tokenGroup: 'default' },
]);
getApiTokenMock.mockResolvedValue(null);
const response = await app.inject({
method: 'POST',
url: `/api/account-tokens/sync/${account.id}`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
synced: true,
status: 'synced',
total: 1,
created: 0,
updated: 1,
});
const tokenRows = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all();
expect(tokenRows).toHaveLength(1);
expect(tokenRows[0]).toMatchObject({
name: 'masked-only',
token: 'sk-real-token-1234',
enabled: true,
});
expect((tokenRows[0] as any).valueStatus).toBe('ready');
});
it('does not allow setting a masked_pending placeholder as default', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-token',
token: 'sk-mask***tail',
source: 'sync',
enabled: false,
isDefault: false,
valueStatus: 'masked_pending' as any,
}).returning().get();
const response = await app.inject({
method: 'POST',
url: `/api/account-tokens/${token.id}/default`,
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({
success: false,
message: expect.stringContaining('待补全令牌'),
});
});
it('promotes a masked_pending placeholder to ready when a full token is saved', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-token',
token: 'sk-mask***tail',
source: 'sync',
enabled: false,
isDefault: false,
valueStatus: 'masked_pending' as any,
}).returning().get();
const response = await app.inject({
method: 'PUT',
url: `/api/account-tokens/${token.id}`,
payload: {
token: 'sk-real-token-updated',
enabled: true,
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
token: expect.objectContaining({
id: token.id,
enabled: true,
valueStatus: 'ready',
}),
});
const latest = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.id, token.id))
.get();
expect(latest).toMatchObject({
token: 'sk-real-token-updated',
enabled: true,
});
expect((latest as any)?.valueStatus).toBe('ready');
});
it('deletes masked_pending placeholders locally without calling upstream delete', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-token',
token: 'sk-mask***tail',
source: 'sync',
enabled: false,
isDefault: false,
valueStatus: 'masked_pending' as any,
}).returning().get();
const response = await app.inject({
method: 'DELETE',
url: `/api/account-tokens/${token.id}`,
});
expect(response.statusCode).toBe(200);
expect(deleteApiTokenMock).not.toHaveBeenCalled();
const removed = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, token.id)).get();
expect(removed).toBeUndefined();
});
});
+155 -21
View File
@@ -2,12 +2,17 @@
import { and, eq } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import {
ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING,
ACCOUNT_TOKEN_VALUE_STATUS_READY,
ensureDefaultTokenForAccount,
isMaskedPendingAccountToken,
isMaskedTokenValue,
isUsableAccountToken,
listTokensWithRelations,
normalizeTokenForDisplay,
maskToken,
repairDefaultToken,
resolveAccountTokenValueStatus,
setDefaultToken,
syncTokensFromUpstream,
} from '../../services/accountTokenService.js';
@@ -15,6 +20,11 @@ import { getAdapter } from '../../services/platforms/index.js';
import { getCredentialModeFromExtraConfig, getProxyUrlFromExtraConfig, resolvePlatformUserId } from '../../services/accountExtraConfig.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
import { withAccountProxyOverride } from '../../services/siteProxy.js';
import {
rebuildTokenRoutesFromAvailability,
refreshModelsForAccount,
type ModelRefreshResult,
} from '../../services/modelService.js';
type AccountWithSiteRow = {
accounts: typeof schema.accounts.$inferSelect;
@@ -34,11 +44,31 @@ type SyncExecutionResult = {
synced: boolean;
created: number;
updated: number;
maskedSkipped?: number;
maskedPending?: number;
pendingTokenIds?: number[];
total: number;
defaultTokenId?: number | null;
};
type CoverageRefreshFailureItem = {
accountId: number;
refreshed: false;
status: 'failed';
errorCode: 'coverage_refresh_failed';
errorMessage: string;
modelCount: 0;
modelsPreview: string[];
reason: 'coverage_refresh_failed';
tokenScanned: 0;
discoveredByCredential: false;
discoveredApiToken: false;
};
type CoverageRefreshItem = ModelRefreshResult | CoverageRefreshFailureItem;
type CoverageRefreshRebuildResult =
| { success: true; result: Awaited<ReturnType<typeof rebuildTokenRoutesFromAvailability>> }
| { success: false; error: string };
const TOKEN_SYNC_TIMEOUT_MS = 15_000;
const SYNC_ALL_BATCH_SIZE = 3;
@@ -267,13 +297,13 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise<SyncExe
}
const synced = await syncTokensFromUpstream(accountId, tokens);
if ((synced.created + synced.updated) === 0 && synced.maskedSkipped > 0) {
if ((synced.maskedPending || 0) > 0) {
return {
...base,
status: 'skipped',
status: 'synced',
reason: 'upstream_masked_tokens',
message: `上游仅返回脱敏令牌(含*),跳过 ${synced.maskedSkipped} 条,无法同步明文令牌`,
synced: false,
message: `上游返回 ${synced.maskedPending} 条脱敏令牌,已保存为待补全记录,请手动补全明文 token。`,
synced: true,
...synced,
};
}
@@ -306,7 +336,7 @@ async function appendTokenSyncEvent(result: SyncExecutionResult) {
? 'info'
: (result.status === 'skipped' ? 'warning' : 'error');
const detail = result.status === 'synced'
? `新增 ${result.created},更新 ${result.updated},总数 ${result.total}`
? `新增 ${result.created},更新 ${result.updated},待补全 ${result.maskedPending || 0},总数 ${result.total}`
: (result.message || result.reason || 'sync skipped');
try {
@@ -341,6 +371,12 @@ async function executeSyncAllAccountTokens() {
results.push(...batchResults);
}
const coverageRefresh = await refreshCoverageForAccounts(
results
.filter((item) => item.status === 'synced')
.map((item) => item.accountId),
);
const summary = {
total: results.length,
synced: results.filter((item) => item.status === 'synced').length,
@@ -350,7 +386,72 @@ async function executeSyncAllAccountTokens() {
updated: results.reduce((acc, item) => acc + item.updated, 0),
};
return { summary, results };
return { summary, results, coverageRefresh };
}
async function refreshCoverageForAccounts(accountIds: number[]) {
const uniqueAccountIds = Array.from(new Set(
accountIds.filter((id) => Number.isFinite(id) && id > 0),
));
if (uniqueAccountIds.length === 0) {
return { refresh: [], rebuild: null };
}
const refresh: CoverageRefreshItem[] = [];
for (let offset = 0; offset < uniqueAccountIds.length; offset += SYNC_ALL_BATCH_SIZE) {
const batch = uniqueAccountIds.slice(offset, offset + SYNC_ALL_BATCH_SIZE);
const settled = await Promise.allSettled(
batch.map(async (accountId) => refreshModelsForAccount(accountId)),
);
settled.forEach((result, index) => {
if (result.status === 'fulfilled') {
refresh.push(result.value);
return;
}
const accountId = batch[index] || 0;
const errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason || 'coverage refresh failed');
refresh.push(buildCoverageRefreshFailureItem(accountId, errorMessage));
console.warn(`[account-tokens] coverage refresh failed for account ${accountId}: ${errorMessage}`);
});
}
let rebuild: CoverageRefreshRebuildResult | null = null;
try {
rebuild = {
success: true,
result: await rebuildTokenRoutesFromAvailability(),
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error || 'route rebuild failed');
rebuild = {
success: false,
error: errorMessage,
};
console.warn(`[account-tokens] token route rebuild failed after coverage refresh: ${errorMessage}`);
}
return { refresh, rebuild };
}
function buildCoverageRefreshFailureItem(
accountId: number,
errorMessage: string,
): CoverageRefreshFailureItem {
return {
accountId,
refreshed: false,
status: 'failed',
errorCode: 'coverage_refresh_failed',
errorMessage,
modelCount: 0,
modelsPreview: [],
reason: 'coverage_refresh_failed',
tokenScanned: 0,
discoveredByCredential: false,
discoveredApiToken: false,
};
}
export async function accountTokensRoutes(app: FastifyInstance) {
@@ -394,15 +495,25 @@ export async function accountTokensRoutes(app: FastifyInstance) {
const existing = await db.select().from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, body.accountId))
.all();
const valueStatus = isMaskedTokenValue(tokenValue)
? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
: ACCOUNT_TOKEN_VALUE_STATUS_READY;
const enabled = valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY
? (body.enabled ?? true)
: false;
const isDefault = valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY
? (body.isDefault ?? false)
: false;
const inserted = await db.insert(schema.accountTokens).values({
accountId: body.accountId,
name: (body.name || '').trim() || (existing.length === 0 ? 'default' : `token-${existing.length + 1}`),
token: tokenValue,
tokenGroup: (body.group || '').trim() || null,
valueStatus,
source: body.source || 'manual',
enabled: body.enabled ?? true,
isDefault: body.isDefault ?? false,
enabled,
isDefault,
createdAt: now,
updatedAt: now,
}).run();
@@ -415,13 +526,13 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(500).send({ success: false, message: '创建令牌失败' });
}
if (body.isDefault || (existing.length === 0 && (body.enabled ?? true))) {
if (valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY && (body.isDefault || (existing.length === 0 && enabled))) {
await setDefaultToken(created.id);
} else if (existing.every((token) => !token.isDefault) && (body.enabled ?? true)) {
} else if (valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY && existing.every((token) => !token.isDefault) && enabled) {
await setDefaultToken(created.id);
}
return { success: true, token: created };
const coverageRefresh = await refreshCoverageForAccounts([body.accountId]);
return { success: true, token: created, coverageRefresh };
}
const account = row.accounts;
@@ -503,6 +614,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
if (syncResult.status === 'skipped') {
return reply.code(502).send({ success: false, message: syncResult.message || '站点未返回可用令牌' });
}
const coverageRefresh = await refreshCoverageForAccounts([account.id]);
const preferred = await db.select().from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.isDefault, true)))
@@ -516,6 +628,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
success: true,
createdViaUpstream: true,
...syncResult,
coverageRefresh,
token,
};
});
@@ -539,7 +652,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
const account = row.accounts;
const site = row.sites;
const adapter = getAdapter(site.platform);
const shouldDeleteUpstream = !isSiteDisabled(site.status) && !!account.accessToken?.trim() && !!adapter;
const shouldDeleteUpstream = !isMaskedPendingAccountToken(existing)
&& !isSiteDisabled(site.status)
&& !!account.accessToken?.trim()
&& !!adapter;
if (shouldDeleteUpstream) {
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
@@ -603,6 +719,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
continue;
}
} else {
if (isMaskedPendingAccountToken(existing)) {
failedItems.push({ id, message: '待补全令牌不能修改启用状态,请先补全明文 token' });
continue;
}
await db.update(schema.accountTokens)
.set({
enabled: action === 'enable',
@@ -649,6 +769,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
const body = request.body;
const updates: Record<string, unknown> = { updatedAt: new Date().toISOString() };
let nextValueStatus = resolveAccountTokenValueStatus(existing);
if (body.name !== undefined) {
updates.name = (body.name || '').trim() || existing.name;
@@ -660,15 +781,24 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: '令牌不能为空' });
}
updates.token = tokenValue;
nextValueStatus = isMaskedTokenValue(tokenValue)
? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
: ACCOUNT_TOKEN_VALUE_STATUS_READY;
updates.valueStatus = nextValueStatus;
}
if (body.group !== undefined) {
updates.tokenGroup = (body.group || '').trim() || null;
}
if (body.enabled !== undefined) updates.enabled = body.enabled;
if (nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING) {
updates.enabled = false;
updates.isDefault = false;
} else {
if (body.enabled !== undefined) updates.enabled = body.enabled;
if (body.isDefault !== undefined) updates.isDefault = body.isDefault;
}
if (body.source !== undefined) updates.source = body.source;
if (body.isDefault !== undefined) updates.isDefault = body.isDefault;
await db.update(schema.accountTokens).set(updates).where(eq(schema.accountTokens.id, tokenId)).run();
@@ -677,11 +807,11 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(500).send({ success: false, message: '更新失败' });
}
if (body.isDefault === true) {
if (body.isDefault === true && isUsableAccountToken(latest)) {
setDefaultToken(tokenId);
} else if (latest.isDefault && latest.enabled) {
} else if (latest.isDefault && isUsableAccountToken(latest)) {
setDefaultToken(tokenId);
} else if (existing.isDefault && !latest.enabled) {
} else if (existing.isDefault && !isUsableAccountToken(latest)) {
repairDefaultToken(existing.accountId);
} else if (body.isDefault === false && existing.isDefault) {
repairDefaultToken(existing.accountId);
@@ -706,6 +836,9 @@ export async function accountTokensRoutes(app: FastifyInstance) {
if (isApiKeyConnection(owner)) {
return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' });
}
if (isMaskedPendingAccountToken(tokenRow)) {
return reply.code(400).send({ success: false, message: '待补全令牌不能设为默认,请先补全明文 token' });
}
const success = await setDefaultToken(tokenId);
if (!success) {
return reply.code(404).send({ success: false, message: '令牌不存在' });
@@ -733,7 +866,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' });
}
if (isMaskedTokenValue(row.account_tokens.token)) {
if (isMaskedPendingAccountToken(row.account_tokens) || isMaskedTokenValue(row.account_tokens.token)) {
return reply.code(409).send({
success: false,
message: '当前仅保存了脱敏令牌,无法展开/复制。请在站点重新生成并同步,或手动更新为完整令牌。',
@@ -836,7 +969,8 @@ export async function accountTokensRoutes(app: FastifyInstance) {
if (result.status === 'failed') {
return reply.code(502).send({ success: false, message: result.message || '同步失败' });
}
return { success: true, ...result };
const coverageRefresh = await refreshCoverageForAccounts([accountId]);
return { success: true, ...result, coverageRefresh };
});
app.post<{ Body?: { wait?: boolean } }>('/api/account-tokens/sync-all', async (request, reply) => {
@@ -0,0 +1,178 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const verifyTokenMock = vi.fn();
const getApiTokensMock = vi.fn();
const refreshBalanceMock = vi.fn();
const refreshModelsForAccountMock = vi.fn();
const rebuildTokenRoutesFromAvailabilityMock = vi.fn();
const ensureDefaultTokenForAccountMock = vi.fn();
const syncTokensFromUpstreamMock = vi.fn();
vi.mock('../../services/platforms/index.js', () => ({
getAdapter: () => ({
verifyToken: (...args: unknown[]) => verifyTokenMock(...args),
getApiTokens: (...args: unknown[]) => getApiTokensMock(...args),
}),
}));
vi.mock('../../services/balanceService.js', () => ({
refreshBalance: (...args: unknown[]) => refreshBalanceMock(...args),
}));
vi.mock('../../services/modelService.js', () => ({
refreshModelsForAccount: (...args: unknown[]) => refreshModelsForAccountMock(...args),
rebuildTokenRoutesFromAvailability: (...args: unknown[]) => rebuildTokenRoutesFromAvailabilityMock(...args),
}));
vi.mock('../../services/accountTokenService.js', () => ({
ensureDefaultTokenForAccount: (...args: unknown[]) => ensureDefaultTokenForAccountMock(...args),
syncTokensFromUpstream: (...args: unknown[]) => syncTokensFromUpstreamMock(...args),
}));
type DbModule = typeof import('../../db/index.js');
describe('accounts background initialization', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
let resetBackgroundTasks: (() => void) | null = null;
let getBackgroundTask: ((taskId: string) => { status: string } | null) | null = null;
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-background-init-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./accounts.js');
const backgroundTaskModule = await import('../../services/backgroundTaskService.js');
db = dbModule.db;
schema = dbModule.schema;
resetBackgroundTasks = backgroundTaskModule.__resetBackgroundTasksForTests;
getBackgroundTask = backgroundTaskModule.getBackgroundTask;
app = Fastify();
await app.register(routesModule.accountsRoutes);
});
beforeEach(async () => {
verifyTokenMock.mockReset();
getApiTokensMock.mockReset();
refreshBalanceMock.mockReset();
refreshModelsForAccountMock.mockReset();
rebuildTokenRoutesFromAvailabilityMock.mockReset();
ensureDefaultTokenForAccountMock.mockReset();
syncTokensFromUpstreamMock.mockReset();
resetBackgroundTasks?.();
await db.delete(schema.proxyLogs).run();
await db.delete(schema.checkinLogs).run();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.events).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
if (dataDir) {
try {
rmSync(dataDir, { recursive: true, force: true });
} catch {}
}
delete process.env.DATA_DIR;
});
it('returns immediately and queues background initialization when token sync is slow', async () => {
const site = await db.insert(schema.sites).values({
name: 'Session Site',
url: 'https://session.example.com',
platform: 'new-api',
}).returning().get();
verifyTokenMock.mockResolvedValue({
tokenType: 'session',
userInfo: { username: 'demo-user' },
apiToken: 'sk-demo',
});
ensureDefaultTokenForAccountMock.mockResolvedValue(undefined);
refreshBalanceMock.mockResolvedValue({ balance: 1, used: 0, quota: 1 });
refreshModelsForAccountMock.mockResolvedValue(undefined);
rebuildTokenRoutesFromAvailabilityMock.mockResolvedValue(undefined);
syncTokensFromUpstreamMock.mockResolvedValue(undefined);
let releaseTokens: ((value: Array<{ name: string; value: string }>) => void) | null = null;
const pendingTokens = new Promise<Array<{ name: string; value: string }>>((resolve) => {
releaseTokens = resolve;
});
getApiTokensMock.mockReturnValue(pendingTokens);
const responsePromise = app.inject({
method: 'POST',
url: '/api/accounts',
payload: {
siteId: site.id,
accessToken: 'session-token',
},
});
try {
const raceResult = await Promise.race([
responsePromise.then(() => 'response'),
new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 25)),
]);
expect(raceResult).toBe('response');
const response = await responsePromise;
expect(response.statusCode).toBe(200);
const body = response.json() as {
id: number;
queued?: boolean;
jobId?: string;
usernameDetected?: boolean;
apiTokenFound?: boolean;
};
expect(body).toMatchObject({
queued: true,
usernameDetected: true,
apiTokenFound: true,
});
expect(body.jobId).toBeTruthy();
const insertedAccounts = await db.select().from(schema.accounts).all();
expect(insertedAccounts).toHaveLength(1);
expect(getBackgroundTask?.(body.jobId!)).toMatchObject({
status: expect.stringMatching(/pending|running/),
});
releaseTokens?.([{ name: 'default', value: 'sk-demo' }]);
for (let attempt = 0; attempt < 20; attempt += 1) {
const task = getBackgroundTask?.(body.jobId!);
if (task?.status === 'succeeded') break;
await new Promise((resolve) => setTimeout(resolve, 0));
}
expect(syncTokensFromUpstreamMock).toHaveBeenCalledTimes(1);
expect(refreshBalanceMock).toHaveBeenCalledTimes(1);
expect(refreshModelsForAccountMock).toHaveBeenCalledTimes(1);
expect(rebuildTokenRoutesFromAvailabilityMock).toHaveBeenCalledTimes(1);
expect(getBackgroundTask?.(body.jobId!)).toMatchObject({ status: 'succeeded' });
} finally {
releaseTokens?.([]);
await responsePromise.catch(() => undefined);
}
});
});
@@ -19,7 +19,7 @@ vi.mock('../../services/platforms/index.js', () => ({
type DbModule = typeof import('../../db/index.js');
describe('accounts credential mode', () => {
describe('accounts credential mode', { timeout: 15_000 }, () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
@@ -172,6 +172,62 @@ describe('accounts credential mode', () => {
});
});
it('marks codex oauth connection as direct-routed proxy-only connection without checkin/balance capabilities', async () => {
const site = await db.insert(schema.sites).values({
name: 'Codex Site',
url: 'https://chatgpt.com/backend-api/codex',
platform: 'codex',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'codex-user@example.com',
accessToken: 'oauth-access-token',
apiToken: null,
status: 'active',
checkinEnabled: false,
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-123',
email: 'codex-user@example.com',
planType: 'plus',
},
}),
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-5.2-codex',
available: true,
checkedAt: '2026-03-16T12:00:00.000Z',
}).run();
const listResponse = await app.inject({
method: 'GET',
url: '/api/accounts',
});
expect(listResponse.statusCode).toBe(200);
const list = listResponse.json() as Array<{
id: number;
credentialMode?: string;
capabilities?: {
canCheckin?: boolean;
canRefreshBalance?: boolean;
proxyOnly?: boolean;
};
}>;
const item = list.find((entry) => entry.id === account.id);
expect(item?.credentialMode).toBe('session');
expect(item?.capabilities).toMatchObject({
canCheckin: false,
canRefreshBalance: false,
proxyOnly: true,
});
});
it('stores managed refresh token for sub2api session account', async () => {
verifyTokenMock.mockResolvedValueOnce({
tokenType: 'session',
@@ -15,7 +15,7 @@ vi.mock('../../services/platforms/index.js', () => ({
type DbModule = typeof import('../../db/index.js');
describe('accounts rebind-session api', () => {
describe('accounts rebind-session api', { timeout: 15_000 }, () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
+124 -29
View File
@@ -9,6 +9,7 @@ import {
getCredentialModeFromExtraConfig,
getProxyUrlFromExtraConfig,
guessPlatformUserIdFromUsername,
hasOauthProvider,
getSub2ApiAuthFromExtraConfig,
mergeAccountExtraConfig,
normalizeCredentialMode as normalizeCredentialModeInput,
@@ -49,6 +50,17 @@ type AccountCapabilities = {
proxyOnly: boolean;
};
type AccountInitializationParams = {
accountId: number;
site: typeof schema.sites.$inferSelect;
adapter: NonNullable<ReturnType<typeof getAdapter>>;
tokenType: 'session' | 'apikey' | 'unknown';
accessToken: string;
apiToken: string;
platformUserId?: number;
skipModelFetch?: boolean;
};
type VerifyFailureReason = 'needs-user-id' | 'invalid-user-id' | 'shield-blocked' | null;
const limitAccountLogin = createRateLimitGuard({
@@ -80,7 +92,15 @@ function resolveStoredCredentialMode(account: typeof schema.accounts.$inferSelec
function buildCapabilitiesFromCredentialMode(
credentialMode: AccountCredentialMode,
hasSessionToken: boolean,
extraConfig?: string | null,
): AccountCapabilities {
if (hasOauthProvider(extraConfig)) {
return {
canCheckin: false,
canRefreshBalance: false,
proxyOnly: true,
};
}
const sessionCapable = credentialMode === 'session'
? hasSessionToken
: (credentialMode === 'apikey' ? false : hasSessionToken);
@@ -93,7 +113,7 @@ function buildCapabilitiesFromCredentialMode(
function buildCapabilitiesForAccount(account: typeof schema.accounts.$inferSelect): AccountCapabilities {
const credentialMode = resolveStoredCredentialMode(account);
return buildCapabilitiesFromCredentialMode(credentialMode, hasSessionTokenValue(account.accessToken));
return buildCapabilitiesFromCredentialMode(credentialMode, hasSessionTokenValue(account.accessToken), account.extraConfig);
}
function normalizeBatchIds(input: unknown): number[] {
@@ -115,6 +135,75 @@ function normalizePinnedFlag(input: unknown): boolean | null {
return null;
}
async function initializeAccountInBackground({
accountId,
site,
adapter,
tokenType,
accessToken,
apiToken,
platformUserId,
skipModelFetch,
}: AccountInitializationParams) {
const summary = {
accountId,
syncedTokenCount: 0,
refreshedBalance: false,
refreshedModels: false,
rebuiltRoutes: false,
};
if (tokenType === 'session' && apiToken) {
try {
await ensureDefaultTokenForAccount(accountId, apiToken, { name: 'default', source: 'manual' });
} catch {}
}
if (tokenType === 'session' && accessToken) {
try {
const syncedTokens = await adapter.getApiTokens(site.url, accessToken, platformUserId);
summary.syncedTokenCount = Array.isArray(syncedTokens) ? syncedTokens.length : 0;
if (summary.syncedTokenCount > 0) {
await syncTokensFromUpstream(accountId, syncedTokens);
}
} catch {}
}
if (tokenType === 'session') {
try {
await refreshBalance(accountId);
summary.refreshedBalance = true;
} catch {}
}
if (skipModelFetch !== true) {
try {
await refreshModelsForAccount(accountId);
summary.refreshedModels = true;
await rebuildTokenRoutesFromAvailability();
summary.rebuiltRoutes = true;
} catch {}
}
return summary;
}
function buildQueuedAccountInitializationMessage(
tokenType: 'session' | 'apikey' | 'unknown',
skipModelFetch?: boolean,
) {
if (tokenType === 'session' && skipModelFetch === true) {
return '账号已添加,后台正在同步令牌和余额信息。';
}
if (tokenType === 'session') {
return '账号已添加,后台正在同步令牌、余额和模型信息。';
}
if (skipModelFetch === true) {
return '已添加为 API Key 账号(可用于代理转发)。';
}
return '已添加为 API Key 账号,后台正在同步模型和路由信息。';
}
function normalizeSortOrder(input: unknown): number | null {
if (input === undefined || input === null || input === '') return null;
const parsed = Number.parseInt(String(input), 10);
@@ -411,6 +500,7 @@ export async function accountsRoutes(app: FastifyInstance) {
capabilities: buildCapabilitiesFromCredentialMode(
credentialMode,
hasSessionTokenValue(r.accounts.accessToken),
r.accounts.extraConfig,
),
todaySpend: Math.round((spendByAccount[r.accounts.id] || 0) * 1_000_000) / 1_000_000,
todayReward: Math.round(estimateRewardWithTodayIncomeFallback({
@@ -427,6 +517,7 @@ export async function accountsRoutes(app: FastifyInstance) {
sessionCapable: buildCapabilitiesFromCredentialMode(
credentialMode,
hasSessionTokenValue(r.accounts.accessToken),
r.accounts.extraConfig,
).canRefreshBalance,
hasDiscoveredModels: (modelCountByAccount[r.accounts.id] || 0) > 0,
}),
@@ -972,7 +1063,7 @@ export async function accountsRoutes(app: FastifyInstance) {
account: latest,
tokenType: 'session',
credentialMode: 'session',
capabilities: latest ? buildCapabilitiesForAccount(latest) : buildCapabilitiesFromCredentialMode('session', true),
capabilities: latest ? buildCapabilitiesForAccount(latest) : buildCapabilitiesFromCredentialMode('session', true, null),
apiTokenFound: !!nextApiToken,
};
},
@@ -1110,39 +1201,40 @@ export async function accountsRoutes(app: FastifyInstance) {
return reply.code(500).send({ success: false, message: '创建账号失败' });
}
if (tokenType === 'session' && apiToken) {
try {
await ensureDefaultTokenForAccount(result.id, apiToken, { name: 'default', source: 'manual' });
} catch { }
}
if (tokenType === 'session' && accessToken) {
try {
const syncedTokens = await adapter.getApiTokens(site.url, accessToken, resolvedPlatformUserId);
if (syncedTokens.length > 0) {
await syncTokensFromUpstream(result.id, syncedTokens);
}
} catch { }
}
// Try to refresh balance
if (tokenType === 'session') {
try { await refreshBalance(result.id); } catch { }
}
// Try to refresh models only if not skipping.
if (body.skipModelFetch !== true) {
try {
await refreshModelsForAccount(result.id);
await rebuildTokenRoutesFromAvailability();
} catch { }
const shouldQueueInitialization = tokenType === 'session' || body.skipModelFetch !== true;
let queuedTaskId: string | undefined;
let queuedMessage: string | undefined;
if (shouldQueueInitialization) {
const taskTitle = `初始化连接 #${result.id}`;
const { task } = startBackgroundTask(
{
type: 'account-init',
title: taskTitle,
dedupeKey: `account-init-${result.id}`,
notifyOnFailure: true,
successMessage: () => `${taskTitle}已完成`,
failureMessage: (currentTask) => `${taskTitle}失败:${currentTask.error || 'unknown error'}`,
},
async () => initializeAccountInBackground({
accountId: result.id,
site,
adapter,
tokenType,
accessToken,
apiToken,
platformUserId: resolvedPlatformUserId,
skipModelFetch: body.skipModelFetch,
}),
);
queuedTaskId = task.id;
queuedMessage = buildQueuedAccountInitializationMessage(tokenType, body.skipModelFetch);
}
const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get();
const finalCredentialMode = account ? resolveStoredCredentialMode(account) : resolvedCredentialMode;
const capabilities = account
? buildCapabilitiesForAccount(account)
: buildCapabilitiesFromCredentialMode(finalCredentialMode, tokenType === 'session');
: buildCapabilitiesFromCredentialMode(finalCredentialMode, tokenType === 'session', null);
return {
...account,
tokenType,
@@ -1151,6 +1243,9 @@ export async function accountsRoutes(app: FastifyInstance) {
modelCount: verifiedModels.length,
apiTokenFound: !!apiToken,
usernameDetected: !!(!body.username && username),
queued: !!queuedTaskId,
jobId: queuedTaskId,
message: queuedMessage,
};
});
@@ -0,0 +1,510 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
import { PgDialect } from 'drizzle-orm/pg-core';
type DbModule = typeof import('../../db/index.js');
describe('downstream api keys routes', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-downstream-routes-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./downstreamApiKeys.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.downstreamApiKeysRoutes);
});
beforeEach(async () => {
await db.delete(schema.proxyLogs).run();
await db.delete(schema.downstreamApiKeys).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('builds postgres trend buckets by casting text timestamps before date_trunc', async () => {
const routesModule = await import('./downstreamApiKeys.js') as Record<string, any>;
expect(typeof routesModule.buildBucketTsExpressionForDialect).toBe('function');
const expression = routesModule.buildBucketTsExpressionForDialect('postgres', schema.proxyLogs.createdAt, 3600);
const rendered = new PgDialect().sqlToQuery(expression).sql;
expect(rendered).toContain('date_trunc');
expect(rendered).toContain('cast');
expect(rendered).toContain('timestamp');
});
it('creates, updates, resets and deletes downstream api keys', async () => {
const site = await db.insert(schema.sites).values({
name: 'portal-site',
url: 'https://portal.example.com',
status: 'active',
platform: 'openai',
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.2',
displayName: 'portal-route',
enabled: true,
}).returning().get();
const createRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys',
payload: {
name: 'portal-key',
key: 'sk-portal-key-001',
description: 'portal consumer',
groupName: '项目A',
tags: ['移动端', 'VIP'],
enabled: true,
maxCost: 12.5,
maxRequests: 500,
supportedModels: ['gpt-5.2', 'claude-sonnet-4-5'],
allowedRouteIds: [route.id],
siteWeightMultipliers: { [site.id]: 1.2 },
},
});
expect(createRes.statusCode).toBe(200);
const createdBody = createRes.json();
expect(createdBody.success).toBe(true);
expect(createdBody.item).toMatchObject({
name: 'portal-key',
keyMasked: expect.any(String),
groupName: '项目A',
tags: ['移动端', 'VIP'],
maxCost: 12.5,
maxRequests: 500,
supportedModels: ['gpt-5.2', 'claude-sonnet-4-5'],
allowedRouteIds: [route.id],
});
const keyId = createdBody.item.id as number;
const updateRes = await app.inject({
method: 'PUT',
url: `/api/downstream-keys/${keyId}`,
payload: {
name: 'portal-key-updated',
key: 'sk-portal-key-001',
groupName: '项目B',
tags: ['批量候选', 'VIP'],
enabled: false,
maxCost: 20,
maxRequests: 900,
},
});
expect(updateRes.statusCode).toBe(200);
expect(updateRes.json()).toMatchObject({
success: true,
item: {
id: keyId,
name: 'portal-key-updated',
groupName: '项目B',
tags: ['批量候选', 'VIP'],
enabled: false,
maxCost: 20,
maxRequests: 900,
},
});
await db.update(schema.downstreamApiKeys).set({
usedCost: 5.5,
usedRequests: 123,
}).where(eq(schema.downstreamApiKeys.id, keyId)).run();
const resetRes = await app.inject({
method: 'POST',
url: `/api/downstream-keys/${keyId}/reset-usage`,
});
expect(resetRes.statusCode).toBe(200);
expect(resetRes.json()).toMatchObject({ success: true });
const resetRow = await db.select().from(schema.downstreamApiKeys)
.where(eq(schema.downstreamApiKeys.id, keyId))
.get();
expect(resetRow?.usedCost).toBe(0);
expect(resetRow?.usedRequests).toBe(0);
const deleteRes = await app.inject({
method: 'DELETE',
url: `/api/downstream-keys/${keyId}`,
});
expect(deleteRes.statusCode).toBe(200);
expect(deleteRes.json()).toMatchObject({ success: true });
const listRes = await app.inject({
method: 'GET',
url: '/api/downstream-keys',
});
expect(listRes.statusCode).toBe(200);
expect(listRes.json()).toMatchObject({ success: true, items: [] });
});
it('supports batch enable/disable/reset/delete operations', async () => {
const inserted = await db.insert(schema.downstreamApiKeys).values([
{
name: 'batch-a',
key: 'sk-batch-a-001',
enabled: true,
usedCost: 1.2,
usedRequests: 12,
},
{
name: 'batch-b',
key: 'sk-batch-b-001',
enabled: false,
usedCost: 2.4,
usedRequests: 24,
},
]).returning().all();
const disableRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys/batch',
payload: {
ids: [inserted[0].id, inserted[1].id],
action: 'disable',
},
});
expect(disableRes.statusCode).toBe(200);
expect(disableRes.json()).toMatchObject({
success: true,
successIds: [inserted[0].id, inserted[1].id],
failedItems: [],
});
const disabledRows = await db.select().from(schema.downstreamApiKeys).all();
expect(disabledRows.every((row) => row.enabled === false)).toBe(true);
const resetRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys/batch',
payload: {
ids: [inserted[0].id, inserted[1].id],
action: 'resetUsage',
},
});
expect(resetRes.statusCode).toBe(200);
const resetRows = await db.select().from(schema.downstreamApiKeys).all();
expect(resetRows.every((row) => Number(row.usedCost) === 0 && Number(row.usedRequests) === 0)).toBe(true);
const deleteRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys/batch',
payload: {
ids: [inserted[0].id, inserted[1].id],
action: 'delete',
},
});
expect(deleteRes.statusCode).toBe(200);
expect(await db.select().from(schema.downstreamApiKeys).all()).toHaveLength(0);
});
it('supports batch metadata update for group and tags', async () => {
const inserted = await db.insert(schema.downstreamApiKeys).values([
{
name: 'meta-a',
key: 'sk-meta-a-001',
groupName: '旧分组',
tags: JSON.stringify(['旧标签']),
enabled: true,
},
{
name: 'meta-b',
key: 'sk-meta-b-001',
tags: JSON.stringify(['旧标签', '公共']),
enabled: true,
},
]).returning().all();
const res = await app.inject({
method: 'POST',
url: '/api/downstream-keys/batch',
payload: {
ids: inserted.map((item) => item.id),
action: 'updateMetadata',
groupOperation: 'set',
groupName: '新分组',
tagOperation: 'append',
tags: ['项目A', '公共'],
},
});
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({
success: true,
successIds: inserted.map((item) => item.id),
failedItems: [],
});
const rows = await db.select().from(schema.downstreamApiKeys).all();
expect(rows).toHaveLength(2);
for (const row of rows) {
expect(row.groupName).toBe('新分组');
}
const views = rows.map((row) => ({
id: row.id,
groupName: row.groupName,
tags: JSON.parse(String(row.tags || '[]')),
}));
expect(views).toContainEqual(expect.objectContaining({ tags: ['旧标签', '项目A', '公共'] }));
expect(views).toContainEqual(expect.objectContaining({ tags: ['旧标签', '公共', '项目A'] }));
});
it('rejects duplicate key creation and invalid batch action', async () => {
const firstRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys',
payload: {
name: 'dup-a',
key: 'sk-dup-key-001',
},
});
expect(firstRes.statusCode).toBe(200);
const duplicateRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys',
payload: {
name: 'dup-b',
key: 'sk-dup-key-001',
},
});
expect(duplicateRes.statusCode).toBe(409);
expect(duplicateRes.json()).toMatchObject({
success: false,
message: 'API key 已存在',
});
const invalidBatchRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys/batch',
payload: {
ids: [999],
action: 'archive',
},
});
expect(invalidBatchRes.statusCode).toBe(400);
expect(invalidBatchRes.json()).toMatchObject({
success: false,
message: 'Invalid action',
});
});
it('returns summary, overview and trend aggregated from proxy logs', async () => {
const inserted = await db.insert(schema.downstreamApiKeys).values({
name: 'analytics-key',
key: 'sk-analytics-key-001',
enabled: true,
description: 'analytics',
usedCost: 0,
usedRequests: 0,
}).returning().get();
const now = Date.now();
const within24h = new Date(now - 2 * 60 * 60 * 1000).toISOString();
const within7d = new Date(now - 2 * 24 * 60 * 60 * 1000).toISOString();
const outside7d = new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString();
await db.insert(schema.proxyLogs).values([
{
downstreamApiKeyId: inserted.id,
status: 'success',
totalTokens: 1200,
estimatedCost: 0.12,
createdAt: within24h,
},
{
downstreamApiKeyId: inserted.id,
status: 'failed',
totalTokens: 300,
estimatedCost: 0.03,
createdAt: within24h,
},
{
downstreamApiKeyId: inserted.id,
status: 'success',
totalTokens: 600,
estimatedCost: 0.06,
createdAt: within7d,
},
{
downstreamApiKeyId: inserted.id,
status: 'success',
totalTokens: 900,
estimatedCost: 0.09,
createdAt: outside7d,
},
]).run();
const summaryRes = await app.inject({
method: 'GET',
url: '/api/downstream-keys/summary?range=24h&status=enabled&search=analytics',
});
expect(summaryRes.statusCode).toBe(200);
expect(summaryRes.json()).toMatchObject({
success: true,
range: '24h',
status: 'enabled',
search: 'analytics',
group: '',
tags: [],
tagMatch: 'any',
items: [
{
id: inserted.id,
name: 'analytics-key',
rangeUsage: {
totalRequests: 2,
successRequests: 1,
failedRequests: 1,
successRate: 50,
totalTokens: 1500,
totalCost: 0.15,
},
},
],
});
const overviewRes = await app.inject({
method: 'GET',
url: `/api/downstream-keys/${inserted.id}/overview`,
});
expect(overviewRes.statusCode).toBe(200);
expect(overviewRes.json()).toMatchObject({
success: true,
item: { id: inserted.id, name: 'analytics-key' },
usage: {
last24h: {
totalRequests: 2,
successRequests: 1,
failedRequests: 1,
totalTokens: 1500,
totalCost: 0.15,
},
last7d: {
totalRequests: 3,
successRequests: 2,
failedRequests: 1,
totalTokens: 2100,
totalCost: 0.21,
},
all: {
totalRequests: 4,
successRequests: 3,
failedRequests: 1,
totalTokens: 3000,
totalCost: 0.3,
},
},
});
const trendRes = await app.inject({
method: 'GET',
url: `/api/downstream-keys/${inserted.id}/trend?range=all`,
});
expect(trendRes.statusCode).toBe(200);
const trendBody = trendRes.json();
expect(trendBody.success).toBe(true);
expect(trendBody.range).toBe('all');
expect(trendBody.item).toMatchObject({ id: inserted.id, name: 'analytics-key' });
expect(Array.isArray(trendBody.buckets)).toBe(true);
expect(trendBody.buckets.length).toBeGreaterThanOrEqual(3);
expect(trendBody.buckets.some((bucket: any) => bucket.totalTokens === 1500)).toBe(true);
expect(trendBody.buckets.some((bucket: any) => bucket.totalTokens === 600)).toBe(true);
expect(trendBody.buckets.some((bucket: any) => bucket.totalTokens === 900)).toBe(true);
});
it('rejects unknown route ids and site ids in downstream policy payloads', async () => {
const createRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys',
payload: {
name: 'invalid-policy',
key: 'sk-invalid-policy-001',
allowedRouteIds: [999],
},
});
expect(createRes.statusCode).toBe(400);
expect(createRes.json()).toMatchObject({
success: false,
message: 'allowedRouteIds 包含不存在的路由: 999',
});
const site = await db.insert(schema.sites).values({
name: 'site-a',
url: 'https://example.com',
status: 'active',
platform: 'openai',
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.2',
enabled: true,
}).returning().get();
const created = await app.inject({
method: 'POST',
url: '/api/downstream-keys',
payload: {
name: 'valid-policy',
key: 'sk-valid-policy-001',
allowedRouteIds: [route.id],
siteWeightMultipliers: { [site.id]: 1.2 },
},
});
expect(created.statusCode).toBe(200);
const keyId = created.json().item.id as number;
const updateRes = await app.inject({
method: 'PUT',
url: `/api/downstream-keys/${keyId}`,
payload: {
siteWeightMultipliers: { 999: 1.5 },
},
});
expect(updateRes.statusCode).toBe(400);
expect(updateRes.json()).toMatchObject({
success: false,
message: 'siteWeightMultipliers 包含不存在的站点: 999',
});
const filteredSummaryRes = await app.inject({
method: 'GET',
url: '/api/downstream-keys/summary?range=all&group=__ungrouped__&tags=foo,bar&tagMatch=all',
});
expect(filteredSummaryRes.statusCode).toBe(200);
expect(filteredSummaryRes.json()).toMatchObject({
success: true,
range: 'all',
group: '__ungrouped__',
tags: ['foo', 'bar'],
tagMatch: 'all',
items: [],
});
});
});
+494 -6
View File
@@ -1,6 +1,6 @@
import { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { and, eq, inArray, sql, type SQL, type SQLWrapper } from 'drizzle-orm';
import { db, hasProxyLogDownstreamApiKeyIdColumn, runtimeDbDialect, schema } from '../../db/index.js';
import {
getDownstreamApiKeyById,
listDownstreamApiKeys,
@@ -8,6 +8,7 @@ import {
toDownstreamApiKeyPolicyView,
toPersistenceJson,
} from '../../services/downstreamApiKeyService.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
function parseRouteId(raw: string): number | null {
const id = Number.parseInt(raw, 10);
@@ -24,7 +25,380 @@ function looksLikeUniqueViolation(error: unknown): boolean {
return message.includes('UNIQUE constraint failed') && message.includes('downstream_api_keys.key');
}
function normalizeBatchIds(raw: unknown): number[] {
const values = Array.isArray(raw) ? raw : [];
const ids: number[] = [];
for (const item of values) {
const parsed = Number(item);
if (!Number.isFinite(parsed)) continue;
const id = Math.trunc(parsed);
if (id <= 0 || ids.includes(id)) continue;
ids.push(id);
if (ids.length >= 500) break;
}
return ids;
}
type DownstreamKeyRange = '24h' | '7d' | 'all';
type DownstreamKeyStatus = 'all' | 'enabled' | 'disabled';
function normalizeDownstreamKeyRange(raw: unknown): DownstreamKeyRange {
const value = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
if (value === '24h') return '24h';
if (value === '7d') return '7d';
if (value === 'all') return 'all';
return '24h';
}
function normalizeDownstreamKeyStatus(raw: unknown): DownstreamKeyStatus {
const value = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
if (value === 'enabled') return 'enabled';
if (value === 'disabled') return 'disabled';
return 'all';
}
function normalizeSearchQuery(raw: unknown): string {
const value = typeof raw === 'string' ? raw.trim() : '';
if (!value) return '';
return value.slice(0, 80);
}
function normalizeGroupQuery(raw: unknown): string {
const value = typeof raw === 'string' ? raw.trim() : '';
return value.slice(0, 64);
}
function normalizeTagQuery(raw: unknown): string[] {
const value = typeof raw === 'string' ? raw : Array.isArray(raw) ? raw.join(',') : '';
return value
.split(/[\r\n,,]+/g)
.map((item) => item.trim())
.filter(Boolean)
.map((item) => item.slice(0, 32))
.filter((item, index, arr) => arr.findIndex((candidate) => candidate.toLowerCase() === item.toLowerCase()) === index)
.slice(0, 20);
}
function normalizeTagMatchMode(raw: unknown): 'any' | 'all' {
const value = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
return value === 'all' ? 'all' : 'any';
}
function resolveRangeSinceUtc(range: DownstreamKeyRange): string | null {
const nowTs = Date.now();
if (range === '24h') return formatUtcSqlDateTime(new Date(nowTs - 24 * 60 * 60 * 1000));
if (range === '7d') return formatUtcSqlDateTime(new Date(nowTs - 7 * 24 * 60 * 60 * 1000));
return null;
}
function resolveBucketSeconds(range: DownstreamKeyRange): number {
return range === 'all' ? 86400 : 3600;
}
export function buildBucketTsExpressionForDialect(
dialect: 'sqlite' | 'mysql' | 'postgres',
createdAtColumn: SQLWrapper,
bucketSeconds: number,
) {
if (dialect === 'mysql') {
return sql<number>`floor(unix_timestamp(${createdAtColumn}) / ${bucketSeconds}) * ${bucketSeconds}`;
}
if (dialect === 'postgres') {
const createdAtTimestamp = sql`cast(${createdAtColumn} as timestamp)`;
if (bucketSeconds === 86400) {
return sql<number>`extract(epoch from date_trunc('day', ${createdAtTimestamp}))::bigint`;
}
return sql<number>`extract(epoch from date_trunc('hour', ${createdAtTimestamp}))::bigint`;
}
// sqlite
return sql<number>`cast(cast(strftime('%s', ${createdAtColumn}) as integer) / ${bucketSeconds} as integer) * ${bucketSeconds}`;
}
function resolveBucketTsExpression(bucketSeconds: number) {
return buildBucketTsExpressionForDialect(runtimeDbDialect, schema.proxyLogs.createdAt, bucketSeconds);
}
async function validatePolicyReferences(input: {
allowedRouteIds: number[];
siteWeightMultipliers: Record<number, number>;
}): Promise<string | null> {
const routeIds = input.allowedRouteIds || [];
if (routeIds.length > 0) {
const rows = await db.select({ id: schema.tokenRoutes.id })
.from(schema.tokenRoutes)
.where(inArray(schema.tokenRoutes.id, routeIds))
.all();
const existingIds = new Set(rows.map((row) => Number(row.id)));
const missingIds = routeIds.filter((id) => !existingIds.has(id));
if (missingIds.length > 0) {
return `allowedRouteIds 包含不存在的路由: ${missingIds.join(', ')}`;
}
}
const siteIds = Object.keys(input.siteWeightMultipliers || {})
.map((key) => Number(key))
.filter((value) => Number.isFinite(value) && value > 0)
.map((value) => Math.trunc(value));
if (siteIds.length > 0) {
const rows = await db.select({ id: schema.sites.id })
.from(schema.sites)
.where(inArray(schema.sites.id, siteIds))
.all();
const existingIds = new Set(rows.map((row) => Number(row.id)));
const missingIds = siteIds.filter((id) => !existingIds.has(id));
if (missingIds.length > 0) {
return `siteWeightMultipliers 包含不存在的站点: ${missingIds.join(', ')}`;
}
}
return null;
}
export async function downstreamApiKeysRoutes(app: FastifyInstance) {
app.get<{ Querystring: { range?: string; status?: string; search?: string; group?: string; tags?: string | string[]; tagMatch?: string } }>('/api/downstream-keys/summary', async (request) => {
const range = normalizeDownstreamKeyRange(request.query?.range);
const status = normalizeDownstreamKeyStatus(request.query?.status);
const search = normalizeSearchQuery(request.query?.search);
const group = normalizeGroupQuery(request.query?.group);
const tags = normalizeTagQuery(request.query?.tags);
const tagMatch = normalizeTagMatchMode(request.query?.tagMatch);
const whereClauses: SQL[] = [];
if (status === 'enabled') {
whereClauses.push(eq(schema.downstreamApiKeys.enabled, true));
} else if (status === 'disabled') {
whereClauses.push(eq(schema.downstreamApiKeys.enabled, false));
}
if (search) {
const pattern = `%${search.toLowerCase()}%`;
whereClauses.push(sql`(lower(${schema.downstreamApiKeys.name}) like ${pattern} or lower(coalesce(${schema.downstreamApiKeys.description}, '')) like ${pattern})`);
}
let keysQuery = db.select().from(schema.downstreamApiKeys);
if (whereClauses.length > 0) {
keysQuery = keysQuery.where(and(...whereClauses));
}
const keys = (await keysQuery.all())
.map((row) => toDownstreamApiKeyPolicyView(row))
.filter((item) => {
if (group === '__ungrouped__') {
if (item.groupName) return false;
} else if (group && item.groupName !== group) {
return false;
}
if (!search && tags.length === 0) return true;
const haystack = [
item.name,
item.description || '',
item.keyMasked,
item.groupName || '',
...item.tags,
...item.supportedModels,
].join(' ').toLowerCase();
if (search && !haystack.includes(search.toLowerCase())) return false;
if (tags.length === 0) return true;
const itemTags = new Set(item.tags.map((tag) => tag.toLowerCase()));
return tagMatch === 'all'
? tags.every((tag) => itemTags.has(tag.toLowerCase()))
: tags.some((tag) => itemTags.has(tag.toLowerCase()));
})
.sort((a, b) => b.id - a.id);
if (keys.length === 0) {
return { success: true, range, status, search, group, tags, tagMatch, items: [] };
}
const columnReady = await hasProxyLogDownstreamApiKeyIdColumn();
const sinceUtc = resolveRangeSinceUtc(range);
const ids = keys.map((k) => k.id);
const usageRows = columnReady
? await db.select({
keyId: schema.proxyLogs.downstreamApiKeyId,
totalRequests: sql<number>`count(*)`,
successRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`,
failedRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 0 else 1 end), 0)`,
totalTokens: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`,
totalCost: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`,
})
.from(schema.proxyLogs)
.where(and(
inArray(schema.proxyLogs.downstreamApiKeyId, ids),
...(sinceUtc ? [sql`${schema.proxyLogs.createdAt} >= ${sinceUtc}`] : []),
))
.groupBy(schema.proxyLogs.downstreamApiKeyId)
.all()
: [];
const usageByKey = new Map<number, {
totalRequests: number;
successRequests: number;
failedRequests: number;
totalTokens: number;
totalCost: number;
}>();
for (const row of usageRows) {
const keyId = Number((row as any).keyId ?? 0);
if (!Number.isFinite(keyId) || keyId <= 0) continue;
usageByKey.set(keyId, {
totalRequests: Number((row as any).totalRequests || 0),
successRequests: Number((row as any).successRequests || 0),
failedRequests: Number((row as any).failedRequests || 0),
totalTokens: Number((row as any).totalTokens || 0),
totalCost: Number((row as any).totalCost || 0),
});
}
return {
success: true,
range,
status,
search,
group,
tags,
tagMatch,
items: keys.map((key) => {
const usage = usageByKey.get(key.id) || {
totalRequests: 0,
successRequests: 0,
failedRequests: 0,
totalTokens: 0,
totalCost: 0,
};
const successRate = usage.totalRequests > 0
? Math.round((usage.successRequests / usage.totalRequests) * 1000) / 10
: null;
return {
...key,
rangeUsage: {
totalRequests: usage.totalRequests,
successRequests: usage.successRequests,
failedRequests: usage.failedRequests,
successRate,
totalTokens: usage.totalTokens,
totalCost: Math.round(usage.totalCost * 1_000_000) / 1_000_000,
},
};
}),
};
});
app.get<{ Params: { id: string } }>('/api/downstream-keys/:id/overview', async (request, reply) => {
const id = parseRouteId(request.params.id);
if (!id) {
return reply.code(400).send({ success: false, message: 'id 无效' });
}
const item = await getDownstreamApiKeyById(id);
if (!item) {
return reply.code(404).send({ success: false, message: 'API key 不存在' });
}
const columnReady = await hasProxyLogDownstreamApiKeyIdColumn();
if (!columnReady) {
return { success: true, item, usage: { last24h: null, last7d: null, all: null } };
}
const readAggregate = async (range: DownstreamKeyRange) => {
const sinceUtc = resolveRangeSinceUtc(range);
const row = await db.select({
totalRequests: sql<number>`count(*)`,
successRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`,
failedRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 0 else 1 end), 0)`,
totalTokens: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`,
totalCost: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`,
})
.from(schema.proxyLogs)
.where(and(
eq(schema.proxyLogs.downstreamApiKeyId, id),
...(sinceUtc ? [sql`${schema.proxyLogs.createdAt} >= ${sinceUtc}`] : []),
))
.get();
const totalRequests = Number((row as any)?.totalRequests || 0);
const successRequests = Number((row as any)?.successRequests || 0);
const totalCost = Number((row as any)?.totalCost || 0);
return {
totalRequests,
successRequests,
failedRequests: Number((row as any)?.failedRequests || 0),
successRate: totalRequests > 0 ? Math.round((successRequests / totalRequests) * 1000) / 10 : null,
totalTokens: Number((row as any)?.totalTokens || 0),
totalCost: Math.round(totalCost * 1_000_000) / 1_000_000,
};
};
const [last24h, last7d, all] = await Promise.all([
readAggregate('24h'),
readAggregate('7d'),
readAggregate('all'),
]);
return { success: true, item, usage: { last24h, last7d, all } };
});
app.get<{ Params: { id: string }; Querystring: { range?: string } }>('/api/downstream-keys/:id/trend', async (request, reply) => {
const id = parseRouteId(request.params.id);
if (!id) {
return reply.code(400).send({ success: false, message: 'id 无效' });
}
const range = normalizeDownstreamKeyRange(request.query?.range);
const item = await getDownstreamApiKeyById(id);
if (!item) {
return reply.code(404).send({ success: false, message: 'API key 不存在' });
}
const columnReady = await hasProxyLogDownstreamApiKeyIdColumn();
if (!columnReady) {
return { success: true, range, item: { id: item.id, name: item.name }, buckets: [] };
}
const bucketSeconds = resolveBucketSeconds(range);
const bucketTs = resolveBucketTsExpression(bucketSeconds);
const sinceUtc = resolveRangeSinceUtc(range);
const rows = await db.select({
bucketTs,
totalRequests: sql<number>`count(*)`,
successRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`,
failedRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 0 else 1 end), 0)`,
totalTokens: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`,
totalCost: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`,
})
.from(schema.proxyLogs)
.where(and(
eq(schema.proxyLogs.downstreamApiKeyId, id),
...(sinceUtc ? [sql`${schema.proxyLogs.createdAt} >= ${sinceUtc}`] : []),
))
.groupBy(bucketTs)
.orderBy(bucketTs)
.all();
return {
success: true,
range,
item: { id: item.id, name: item.name },
bucketSeconds,
buckets: rows.map((row: any) => {
const tsSeconds = Number(row.bucketTs || 0);
const totalRequests = Number(row.totalRequests || 0);
const successRequests = Number(row.successRequests || 0);
return {
startUtc: tsSeconds > 0 ? new Date(tsSeconds * 1000).toISOString() : null,
totalRequests,
successRequests,
failedRequests: Number(row.failedRequests || 0),
successRate: totalRequests > 0 ? Math.round((successRequests / totalRequests) * 1000) / 10 : null,
totalTokens: Number(row.totalTokens || 0),
totalCost: Math.round(Number(row.totalCost || 0) * 1_000_000) / 1_000_000,
};
}),
};
});
app.get('/api/downstream-keys', async () => {
return {
success: true,
@@ -37,6 +411,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
name?: unknown;
key?: unknown;
description?: unknown;
groupName?: unknown;
tags?: unknown;
enabled?: unknown;
expiresAt?: unknown;
maxCost?: unknown;
@@ -62,6 +438,13 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
if (!validateKeyShape(normalized.key)) {
return reply.code(400).send({ success: false, message: 'key 必须以 sk- 开头且长度至少 6' });
}
const policyRefError = await validatePolicyReferences({
allowedRouteIds: normalized.allowedRouteIds,
siteWeightMultipliers: normalized.siteWeightMultipliers,
});
if (policyRefError) {
return reply.code(400).send({ success: false, message: policyRefError });
}
const nowIso = new Date().toISOString();
@@ -70,6 +453,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
name: normalized.name,
key: normalized.key,
description: normalized.description,
groupName: normalized.groupName,
tags: toPersistenceJson(normalized.tags),
enabled: normalized.enabled,
expiresAt: normalized.expiresAt,
maxCost: normalized.maxCost,
@@ -111,6 +496,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
name?: unknown;
key?: unknown;
description?: unknown;
groupName?: unknown;
tags?: unknown;
enabled?: unknown;
expiresAt?: unknown;
maxCost?: unknown;
@@ -140,6 +527,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
name: request.body?.name ?? existing.name,
key: request.body?.key ?? existing.key,
description: request.body?.description ?? existing.description,
groupName: request.body?.groupName ?? existing.groupName,
tags: request.body?.tags ?? existingView.tags,
enabled: request.body?.enabled ?? existing.enabled,
expiresAt: request.body?.expiresAt ?? existing.expiresAt,
maxCost: request.body?.maxCost ?? existing.maxCost,
@@ -161,6 +550,13 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
if (!validateKeyShape(normalized.key)) {
return reply.code(400).send({ success: false, message: 'key 必须以 sk- 开头且长度至少 6' });
}
const policyRefError = await validatePolicyReferences({
allowedRouteIds: normalized.allowedRouteIds,
siteWeightMultipliers: normalized.siteWeightMultipliers,
});
if (policyRefError) {
return reply.code(400).send({ success: false, message: policyRefError });
}
const nowIso = new Date().toISOString();
try {
@@ -168,6 +564,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
name: normalized.name,
key: normalized.key,
description: normalized.description,
groupName: normalized.groupName,
tags: toPersistenceJson(normalized.tags),
enabled: normalized.enabled,
expiresAt: normalized.expiresAt,
maxCost: normalized.maxCost,
@@ -178,7 +576,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
updatedAt: nowIso,
}).where(eq(schema.downstreamApiKeys.id, id)).run();
const updated = getDownstreamApiKeyById(id);
const updated = await getDownstreamApiKeyById(id);
return {
success: true,
item: updated,
@@ -197,7 +595,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: 'id 无效' });
}
const existing = getDownstreamApiKeyById(id);
const existing = await getDownstreamApiKeyById(id);
if (!existing) {
return reply.code(404).send({ success: false, message: 'API key 不存在' });
}
@@ -210,7 +608,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
return {
success: true,
item: getDownstreamApiKeyById(id),
item: await getDownstreamApiKeyById(id),
};
});
@@ -220,7 +618,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: 'id 无效' });
}
const existing = getDownstreamApiKeyById(id);
const existing = await getDownstreamApiKeyById(id);
if (!existing) {
return reply.code(404).send({ success: false, message: 'API key 不存在' });
}
@@ -231,4 +629,94 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
return { success: true };
});
app.post<{ Body?: {
ids?: number[];
action?: string;
groupOperation?: string;
groupName?: unknown;
tagOperation?: string;
tags?: unknown;
} }>('/api/downstream-keys/batch', async (request, reply) => {
const ids = normalizeBatchIds(request.body?.ids);
const action = String(request.body?.action || '').trim();
if (ids.length === 0) {
return reply.code(400).send({ success: false, message: 'ids is required' });
}
if (!['enable', 'disable', 'delete', 'resetUsage', 'updateMetadata'].includes(action)) {
return reply.code(400).send({ success: false, message: 'Invalid action' });
}
const groupOperation = String(request.body?.groupOperation || 'keep').trim();
const tagOperation = String(request.body?.tagOperation || 'keep').trim();
const normalizedGroupName = normalizeDownstreamApiKeyPayload({ groupName: request.body?.groupName }).groupName;
const normalizedTags = normalizeDownstreamApiKeyPayload({ tags: request.body?.tags }).tags;
if (action === 'updateMetadata') {
if (!['keep', 'set', 'clear'].includes(groupOperation)) {
return reply.code(400).send({ success: false, message: 'Invalid groupOperation' });
}
if (!['keep', 'append'].includes(tagOperation)) {
return reply.code(400).send({ success: false, message: 'Invalid tagOperation' });
}
if (groupOperation === 'set' && !normalizedGroupName) {
return reply.code(400).send({ success: false, message: 'groupName is required when groupOperation=set' });
}
if (tagOperation === 'append' && normalizedTags.length === 0) {
return reply.code(400).send({ success: false, message: 'tags is required when tagOperation=append' });
}
}
const successIds: number[] = [];
const failedItems: Array<{ id: number; message: string }> = [];
for (const id of ids) {
try {
const existing = await getDownstreamApiKeyById(id);
if (!existing) {
failedItems.push({ id, message: 'API key 不存在' });
continue;
}
if (action === 'delete') {
await db.delete(schema.downstreamApiKeys)
.where(eq(schema.downstreamApiKeys.id, id))
.run();
} else if (action === 'resetUsage') {
await db.update(schema.downstreamApiKeys).set({
usedCost: 0,
usedRequests: 0,
updatedAt: new Date().toISOString(),
}).where(eq(schema.downstreamApiKeys.id, id)).run();
} else if (action === 'updateMetadata') {
const nextGroupName = groupOperation === 'keep'
? existing.groupName
: (groupOperation === 'clear' ? null : normalizedGroupName);
const nextTags = tagOperation === 'append'
? Array.from(new Map([...existing.tags, ...normalizedTags].map((tag) => [tag.toLowerCase(), tag])).values())
: existing.tags;
await db.update(schema.downstreamApiKeys).set({
groupName: nextGroupName,
tags: toPersistenceJson(nextTags),
updatedAt: new Date().toISOString(),
}).where(eq(schema.downstreamApiKeys.id, id)).run();
} else {
await db.update(schema.downstreamApiKeys).set({
enabled: action === 'enable',
updatedAt: new Date().toISOString(),
}).where(eq(schema.downstreamApiKeys.id, id)).run();
}
successIds.push(id);
} catch (error: any) {
failedItems.push({ id, message: error?.message || 'Batch operation failed' });
}
}
return {
success: true,
successIds,
failedItems,
};
});
}
@@ -0,0 +1,32 @@
import Fastify from 'fastify';
import { afterEach, describe, expect, it } from 'vitest';
import { oauthRoutes } from './oauth.js';
import { isPublicApiRoute } from '../../desktop.js';
describe('oauth route registration', () => {
const apps: Array<Awaited<ReturnType<typeof Fastify>>> = [];
afterEach(async () => {
await Promise.all(apps.splice(0).map((app) => app.close()));
});
it('registers oauth routes on a Fastify instance at runtime', async () => {
const app = Fastify();
apps.push(app);
await app.register(oauthRoutes);
const routes = app.printRoutes();
expect(routes).toContain('providers (GET, HEAD)');
expect(routes).toContain(':provider');
expect(routes).toContain('sessions/');
expect(routes).toContain(':state (GET, HEAD)');
expect(routes).toContain('onnections (GET, HEAD)');
expect(routes).toContain('allback/');
});
it('treats oauth callback route as a public desktop API route', () => {
expect(isPublicApiRoute('/api/oauth/callback/codex')).toBe(true);
expect(isPublicApiRoute('/api/oauth/callback/claude')).toBe(true);
expect(isPublicApiRoute('/api/oauth/providers')).toBe(false);
});
});
File diff suppressed because it is too large Load Diff
+275
View File
@@ -0,0 +1,275 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { createRateLimitGuard } from '../../middleware/requestRateLimit.js';
import {
deleteOauthConnection,
getOauthSessionStatus,
handleOauthCallback,
listOauthConnections,
listOauthProviders,
refreshOauthConnectionQuota,
startOauthProviderFlow,
startOauthRebindFlow,
submitOauthManualCallback,
} from '../../services/oauth/service.js';
const limitOauthProviderRead = createRateLimitGuard({
bucket: 'oauth-provider-read',
max: 60,
windowMs: 60_000,
});
const limitOauthStart = createRateLimitGuard({
bucket: 'oauth-start',
max: 20,
windowMs: 60_000,
});
const limitOauthSessionRead = createRateLimitGuard({
bucket: 'oauth-session-read',
max: 120,
windowMs: 60_000,
});
const limitOauthSessionMutate = createRateLimitGuard({
bucket: 'oauth-session-mutate',
max: 30,
windowMs: 60_000,
});
const limitOauthConnectionRead = createRateLimitGuard({
bucket: 'oauth-connection-read',
max: 60,
windowMs: 60_000,
});
const limitOauthConnectionMutate = createRateLimitGuard({
bucket: 'oauth-connection-mutate',
max: 20,
windowMs: 60_000,
});
const limitOauthCallback = createRateLimitGuard({
bucket: 'oauth-callback',
max: 30,
windowMs: 60_000,
});
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function renderCallbackPage(message: string): string {
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>OAuth Callback</title>
</head>
<body>
<script>window.close();</script>
${escapeHtml(message)}
</body>
</html>`;
}
function parsePositiveInteger(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value;
if (typeof value !== 'string') return null;
const parsed = Number.parseInt(value.trim(), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
function parseOptionalProjectId(value: unknown): string | undefined | null {
if (value === undefined || value === null) return undefined;
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed || undefined;
}
function resolveRequestOrigin(request: FastifyRequest): string | undefined {
const forwardedProto = typeof request.headers['x-forwarded-proto'] === 'string'
? request.headers['x-forwarded-proto'].split(',')[0]?.trim()
: '';
const protocol = forwardedProto || request.protocol || 'http';
const forwardedHost = typeof request.headers['x-forwarded-host'] === 'string'
? request.headers['x-forwarded-host'].split(',')[0]?.trim()
: '';
const host = forwardedHost
|| (typeof request.headers.host === 'string' ? request.headers.host.trim() : '');
if (!host) return undefined;
return `${protocol}://${host}`;
}
export async function oauthRoutes(app: FastifyInstance) {
app.get('/api/oauth/providers', { preHandler: [limitOauthProviderRead] }, async () => ({
providers: listOauthProviders(),
}));
app.post<{ Params: { provider: string }; Body: { accountId?: number; projectId?: string } }>(
'/api/oauth/providers/:provider/start',
{ preHandler: [limitOauthStart] },
async (request, reply) => {
const rebindAccountId = request.body?.accountId === undefined
? undefined
: parsePositiveInteger(request.body.accountId);
if (request.body?.accountId !== undefined && rebindAccountId === null) {
return reply.code(400).send({ message: 'invalid account id' });
}
const projectId = parseOptionalProjectId(request.body?.projectId);
if (request.body?.projectId !== undefined && projectId === null) {
return reply.code(400).send({ message: 'invalid project id' });
}
try {
return await startOauthProviderFlow({
provider: request.params.provider,
rebindAccountId: rebindAccountId ?? undefined,
projectId: projectId ?? undefined,
requestOrigin: resolveRequestOrigin(request),
});
} catch (error: any) {
return reply.code(404).send({ message: error?.message || 'oauth provider not found' });
}
},
);
app.get<{ Params: { state: string } }>(
'/api/oauth/sessions/:state',
{ preHandler: [limitOauthSessionRead] },
async (request, reply) => {
const session = getOauthSessionStatus(request.params.state);
if (!session) {
return reply.code(404).send({ message: 'oauth session not found' });
}
return session;
},
);
app.post<{ Params: { state: string }; Body: { callbackUrl?: string } }>(
'/api/oauth/sessions/:state/manual-callback',
{ preHandler: [limitOauthSessionMutate] },
async (request, reply) => {
const callbackUrl = typeof request.body?.callbackUrl === 'string'
? request.body.callbackUrl.trim()
: '';
if (!callbackUrl) {
return reply.code(400).send({ message: 'invalid oauth callback url' });
}
try {
return await submitOauthManualCallback({
state: request.params.state,
callbackUrl,
});
} catch (error: any) {
const message = error?.message || 'oauth callback submission failed';
if (message === 'invalid oauth callback url' || message === 'oauth callback state mismatch') {
return reply.code(400).send({ message });
}
if (message === 'oauth session not found') {
return reply.code(404).send({ message });
}
return reply.code(500).send({ message });
}
},
);
app.get<{ Querystring: { limit?: string; offset?: string } }>(
'/api/oauth/connections',
{ preHandler: [limitOauthConnectionRead] },
async (request, reply) => {
const limit = request.query.limit === undefined ? undefined : parsePositiveInteger(request.query.limit);
const offset = request.query.offset === undefined
? undefined
: (() => {
if (typeof request.query.offset !== 'string') return null;
const parsed = Number.parseInt(request.query.offset.trim(), 10);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
})();
if (request.query.limit !== undefined && limit === null) {
return reply.code(400).send({ message: 'invalid limit' });
}
if (request.query.offset !== undefined && offset === null) {
return reply.code(400).send({ message: 'invalid offset' });
}
return listOauthConnections({
limit: limit ?? undefined,
offset: offset ?? undefined,
});
},
);
app.post<{ Params: { accountId: string } }>(
'/api/oauth/connections/:accountId/rebind',
{ preHandler: [limitOauthConnectionMutate] },
async (request, reply) => {
const accountId = parsePositiveInteger(request.params.accountId);
if (accountId === null) {
return reply.code(400).send({ message: 'invalid account id' });
}
try {
return await startOauthRebindFlow(accountId, resolveRequestOrigin(request));
} catch (error: any) {
return reply.code(404).send({ message: error?.message || 'oauth account not found' });
}
},
);
app.delete<{ Params: { accountId: string } }>(
'/api/oauth/connections/:accountId',
{ preHandler: [limitOauthConnectionMutate] },
async (request, reply) => {
const accountId = parsePositiveInteger(request.params.accountId);
if (accountId === null) {
return reply.code(400).send({ message: 'invalid account id' });
}
try {
return await deleteOauthConnection(accountId);
} catch (error: any) {
return reply.code(404).send({ message: error?.message || 'oauth account not found' });
}
},
);
app.post<{ Params: { accountId: string } }>(
'/api/oauth/connections/:accountId/quota/refresh',
{ preHandler: [limitOauthConnectionMutate] },
async (request, reply) => {
const accountId = parsePositiveInteger(request.params.accountId);
if (accountId === null) {
return reply.code(400).send({ message: 'invalid account id' });
}
try {
return await refreshOauthConnectionQuota(accountId);
} catch (error: any) {
return reply.code(404).send({ message: error?.message || 'oauth account not found' });
}
},
);
app.get<{ Params: { provider: string }; Querystring: { state?: string; code?: string; error?: string } }>(
'/api/oauth/callback/:provider',
{ preHandler: [limitOauthCallback] },
async (request, reply) => {
let message = 'OAuth callback received.';
try {
await handleOauthCallback({
provider: request.params.provider,
state: String(request.query.state || ''),
code: request.query.code,
error: request.query.error,
});
message = 'OAuth authorization succeeded. You can close this window.';
} catch {
message = 'OAuth authorization failed. Return to metapi and review the server logs.';
}
reply.type('text/html; charset=utf-8');
return renderCallbackPage(message);
},
);
}
@@ -137,4 +137,57 @@ describe('search routes', () => {
],
});
});
it('includes oauth direct-account model availability in model search results', async () => {
const site = await db.insert(schema.sites).values({
name: 'codex site',
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: 'codex-user@example.com',
accessToken: 'oauth-access-token',
apiToken: null,
status: 'active',
extraConfig: mergeAccountExtraConfig(null, {
credentialMode: 'session',
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-123',
email: 'codex-user@example.com',
planType: 'team',
},
}),
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-5.2-codex',
available: true,
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/search',
payload: {
query: 'gpt-5.2',
limit: 20,
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
models: [
expect.objectContaining({
name: 'gpt-5.2-codex',
accountCount: 1,
tokenCount: 0,
siteCount: 1,
}),
],
});
});
});
+32 -1
View File
@@ -2,7 +2,8 @@ import { FastifyInstance } from 'fastify';
import { db, schema } from '../../db/index.js';
import { and, like, desc, eq, or } from 'drizzle-orm';
import { getProxyLogBaseSelectFields } from '../../services/proxyLogStore.js';
import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js';
import { getCredentialModeFromExtraConfig, supportsDirectAccountRoutingConnection } from '../../services/accountExtraConfig.js';
import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js';
function hasSessionTokenValue(value: string | null | undefined): boolean {
return typeof value === 'string' && value.trim().length > 0;
@@ -134,11 +135,31 @@ export async function searchRoutes(app: FastifyInstance) {
like(schema.tokenModelAvailability.modelName, q),
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
eq(schema.accounts.status, 'active'),
),
)
.limit(perCategory * 20)
.all();
const directAccountModelRows = await db.select({
modelName: schema.modelAvailability.modelName,
accountId: schema.accounts.id,
siteId: schema.sites.id,
accounts: schema.accounts,
})
.from(schema.modelAvailability)
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(
and(
like(schema.modelAvailability.modelName, q),
eq(schema.modelAvailability.available, true),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.limit(perCategory * 20)
.all();
const modelAgg = new Map<string, { tokenIds: Set<number>; accountIds: Set<number>; siteIds: Set<number> }>();
for (const row of modelRows) {
@@ -151,6 +172,16 @@ export async function searchRoutes(app: FastifyInstance) {
agg.accountIds.add(row.accountId);
agg.siteIds.add(row.siteId);
}
for (const row of directAccountModelRows) {
if (!supportsDirectAccountRoutingConnection(row.accounts)) continue;
const key = row.modelName;
if (!modelAgg.has(key)) {
modelAgg.set(key, { tokenIds: new Set(), accountIds: new Set(), siteIds: new Set() });
}
const agg = modelAgg.get(key)!;
agg.accountIds.add(row.accountId);
agg.siteIds.add(row.siteId);
}
const models = Array.from(modelAgg.entries())
.map(([name, agg]) => ({
@@ -29,6 +29,7 @@ describe('stats proxy logs routes', () => {
beforeEach(async () => {
await db.delete(schema.proxyLogs).run();
await db.delete(schema.downstreamApiKeys).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
@@ -52,6 +53,14 @@ describe('stats proxy logs routes', () => {
status: 'active',
}).returning().get();
const downstreamKey = await db.insert(schema.downstreamApiKeys).values({
name: '项目A-Key',
key: 'sk-project-a-001',
groupName: '项目A',
tags: JSON.stringify(['VIP', '灰度']),
enabled: true,
}).returning().get();
const timestamps = [
formatUtcSqlDateTime(new Date('2026-03-09T08:00:00.000Z')),
formatUtcSqlDateTime(new Date('2026-03-09T08:01:00.000Z')),
@@ -62,6 +71,7 @@ describe('stats proxy logs routes', () => {
await db.insert(schema.proxyLogs).values([
{
accountId: account.id,
downstreamApiKeyId: downstreamKey.id,
modelRequested: 'gpt-4o',
modelActual: 'gpt-4o',
status: 'success',
@@ -74,6 +84,7 @@ describe('stats proxy logs routes', () => {
},
{
accountId: account.id,
downstreamApiKeyId: downstreamKey.id,
modelRequested: 'gpt-4o-mini',
modelActual: 'gpt-4o-mini',
status: 'failed',
@@ -136,6 +147,9 @@ describe('stats proxy logs routes', () => {
expect(body.items).toHaveLength(1);
expect(body.items[0]?.modelRequested).toBe('gpt-4o-mini');
expect(body.items[0]?.status).toBe('failed');
expect(body.items[0]?.downstreamKeyName).toBe('项目A-Key');
expect(body.items[0]?.downstreamKeyGroupName).toBe('项目A');
expect(body.items[0]?.downstreamKeyTags).toEqual(['VIP', '灰度']);
expect(body.items[0]).not.toHaveProperty('billingDetails');
expect(body.summary).toEqual({
totalCount: 3,
@@ -160,8 +174,17 @@ describe('stats proxy logs routes', () => {
status: 'active',
}).returning().get();
const downstreamKey = await db.insert(schema.downstreamApiKeys).values({
name: 'detail-key',
key: 'sk-detail-key-001',
groupName: '测试项目',
tags: JSON.stringify(['回归', '日志']),
enabled: true,
}).returning().get();
const inserted = await db.insert(schema.proxyLogs).values({
accountId: account.id,
downstreamApiKeyId: downstreamKey.id,
modelRequested: 'gpt-5',
modelActual: 'gpt-5',
status: 'success',
@@ -188,18 +211,93 @@ describe('stats proxy logs routes', () => {
id: number;
siteName: string | null;
username: string | null;
downstreamKeyName: string | null;
downstreamKeyGroupName: string | null;
downstreamKeyTags: string[];
billingDetails: Record<string, unknown> | null;
};
expect(body.id).toBe(logId);
expect(body.siteName).toBe('detail-site');
expect(body.username).toBe('detail-user');
expect(body.downstreamKeyName).toBe('detail-key');
expect(body.downstreamKeyGroupName).toBe('测试项目');
expect(body.downstreamKeyTags).toEqual(['回归', '日志']);
expect(body.billingDetails).toMatchObject({
breakdown: { totalCost: 0.12 },
usage: { promptTokens: 100, completionTokens: 20 },
});
});
it('supports searching proxy logs by downstream key metadata', async () => {
const site = await db.insert(schema.sites).values({
name: 'meta-site',
url: 'https://meta.example.com',
platform: 'new-api',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'meta-user',
accessToken: 'meta-token',
status: 'active',
}).returning().get();
const alphaKey = await db.insert(schema.downstreamApiKeys).values({
name: '渠道-A',
key: 'sk-channel-a',
groupName: '项目甲',
tags: JSON.stringify(['商务', 'VIP']),
enabled: true,
}).returning().get();
const betaKey = await db.insert(schema.downstreamApiKeys).values({
name: '渠道-B',
key: 'sk-channel-b',
groupName: '项目乙',
tags: JSON.stringify(['灰度']),
enabled: true,
}).returning().get();
await db.insert(schema.proxyLogs).values([
{
accountId: account.id,
downstreamApiKeyId: alphaKey.id,
modelRequested: 'gpt-4o',
modelActual: 'gpt-4o',
status: 'success',
totalTokens: 12,
estimatedCost: 0.12,
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T10:00:00.000Z')),
},
{
accountId: account.id,
downstreamApiKeyId: betaKey.id,
modelRequested: 'gpt-4.1-mini',
modelActual: 'gpt-4.1-mini',
status: 'success',
totalTokens: 22,
estimatedCost: 0.22,
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T10:05:00.000Z')),
},
]).run();
const response = await app.inject({
method: 'GET',
url: '/api/stats/proxy-logs?search=%E9%A1%B9%E7%9B%AE%E7%94%B2',
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
total: number;
items: Array<Record<string, unknown>>;
};
expect(body.total).toBe(1);
expect(body.items[0]?.downstreamKeyName).toBe('渠道-A');
expect(body.items[0]?.downstreamKeyGroupName).toBe('项目甲');
});
it('filters proxy logs by site and time range', async () => {
const alphaSite = await db.insert(schema.sites).values({
name: 'alpha-site',
@@ -157,6 +157,52 @@ describe('/api/models/token-candidates', () => {
expect(body.modelsMissingTokenGroups['gpt-5.2-codex']).toBeUndefined();
});
it('does not report oauth direct connections as missing account tokens', async () => {
const site = await db.insert(schema.sites).values({
name: 'codex-site',
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: 'codex-user@example.com',
accessToken: 'oauth-access-token',
apiToken: null,
status: 'active',
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-123',
email: 'codex-user@example.com',
planType: 'team',
},
}),
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-5.2-codex',
available: true,
}).run();
const response = await app.inject({
method: 'GET',
url: '/api/models/token-candidates',
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
modelsWithoutToken: Record<string, Array<{ accountId: number }>>;
modelsMissingTokenGroups: Record<string, Array<{ accountId: number }>>;
};
expect(body.modelsWithoutToken['gpt-5.2-codex']).toBeUndefined();
expect(body.modelsMissingTokenGroups['gpt-5.2-codex']).toBeUndefined();
});
it('returns modelsMissingTokenGroups when account has partial group token coverage', async () => {
const site = await db.insert(schema.sites).values({
name: 'site-b',
+78 -14
View File
@@ -17,7 +17,8 @@ import {
parseProxyLogBillingDetails,
withProxyLogSelectFields,
} from '../../services/proxyLogStore.js';
import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js';
import { requiresManagedAccountTokens } from '../../services/accountExtraConfig.js';
import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js';
import {
formatLocalDateTime,
formatUtcSqlDateTime,
@@ -26,6 +27,7 @@ import {
parseStoredUtcDateTime,
toLocalDayKeyFromStoredUtc,
} from '../../services/localTimeService.js';
import { createRateLimitGuard } from '../../middleware/requestRateLimit.js';
function parseBooleanFlag(raw?: string): boolean {
if (!raw) return false;
@@ -33,14 +35,13 @@ function parseBooleanFlag(raw?: string): boolean {
return normalized === '1' || normalized === 'true' || normalized === 'yes';
}
function isApiKeyConnection(account: { accessToken?: string | null; extraConfig?: string | null }): boolean {
const explicit = getCredentialModeFromExtraConfig(account.extraConfig);
if (explicit && explicit !== 'auto') return explicit === 'apikey';
return !(account.accessToken || '').trim();
}
const MODELS_MARKETPLACE_BASE_TTL_MS = 15_000;
const MODELS_MARKETPLACE_PRICING_TTL_MS = 90_000;
const limitModelTokenCandidatesRead = createRateLimitGuard({
bucket: 'models-token-candidates-read',
max: 30,
windowMs: 60_000,
});
type ModelsMarketplaceCacheEntry = {
expiresAt: number;
@@ -121,12 +122,36 @@ function normalizeProxyLogTimeBoundary(raw?: string): string | null {
return formatUtcSqlDateTime(parsed);
}
function parseDownstreamKeyTags(raw: unknown): string[] {
if (typeof raw !== 'string' || !raw.trim()) return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
const seen = new Set<string>();
const result: string[] = [];
for (const value of parsed) {
const text = String(value || '').trim();
if (!text) continue;
const dedupeKey = text.toLowerCase();
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
result.push(text);
}
return result;
} catch {
return [];
}
}
function buildProxyLogSearchCondition(search: string) {
if (!search) return null;
const likeTerm = `%${search}%`;
return sql<boolean>`(
lower(coalesce(${schema.proxyLogs.modelRequested}, '')) like ${likeTerm}
or lower(coalesce(${schema.proxyLogs.modelActual}, '')) like ${likeTerm}
or lower(coalesce(${schema.downstreamApiKeys.name}, '')) like ${likeTerm}
or lower(coalesce(${schema.downstreamApiKeys.groupName}, '')) like ${likeTerm}
or lower(coalesce(${schema.downstreamApiKeys.tags}, '')) like ${likeTerm}
)`;
}
@@ -316,6 +341,12 @@ function mapProxyLogRow(
proxy_logs: Record<string, unknown> & { billingDetails?: string | null };
accounts: { username?: string | null } | null;
sites: { id?: number | null; name?: string | null; url?: string | null } | null;
downstream_api_keys: {
id?: number | null;
name?: string | null;
groupName?: string | null;
tags?: string | null;
} | null;
},
options?: { includeBillingDetails?: boolean },
) {
@@ -328,6 +359,10 @@ function mapProxyLogRow(
siteId: row.sites?.id || null,
siteName: row.sites?.name || null,
siteUrl: row.sites?.url || null,
downstreamKeyId: row.downstream_api_keys?.id || null,
downstreamKeyName: row.downstream_api_keys?.name || null,
downstreamKeyGroupName: row.downstream_api_keys?.groupName || null,
downstreamKeyTags: parseDownstreamKeyTags(row.downstream_api_keys?.tags),
};
}
@@ -530,9 +565,16 @@ export async function statsRoutes(app: FastifyInstance) {
proxy_logs: fields,
accounts: schema.accounts,
sites: schema.sites,
downstream_api_keys: {
id: schema.downstreamApiKeys.id,
name: schema.downstreamApiKeys.name,
groupName: schema.downstreamApiKeys.groupName,
tags: schema.downstreamApiKeys.tags,
},
}).from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id));
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.leftJoin(schema.downstreamApiKeys, eq(schema.proxyLogs.downstreamApiKeyId, schema.downstreamApiKeys.id));
if (listWhere) {
query = query.where(listWhere) as typeof query;
@@ -547,13 +589,15 @@ export async function statsRoutes(app: FastifyInstance) {
proxy_logs: Record<string, unknown> & { billingDetails?: string | null };
accounts: { username?: string | null } | null;
sites: { id?: number | null; name?: string | null; url?: string | null } | null;
downstream_api_keys: { id?: number | null; name?: string | null; groupName?: string | null; tags?: string | null } | null;
}>;
let totalQuery = db.select({
total: sql<number>`count(*)`,
}).from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id));
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.leftJoin(schema.downstreamApiKeys, eq(schema.proxyLogs.downstreamApiKeyId, schema.downstreamApiKeys.id));
if (listWhere) {
totalQuery = totalQuery.where(listWhere) as typeof totalQuery;
}
@@ -567,7 +611,8 @@ export async function statsRoutes(app: FastifyInstance) {
totalTokensAll: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`,
}).from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id));
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.leftJoin(schema.downstreamApiKeys, eq(schema.proxyLogs.downstreamApiKeyId, schema.downstreamApiKeys.id));
if (summaryWhere) {
summaryQuery = summaryQuery.where(summaryWhere) as typeof summaryQuery;
}
@@ -599,15 +644,23 @@ export async function statsRoutes(app: FastifyInstance) {
proxy_logs: fields,
accounts: schema.accounts,
sites: schema.sites,
downstream_api_keys: {
id: schema.downstreamApiKeys.id,
name: schema.downstreamApiKeys.name,
groupName: schema.downstreamApiKeys.groupName,
tags: schema.downstreamApiKeys.tags,
},
}).from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.leftJoin(schema.downstreamApiKeys, eq(schema.proxyLogs.downstreamApiKeyId, schema.downstreamApiKeys.id))
.where(eq(schema.proxyLogs.id, id))
.get()
), { includeBillingDetails: true }) as {
proxy_logs: Record<string, unknown> & { billingDetails?: string | null };
accounts: { username?: string | null } | null;
sites: { id?: number | null; name?: string | null; url?: string | null } | null;
downstream_api_keys: { id?: number | null; name?: string | null; groupName?: string | null; tags?: string | null } | null;
} | undefined;
if (!row) {
@@ -672,6 +725,15 @@ export async function statsRoutes(app: FastifyInstance) {
.innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id))
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(
and(
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.all();
const accountAvailability = await db.select().from(schema.modelAvailability)
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
@@ -914,7 +976,7 @@ export async function statsRoutes(app: FastifyInstance) {
};
});
app.get('/api/models/token-candidates', async () => {
app.get('/api/models/token-candidates', { preHandler: [limitModelTokenCandidatesRead] }, async () => {
const resolveTokenGroupLabel = (tokenGroup: string | null, tokenName: string | null): string | null => {
const explicit = (tokenGroup || '').trim();
if (explicit) return explicit;
@@ -937,6 +999,7 @@ export async function statsRoutes(app: FastifyInstance) {
and(
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
@@ -949,6 +1012,7 @@ export async function statsRoutes(app: FastifyInstance) {
siteId: schema.sites.id,
siteName: schema.sites.name,
accessToken: schema.accounts.accessToken,
apiToken: schema.accounts.apiToken,
extraConfig: schema.accounts.extraConfig,
})
.from(schema.modelAvailability)
@@ -1027,7 +1091,7 @@ export async function statsRoutes(app: FastifyInstance) {
}
for (const row of availableModelRows) {
if (isApiKeyConnection(row)) continue;
if (!requiresManagedAccountTokens(row)) continue;
const modelName = (row.modelName || '').trim();
if (!modelName) continue;
const coverageKey = `${row.accountId}::${modelName.toLowerCase()}`;
@@ -1044,7 +1108,7 @@ export async function statsRoutes(app: FastifyInstance) {
const accountIdsForGroupHints = new Set(
availableModelRows
.filter((row) => !isApiKeyConnection(row))
.filter((row) => requiresManagedAccountTokens(row))
.map((row) => row.accountId),
);
const requiredGroupsByAccountModel = new Map<string, Map<string, string>>();
@@ -1106,7 +1170,7 @@ export async function statsRoutes(app: FastifyInstance) {
}
for (const row of availableModelRows) {
if (isApiKeyConnection(row)) continue;
if (!requiresManagedAccountTokens(row)) continue;
const modelName = (row.modelName || '').trim();
if (!modelName) continue;
const accountModelKey = `${row.accountId}::${modelName.toLowerCase()}`;
@@ -149,4 +149,180 @@ describe('PUT /api/routes/:id route rebuild', () => {
expect(rebuiltAuto?.priority).toBe(0);
expect(rebuiltAuto?.weight).toBe(10);
});
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');
const exactRouteA = await db.insert(schema.tokenRoutes).values({
modelPattern: 'claude-opus-4-5',
enabled: true,
}).returning().get();
const exactRouteB = await db.insert(schema.tokenRoutes).values({
modelPattern: 'claude-sonnet-4-5',
enabled: true,
}).returning().get();
await db.insert(schema.routeChannels).values([
{
routeId: exactRouteA.id,
accountId: sourceA.account.id,
tokenId: sourceA.token.id,
sourceModel: 'claude-opus-4-5',
priority: 0,
weight: 10,
enabled: true,
manualOverride: false,
},
{
routeId: exactRouteB.id,
accountId: sourceB.account.id,
tokenId: sourceB.token.id,
sourceModel: 'claude-sonnet-4-5',
priority: 1,
weight: 8,
enabled: true,
manualOverride: false,
},
]).run();
const createResponse = await app.inject({
method: 'POST',
url: '/api/routes',
payload: {
routeMode: 'explicit_group',
displayName: 'claude-opus-4-6',
sourceRouteIds: [exactRouteA.id, exactRouteB.id],
routingStrategy: 'weighted',
},
});
expect(createResponse.statusCode).toBe(200);
expect(createResponse.json()).toMatchObject({
displayName: 'claude-opus-4-6',
routeMode: 'explicit_group',
sourceRouteIds: [exactRouteA.id, exactRouteB.id],
});
const createdRouteId = (createResponse.json() as { id: number }).id;
const storedChannels = await db.select().from(schema.routeChannels)
.where(eq(schema.routeChannels.routeId, createdRouteId))
.all();
expect(storedChannels).toHaveLength(0);
const summaryResponse = await app.inject({
method: 'GET',
url: '/api/routes/summary',
});
expect(summaryResponse.statusCode).toBe(200);
expect(summaryResponse.json()).toContainEqual(expect.objectContaining({
id: createdRouteId,
routeMode: 'explicit_group',
sourceRouteIds: [exactRouteA.id, exactRouteB.id],
channelCount: 2,
enabledChannelCount: 2,
siteNames: expect.arrayContaining([sourceA.site.name, sourceB.site.name]),
}));
const channelsResponse = await app.inject({
method: 'GET',
url: `/api/routes/${createdRouteId}/channels`,
});
expect(channelsResponse.statusCode).toBe(200);
expect(channelsResponse.json()).toEqual(expect.arrayContaining([
expect.objectContaining({
routeId: exactRouteA.id,
accountId: sourceA.account.id,
sourceModel: 'claude-opus-4-5',
}),
expect.objectContaining({
routeId: exactRouteB.id,
accountId: sourceB.account.id,
sourceModel: 'claude-sonnet-4-5',
}),
]));
});
it('rejects invalid explicit-group payloads', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/routes',
payload: {
routeMode: 'explicit_group',
displayName: '',
sourceRouteIds: [],
},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({
success: false,
});
});
it('prefers an exact route over a colliding explicit-group display name', async () => {
const exactCandidate = await seedAccountWithToken('claude-opus-4-6');
const groupedCandidate = await seedAccountWithToken('claude-opus-4-5');
const exactRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: 'claude-opus-4-6',
enabled: true,
}).returning().get();
const sourceRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: 'claude-opus-4-5',
enabled: true,
}).returning().get();
await db.insert(schema.routeChannels).values([
{
routeId: exactRoute.id,
accountId: exactCandidate.account.id,
tokenId: exactCandidate.token.id,
sourceModel: 'claude-opus-4-6',
priority: 0,
weight: 10,
enabled: true,
manualOverride: false,
},
{
routeId: sourceRoute.id,
accountId: groupedCandidate.account.id,
tokenId: groupedCandidate.token.id,
sourceModel: 'claude-opus-4-5',
priority: 0,
weight: 10,
enabled: true,
manualOverride: false,
},
]).run();
const groupResponse = await app.inject({
method: 'POST',
url: '/api/routes',
payload: {
routeMode: 'explicit_group',
displayName: 'claude-opus-4-6',
sourceRouteIds: [sourceRoute.id],
},
});
expect(groupResponse.statusCode).toBe(200);
const decisionResponse = await app.inject({
method: 'GET',
url: '/api/routes/decision?model=claude-opus-4-6',
});
expect(decisionResponse.statusCode).toBe(200);
expect(decisionResponse.json()).toMatchObject({
success: true,
decision: {
matched: true,
routeId: exactRoute.id,
modelPattern: 'claude-opus-4-6',
actualModel: 'claude-opus-4-6',
},
});
});
});
+474 -219
View File
@@ -1,10 +1,14 @@
import { FastifyInstance } from 'fastify';
import { and, eq, inArray } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { and, eq, inArray } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import {
ACCOUNT_TOKEN_VALUE_STATUS_READY,
isUsableAccountToken,
} from '../../services/accountTokenService.js';
import { normalizeRouteRoutingStrategy } from '../../services/routeRoutingStrategy.js';
import { invalidateTokenRouterCache, matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
import {
clearRouteDecisionSnapshot,
clearRouteDecisionSnapshots,
@@ -12,19 +16,156 @@ import {
saveRouteDecisionSnapshots,
} from '../../services/routeDecisionSnapshotStore.js';
function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim();
if (!normalized) return false;
if (normalized.toLowerCase().startsWith('re:')) return false;
return !/[\*\?\[]/.test(normalized);
}
function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim();
if (!normalized) return false;
if (normalized.toLowerCase().startsWith('re:')) return false;
return !/[\*\?]/.test(normalized);
}
type RouteMode = 'pattern' | 'explicit_group';
type RouteRow = typeof schema.tokenRoutes.$inferSelect & {
routeMode: RouteMode;
sourceRouteIds: number[];
};
function normalizeRouteMode(routeMode: unknown): RouteMode {
return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern';
}
function isExplicitGroupRoute(route: Pick<RouteRow, 'routeMode'> | Pick<typeof schema.tokenRoutes.$inferSelect, 'routeMode'>): boolean {
return normalizeRouteMode(route.routeMode) === 'explicit_group';
}
function normalizeSourceRouteIdsInput(input: unknown): number[] {
const rawValues = Array.isArray(input) ? input : [];
const normalized: number[] = [];
for (const raw of rawValues) {
const value = Number(raw);
if (!Number.isFinite(value)) continue;
const routeId = Math.trunc(value);
if (routeId <= 0 || normalized.includes(routeId)) continue;
normalized.push(routeId);
if (normalized.length >= 500) break;
}
return normalized;
}
async function loadRouteSourceIdsMap(routeIds: number[]): Promise<Map<number, number[]>> {
const normalizedRouteIds = Array.from(new Set(routeIds.filter((routeId) => Number.isFinite(routeId) && routeId > 0)));
if (normalizedRouteIds.length === 0) return new Map();
const rows = await db.select().from(schema.routeGroupSources)
.where(inArray(schema.routeGroupSources.groupRouteId, normalizedRouteIds))
.all();
const sourceRouteIdsByRouteId = new Map<number, number[]>();
for (const row of rows) {
if (!sourceRouteIdsByRouteId.has(row.groupRouteId)) {
sourceRouteIdsByRouteId.set(row.groupRouteId, []);
}
sourceRouteIdsByRouteId.get(row.groupRouteId)!.push(row.sourceRouteId);
}
for (const [routeId, sourceRouteIds] of sourceRouteIdsByRouteId.entries()) {
sourceRouteIdsByRouteId.set(routeId, Array.from(new Set(sourceRouteIds)));
}
return sourceRouteIdsByRouteId;
}
function decorateRoutesWithSources(
routes: Array<typeof schema.tokenRoutes.$inferSelect>,
sourceRouteIdsByRouteId: Map<number, number[]>,
): RouteRow[] {
return routes.map((route) => ({
...route,
routeMode: normalizeRouteMode(route.routeMode),
sourceRouteIds: sourceRouteIdsByRouteId.get(route.id) ?? [],
}));
}
async function listRoutesWithSources(): Promise<RouteRow[]> {
const routes = await db.select().from(schema.tokenRoutes).all();
const sourceRouteIdsByRouteId = await loadRouteSourceIdsMap(routes.map((route) => route.id));
return decorateRoutesWithSources(routes, sourceRouteIdsByRouteId);
}
async function getRouteWithSources(routeId: number): Promise<RouteRow | null> {
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
if (!route) return null;
const sourceRouteIdsByRouteId = await loadRouteSourceIdsMap([routeId]);
return decorateRoutesWithSources([route], sourceRouteIdsByRouteId)[0] ?? null;
}
async function validateExplicitGroupSourceRoutes(sourceRouteIds: number[], currentRouteId?: number): Promise<{ ok: true } | { ok: false; message: string }> {
if (sourceRouteIds.length === 0) {
return { ok: false, message: '显式群组至少需要选择一个来源模型' };
}
const routes = await db.select().from(schema.tokenRoutes)
.where(inArray(schema.tokenRoutes.id, sourceRouteIds))
.all();
if (routes.length !== sourceRouteIds.length) {
return { ok: false, message: '来源模型中存在不存在的路由' };
}
for (const route of routes) {
if (currentRouteId && route.id === currentRouteId) {
return { ok: false, message: '显式群组不能引用自身作为来源模型' };
}
if (normalizeRouteMode(route.routeMode) === 'explicit_group') {
return { ok: false, message: '显式群组只能选择精确模型路由作为来源模型' };
}
if (!isExactModelPattern(route.modelPattern)) {
return { ok: false, message: '显式群组只能选择精确模型路由作为来源模型' };
}
}
return { ok: true };
}
async function replaceRouteSourceRouteIds(routeId: number, sourceRouteIds: number[]): Promise<void> {
await db.delete(schema.routeGroupSources).where(eq(schema.routeGroupSources.groupRouteId, routeId)).run();
if (sourceRouteIds.length === 0) return;
await db.insert(schema.routeGroupSources).values(
sourceRouteIds.map((sourceRouteId) => ({
groupRouteId: routeId,
sourceRouteId,
})),
).run();
}
async function clearDependentExplicitGroupSnapshotsBySourceRouteIds(sourceRouteIds: number[]): Promise<void> {
const normalizedSourceRouteIds = Array.from(new Set(
sourceRouteIds.filter((routeId): routeId is number => Number.isFinite(routeId) && routeId > 0),
));
if (normalizedSourceRouteIds.length === 0) return;
const rows = await db.select({ groupRouteId: schema.routeGroupSources.groupRouteId })
.from(schema.routeGroupSources)
.where(inArray(schema.routeGroupSources.sourceRouteId, normalizedSourceRouteIds))
.all();
const dependentRouteIdSet = new Set<number>();
for (const row of rows) {
const routeId = Number(row.groupRouteId);
if (Number.isFinite(routeId) && routeId > 0) {
dependentRouteIdSet.add(routeId);
}
}
const dependentRouteIds = Array.from(dependentRouteIdSet);
if (dependentRouteIds.length === 0) return;
await clearRouteDecisionSnapshots(dependentRouteIds);
}
async function getDefaultTokenId(accountId: number): Promise<number | null> {
const token = await db.select().from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, accountId), eq(schema.accountTokens.enabled, true), eq(schema.accountTokens.isDefault, true)))
.get();
return token?.id ?? null;
}
async function getDefaultTokenId(accountId: number): Promise<number | null> {
const token = await db.select().from(schema.accountTokens)
.where(and(
eq(schema.accountTokens.accountId, accountId),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.isDefault, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
))
.get();
return isUsableAccountToken(token ?? null) ? token!.id : null;
}
function canonicalModelAlias(modelName: string): string {
const normalized = modelName.trim().toLowerCase();
@@ -58,12 +199,12 @@ async function tokenSupportsModel(tokenId: number, modelName: string): Promise<b
});
}
async function checkTokenBelongsToAccount(tokenId: number, accountId: number): Promise<boolean> {
const row = await db.select().from(schema.accountTokens)
.where(and(eq(schema.accountTokens.id, tokenId), eq(schema.accountTokens.accountId, accountId)))
.get();
return !!row;
}
async function checkTokenBelongsToAccount(tokenId: number, accountId: number): Promise<boolean> {
const row = await db.select().from(schema.accountTokens)
.where(and(eq(schema.accountTokens.id, tokenId), eq(schema.accountTokens.accountId, accountId)))
.get();
return isUsableAccountToken(row ?? null);
}
async function getPatternTokenCandidates(modelPattern: string): Promise<Array<{ tokenId: number; accountId: number; sourceModel: string }>> {
const rows = await db.select().from(schema.tokenModelAvailability)
@@ -72,18 +213,20 @@ async function getPatternTokenCandidates(modelPattern: string): Promise<Array<{
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(
and(
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.all();
const result: Array<{ tokenId: number; accountId: number; sourceModel: string }> = [];
for (const row of rows) {
const modelName = row.token_model_availability.modelName?.trim();
if (!modelName) continue;
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.all();
const result: Array<{ tokenId: number; accountId: number; sourceModel: string }> = [];
for (const row of rows) {
if (!isUsableAccountToken(row.account_tokens)) continue;
const modelName = row.token_model_availability.modelName?.trim();
if (!modelName) continue;
if (!matchesModelPattern(modelName, modelPattern)) continue;
result.push({
tokenId: row.account_tokens.id,
@@ -379,25 +522,54 @@ function parseBatchRouteWideDecisionRouteIds(
};
}
async function fetchChannelsForRoutes(routeIds: number[]): Promise<Map<number, any[]>> {
if (routeIds.length === 0) return new Map();
const channelRows = await db.select().from(schema.routeChannels)
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id))
.where(inArray(schema.routeChannels.routeId, routeIds))
.all();
const channelsByRoute = new Map<number, any[]>();
for (const row of channelRows) {
const routeId = row.route_channels.routeId;
if (!channelsByRoute.has(routeId)) channelsByRoute.set(routeId, []);
channelsByRoute.get(routeId)!.push({
...row.route_channels,
account: row.accounts,
site: row.sites,
type RouteChannelSummary = {
channelCount: number;
enabledChannelCount: number;
siteNames: Set<string>;
};
async function fetchChannelsForRouteRows(routes: RouteRow[]): Promise<Map<number, any[]>> {
if (routes.length === 0) return new Map();
const explicitSourceRouteIds = Array.from(new Set(routes
.filter((route) => isExplicitGroupRoute(route))
.flatMap((route) => route.sourceRouteIds)));
const enabledExplicitSourceRouteIds = explicitSourceRouteIds.length > 0
? (await db.select({
id: schema.tokenRoutes.id,
modelPattern: schema.tokenRoutes.modelPattern,
routeMode: schema.tokenRoutes.routeMode,
enabled: schema.tokenRoutes.enabled,
}).from(schema.tokenRoutes)
.where(inArray(schema.tokenRoutes.id, explicitSourceRouteIds))
.all())
.filter((route) => route.enabled && !isExplicitGroupRoute(route) && isExactModelPattern(route.modelPattern))
.map((route) => route.id)
: [];
const actualRouteIds = Array.from(new Set([
...routes.filter((route) => !isExplicitGroupRoute(route)).map((route) => route.id),
...enabledExplicitSourceRouteIds,
]));
if (actualRouteIds.length === 0) {
return new Map(routes.map((route) => [route.id, []]));
}
const channelRows = await db.select().from(schema.routeChannels)
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id))
.where(inArray(schema.routeChannels.routeId, actualRouteIds))
.all();
const channelsByActualRouteId = new Map<number, any[]>();
for (const row of channelRows) {
const routeId = row.route_channels.routeId;
if (!channelsByActualRouteId.has(routeId)) channelsByActualRouteId.set(routeId, []);
channelsByActualRouteId.get(routeId)!.push({
...row.route_channels,
account: row.accounts,
site: row.sites,
token: row.account_tokens
? {
id: row.account_tokens.id,
@@ -406,63 +578,83 @@ async function fetchChannelsForRoutes(routeIds: number[]): Promise<Map<number, a
enabled: row.account_tokens.enabled,
isDefault: row.account_tokens.isDefault,
}
: null,
});
}
return channelsByRoute;
}
export async function tokensRoutes(app: FastifyInstance) {
// List routes with basic info only (lightweight for selectors)
app.get('/api/routes/lite', async () => {
return await db.select({
id: schema.tokenRoutes.id,
modelPattern: schema.tokenRoutes.modelPattern,
displayName: schema.tokenRoutes.displayName,
displayIcon: schema.tokenRoutes.displayIcon,
routingStrategy: schema.tokenRoutes.routingStrategy,
enabled: schema.tokenRoutes.enabled,
}).from(schema.tokenRoutes).all();
: null,
});
}
const channelsByRoute = new Map<number, any[]>();
for (const route of routes) {
if (isExplicitGroupRoute(route)) {
channelsByRoute.set(route.id, route.sourceRouteIds.flatMap((sourceRouteId) => channelsByActualRouteId.get(sourceRouteId) || []));
continue;
}
channelsByRoute.set(route.id, channelsByActualRouteId.get(route.id) || []);
}
return channelsByRoute;
}
async function fetchChannelsForRoutes(routeIds: number[]): Promise<Map<number, any[]>> {
if (routeIds.length === 0) return new Map();
return await fetchChannelsForRouteRows(await listRoutesWithSources()).then((channelsByRoute) => {
const filtered = new Map<number, any[]>();
for (const routeId of routeIds) {
filtered.set(routeId, channelsByRoute.get(routeId) || []);
}
return filtered;
});
}
async function buildRouteChannelSummaryMap(routes: RouteRow[]): Promise<Map<number, RouteChannelSummary>> {
const channelsByRoute = await fetchChannelsForRouteRows(routes);
const summaryByRoute = new Map<number, RouteChannelSummary>();
for (const route of routes) {
const channels = channelsByRoute.get(route.id) || [];
const siteNames = new Set<string>();
let enabledChannelCount = 0;
for (const channel of channels) {
if (channel.enabled) enabledChannelCount += 1;
if (channel.site?.name) siteNames.add(channel.site.name);
}
summaryByRoute.set(route.id, {
channelCount: channels.length,
enabledChannelCount,
siteNames,
});
}
return summaryByRoute;
}
// Route summary (no channel details) for first-screen rendering
app.get('/api/routes/summary', async () => {
const routes = await db.select().from(schema.tokenRoutes).all();
if (routes.length === 0) return [];
const routeIds = routes.map((route) => route.id);
// Aggregate channel counts and site names per route
const channelRows = await db.select({
routeId: schema.routeChannels.routeId,
enabled: schema.routeChannels.enabled,
siteName: schema.sites.name,
}).from(schema.routeChannels)
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(inArray(schema.routeChannels.routeId, routeIds))
.all();
const aggByRoute = new Map<number, { channelCount: number; enabledChannelCount: number; siteNames: Set<string> }>();
for (const row of channelRows) {
let agg = aggByRoute.get(row.routeId);
if (!agg) {
agg = { channelCount: 0, enabledChannelCount: 0, siteNames: new Set() };
aggByRoute.set(row.routeId, agg);
}
agg.channelCount += 1;
if (row.enabled) agg.enabledChannelCount += 1;
if (row.siteName) agg.siteNames.add(row.siteName);
}
return routes.map((route) => {
const agg = aggByRoute.get(route.id);
return {
id: route.id,
modelPattern: route.modelPattern,
displayName: route.displayName ?? null,
export async function tokensRoutes(app: FastifyInstance) {
// List routes with basic info only (lightweight for selectors)
app.get('/api/routes/lite', async () => {
return (await listRoutesWithSources()).map((route) => ({
id: route.id,
modelPattern: route.modelPattern,
displayName: route.displayName,
displayIcon: route.displayIcon,
routeMode: route.routeMode,
sourceRouteIds: route.sourceRouteIds,
routingStrategy: route.routingStrategy,
enabled: route.enabled,
}));
});
// Route summary (no channel details) for first-screen rendering
app.get('/api/routes/summary', async () => {
const routes = await listRoutesWithSources();
if (routes.length === 0) return [];
const aggByRoute = await buildRouteChannelSummaryMap(routes);
return routes.map((route) => {
const agg = aggByRoute.get(route.id);
return {
id: route.id,
modelPattern: route.modelPattern,
displayName: route.displayName ?? null,
displayIcon: route.displayIcon ?? null,
routeMode: route.routeMode,
sourceRouteIds: route.sourceRouteIds,
modelMapping: route.modelMapping ?? null,
routingStrategy: route.routingStrategy ?? 'weighted',
enabled: route.enabled,
@@ -475,26 +667,29 @@ export async function tokensRoutes(app: FastifyInstance) {
});
});
// Get channels for a single route (on-demand loading)
app.get<{ Params: { id: string } }>('/api/routes/:id/channels', async (request, reply) => {
const routeId = parseInt(request.params.id, 10);
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
if (!route) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
const channelsByRoute = await fetchChannelsForRoutes([routeId]);
return channelsByRoute.get(routeId) || [];
});
// Get channels for a single route (on-demand loading)
app.get<{ Params: { id: string } }>('/api/routes/:id/channels', async (request, reply) => {
const routeId = parseInt(request.params.id, 10);
const route = await getRouteWithSources(routeId);
if (!route) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
const channelsByRoute = await fetchChannelsForRouteRows([route]);
return channelsByRoute.get(routeId) || [];
});
// Batch add channels to a route
app.post<{ Params: { id: string }; Body: { channels: Array<{ accountId: number; tokenId?: number; sourceModel?: string }> } }>('/api/routes/:id/channels/batch', async (request, reply) => {
const routeId = parseInt(request.params.id, 10);
const body = request.body;
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
if (!route) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
app.post<{ Params: { id: string }; Body: { channels: Array<{ accountId: number; tokenId?: number; sourceModel?: string }> } }>('/api/routes/:id/channels/batch', async (request, reply) => {
const routeId = parseInt(request.params.id, 10);
const body = request.body;
const route = await getRouteWithSources(routeId);
if (!route) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
if (isExplicitGroupRoute(route)) {
return reply.code(400).send({ success: false, message: '显式群组不支持直接维护通道' });
}
if (!body?.channels || !Array.isArray(body.channels) || body.channels.length === 0) {
return reply.code(400).send({ success: false, message: 'channels 必须是非空数组' });
@@ -555,24 +750,24 @@ export async function tokensRoutes(app: FastifyInstance) {
}
}
if (created > 0) {
await clearRouteDecisionSnapshot(routeId);
invalidateTokenRouterCache();
}
if (created > 0) {
await clearRouteDecisionSnapshot(routeId);
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([routeId]);
invalidateTokenRouterCache();
}
return { success: true, created, skipped, errors };
});
// List all routes
app.get('/api/routes', async () => {
const routes = await db.select().from(schema.tokenRoutes).all();
if (routes.length === 0) return [];
const routeIds = routes.map((route) => route.id);
const channelsByRoute = await fetchChannelsForRoutes(routeIds);
return routes.map((route) => ({
...route,
// List all routes
app.get('/api/routes', async () => {
const routes = await listRoutesWithSources();
if (routes.length === 0) return [];
const channelsByRoute = await fetchChannelsForRouteRows(routes);
return routes.map((route) => ({
...route,
decisionSnapshot: parseRouteDecisionSnapshot(route.decisionSnapshot),
decisionRefreshedAt: route.decisionRefreshedAt ?? null,
channels: channelsByRoute.get(route.id) || [],
@@ -678,87 +873,143 @@ export async function tokensRoutes(app: FastifyInstance) {
return { success: true, decisions };
});
// Create a route
app.post<{ Body: { modelPattern: string; displayName?: string; displayIcon?: string; modelMapping?: string; routingStrategy?: string; enabled?: boolean } }>('/api/routes', async (request) => {
// Create a route
app.post<{ Body: { routeMode?: string; modelPattern?: string; displayName?: string; displayIcon?: string; modelMapping?: string; routingStrategy?: string; enabled?: boolean; sourceRouteIds?: number[] } }>('/api/routes', async (request, reply) => {
const body = request.body;
const routeMode = normalizeRouteMode(body.routeMode);
const displayName = typeof body.displayName === 'string' ? body.displayName.trim() : '';
const sourceRouteIds = normalizeSourceRouteIdsInput(body.sourceRouteIds);
const modelPattern = routeMode === 'explicit_group'
? displayName
: (typeof body.modelPattern === 'string' ? body.modelPattern.trim() : '');
if (routeMode === 'explicit_group') {
if (!displayName) {
return reply.code(400).send({ success: false, message: '显式群组必须填写对外模型名' });
}
const validation = await validateExplicitGroupSourceRoutes(sourceRouteIds);
if (!validation.ok) {
return reply.code(400).send({ success: false, message: validation.message });
}
} else if (!modelPattern) {
return reply.code(400).send({ success: false, message: '模型匹配不能为空' });
}
const insertedRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: body.modelPattern,
displayName: body.displayName,
modelPattern,
displayName: displayName || body.displayName,
displayIcon: body.displayIcon,
routeMode,
modelMapping: body.modelMapping,
routingStrategy: normalizeRouteRoutingStrategy(body.routingStrategy),
enabled: body.enabled ?? true,
}).run();
const routeId = Number(insertedRoute.lastInsertRowid || 0);
if (routeId <= 0) {
return { success: false, message: '创建路由失败' };
}
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
if (!route) {
return { success: false, message: '创建路由失败' };
}
await populateRouteChannelsByModelPattern(route.id, body.modelPattern);
invalidateTokenRouterCache();
return route;
});
// Update a route
app.put<{ Params: { id: string }; Body: any }>('/api/routes/:id', async (request, reply) => {
const id = parseInt(request.params.id, 10);
const body = request.body as Record<string, unknown>;
const existingRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get();
if (!existingRoute) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
const updates: Record<string, unknown> = {};
let nextModelPattern = existingRoute.modelPattern;
if (body.displayName !== undefined) updates.displayName = body.displayName;
if (body.displayIcon !== undefined) updates.displayIcon = body.displayIcon;
if (body.modelPattern !== undefined) {
const routeId = Number(insertedRoute.lastInsertRowid || 0);
if (routeId <= 0) {
return { success: false, message: '创建路由失败' };
}
const route = await getRouteWithSources(routeId);
if (!route) {
return { success: false, message: '创建路由失败' };
}
if (routeMode === 'explicit_group') {
await replaceRouteSourceRouteIds(route.id, sourceRouteIds);
} else {
await populateRouteChannelsByModelPattern(route.id, modelPattern);
}
invalidateTokenRouterCache();
return await getRouteWithSources(routeId);
});
// Update a route
app.put<{ Params: { id: string }; Body: any }>('/api/routes/:id', async (request, reply) => {
const id = parseInt(request.params.id, 10);
const body = request.body as Record<string, unknown>;
const existingRoute = await getRouteWithSources(id);
if (!existingRoute) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
const routeMode = normalizeRouteMode(body.routeMode ?? existingRoute.routeMode);
if (routeMode !== existingRoute.routeMode) {
return reply.code(400).send({ success: false, message: '暂不支持在不同群组模式之间直接切换' });
}
const updates: Record<string, unknown> = {};
let nextModelPattern = existingRoute.modelPattern;
let nextDisplayName = existingRoute.displayName ?? '';
let nextSourceRouteIds = existingRoute.sourceRouteIds;
if (body.displayName !== undefined) {
nextDisplayName = String(body.displayName || '').trim();
updates.displayName = nextDisplayName || null;
}
if (body.displayIcon !== undefined) updates.displayIcon = body.displayIcon;
if (routeMode === 'explicit_group') {
nextModelPattern = nextDisplayName;
updates.modelPattern = nextModelPattern;
if (body.sourceRouteIds !== undefined) {
nextSourceRouteIds = normalizeSourceRouteIdsInput(body.sourceRouteIds);
}
if (!nextDisplayName) {
return reply.code(400).send({ success: false, message: '显式群组必须填写对外模型名' });
}
const validation = await validateExplicitGroupSourceRoutes(nextSourceRouteIds, id);
if (!validation.ok) {
return reply.code(400).send({ success: false, message: validation.message });
}
} else if (body.modelPattern !== undefined) {
nextModelPattern = String(body.modelPattern);
updates.modelPattern = nextModelPattern;
}
if (body.modelMapping !== undefined) updates.modelMapping = body.modelMapping;
if (body.routingStrategy !== undefined) updates.routingStrategy = normalizeRouteRoutingStrategy(body.routingStrategy);
if (body.enabled !== undefined) updates.enabled = body.enabled;
if (body.routeMode !== undefined) updates.routeMode = routeMode;
updates.updatedAt = new Date().toISOString();
await db.update(schema.tokenRoutes).set(updates).where(eq(schema.tokenRoutes.id, id)).run();
const modelPatternChanged = body.modelPattern !== undefined && nextModelPattern !== existingRoute.modelPattern;
if (routeMode === 'explicit_group' && body.sourceRouteIds !== undefined) {
await replaceRouteSourceRouteIds(id, nextSourceRouteIds);
}
const modelPatternChanged = nextModelPattern !== existingRoute.modelPattern;
const routeBehaviorChanged = modelPatternChanged
|| (routeMode === 'explicit_group' && body.sourceRouteIds !== undefined)
|| body.modelMapping !== undefined
|| body.routingStrategy !== undefined
|| body.enabled !== undefined;
if (modelPatternChanged) {
await rebuildAutomaticRouteChannelsByModelPattern(id, nextModelPattern);
}
if (routeBehaviorChanged) {
await clearRouteDecisionSnapshot(id);
}
invalidateTokenRouterCache();
return await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get();
});
if (routeMode === 'pattern' && modelPatternChanged) {
await rebuildAutomaticRouteChannelsByModelPattern(id, nextModelPattern);
}
if (routeBehaviorChanged) {
await clearRouteDecisionSnapshot(id);
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([id]);
}
invalidateTokenRouterCache();
return await getRouteWithSources(id);
});
// Delete a route
app.delete<{ Params: { id: string } }>('/api/routes/:id', async (request) => {
const id = parseInt(request.params.id, 10);
await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).run();
invalidateTokenRouterCache();
return { success: true };
});
app.delete<{ Params: { id: string } }>('/api/routes/:id', async (request) => {
const id = parseInt(request.params.id, 10);
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([id]);
await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).run();
invalidateTokenRouterCache();
return { success: true };
});
// Add a channel to a route
app.post<{ Params: { id: string }; Body: { accountId: number; tokenId?: number; sourceModel?: string; priority?: number; weight?: number } }>('/api/routes/:id/channels', async (request, reply) => {
const routeId = parseInt(request.params.id, 10);
const body = request.body;
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
if (!route) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
app.post<{ Params: { id: string }; Body: { accountId: number; tokenId?: number; sourceModel?: string; priority?: number; weight?: number } }>('/api/routes/:id/channels', async (request, reply) => {
const routeId = parseInt(request.params.id, 10);
const body = request.body;
const route = await getRouteWithSources(routeId);
if (!route) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
if (isExplicitGroupRoute(route)) {
return reply.code(400).send({ success: false, message: '显式群组不支持直接维护通道' });
}
const sourceModel = typeof body.sourceModel === 'string'
? body.sourceModel.trim()
@@ -800,11 +1051,12 @@ export async function tokensRoutes(app: FastifyInstance) {
const created = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
if (!created) {
return reply.code(500).send({ success: false, message: '创建通道失败' });
}
await clearRouteDecisionSnapshot(routeId);
invalidateTokenRouterCache();
return created;
});
}
await clearRouteDecisionSnapshot(routeId);
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([routeId]);
invalidateTokenRouterCache();
return created;
});
// Batch update channel priorities
app.put<{ Body: { updates: Array<{ id: number; priority: number }> } }>('/api/channels/batch', async (request, reply) => {
@@ -830,13 +1082,14 @@ export async function tokensRoutes(app: FastifyInstance) {
}).where(eq(schema.routeChannels.id, update.id)).run();
}
const updatedChannels = await db.select().from(schema.routeChannels)
.where(inArray(schema.routeChannels.id, channelIds))
.all();
await clearRouteDecisionSnapshots(existingChannels.map((channel) => channel.routeId));
invalidateTokenRouterCache();
return { success: true, channels: updatedChannels };
});
const updatedChannels = await db.select().from(schema.routeChannels)
.where(inArray(schema.routeChannels.id, channelIds))
.all();
await clearRouteDecisionSnapshots(existingChannels.map((channel) => channel.routeId));
await clearDependentExplicitGroupSnapshotsBySourceRouteIds(existingChannels.map((channel) => channel.routeId));
invalidateTokenRouterCache();
return { success: true, channels: updatedChannels };
});
// Update a channel
app.put<{ Params: { channelId: string }; Body: any }>('/api/channels/:channelId', async (request, reply) => {
@@ -878,23 +1131,25 @@ export async function tokensRoutes(app: FastifyInstance) {
if (body[key] !== undefined) updates[key] = body[key];
}
await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run();
await clearRouteDecisionSnapshot(channel.routeId);
invalidateTokenRouterCache();
return await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
});
await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run();
await clearRouteDecisionSnapshot(channel.routeId);
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([channel.routeId]);
invalidateTokenRouterCache();
return await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
});
// Delete a channel
app.delete<{ Params: { channelId: string } }>('/api/channels/:channelId', async (request) => {
const channelId = parseInt(request.params.channelId, 10);
const channel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).run();
if (channel) {
await clearRouteDecisionSnapshot(channel.routeId);
}
invalidateTokenRouterCache();
return { success: true };
});
await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).run();
if (channel) {
await clearRouteDecisionSnapshot(channel.routeId);
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([channel.routeId]);
}
invalidateTokenRouterCache();
return { success: true };
});
// Rebuild routes/channels from model availability.
app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => {
@@ -0,0 +1,248 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const fetchMock = vi.fn();
const selectChannelMock = vi.fn();
const selectNextChannelMock = vi.fn();
const recordSuccessMock = vi.fn();
const recordFailureMock = vi.fn();
const refreshModelsAndRebuildRoutesMock = vi.fn();
const reportProxyAllFailedMock = vi.fn();
const reportTokenExpiredMock = vi.fn();
const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => ({
...usage,
estimatedCostFromQuota: 0,
recoveredFromSelfLog: false,
}));
const dbInsertMock = vi.fn((_arg?: any) => ({
values: () => ({
run: () => undefined,
}),
}));
vi.mock('undici', () => ({
fetch: (...args: unknown[]) => fetchMock(...args),
}));
vi.mock('../../services/tokenRouter.js', () => ({
tokenRouter: {
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
recordSuccess: (...args: unknown[]) => recordSuccessMock(...args),
recordFailure: (...args: unknown[]) => recordFailureMock(...args),
},
}));
vi.mock('../../services/modelService.js', () => ({
refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args),
}));
vi.mock('../../services/alertService.js', () => ({
reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args),
reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args),
}));
vi.mock('../../services/alertRules.js', () => ({
isTokenExpiredError: ({ status }: { status?: number }) => status === 401,
}));
vi.mock('../../services/modelPricingService.js', () => ({
estimateProxyCost: async () => 0,
buildProxyBillingDetails: async () => null,
fetchModelPricingCatalog: async () => null,
}));
vi.mock('../../services/proxyRetryPolicy.js', () => ({
shouldRetryProxyRequest: () => false,
}));
vi.mock('../../services/proxyUsageFallbackService.js', () => ({
resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg),
}));
vi.mock('../../db/index.js', () => ({
db: {
insert: (arg: any) => dbInsertMock(arg),
},
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
schema: {
proxyLogs: {},
},
}));
describe('chat proxy codex oauth compatibility', () => {
let app: FastifyInstance;
const createSseResponse = (chunks: string[], status = 200) => {
const encoder = new TextEncoder();
return new Response(new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
}), {
status,
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
});
};
beforeAll(async () => {
const { chatProxyRoute, claudeMessagesProxyRoute } = await import('./chat.js');
app = Fastify();
await app.register(chatProxyRoute);
await app.register(claudeMessagesProxyRoute);
});
beforeEach(() => {
fetchMock.mockReset();
selectChannelMock.mockReset();
selectNextChannelMock.mockReset();
recordSuccessMock.mockReset();
recordFailureMock.mockReset();
refreshModelsAndRebuildRoutesMock.mockReset();
reportProxyAllFailedMock.mockReset();
reportTokenExpiredMock.mockReset();
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
dbInsertMock.mockClear();
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'codex-site', url: 'https://chatgpt.com/backend-api/codex', platform: 'codex' },
account: {
id: 33,
username: 'codex-user@example.com',
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-123',
email: 'codex-user@example.com',
planType: 'team',
},
}),
},
tokenName: 'default',
tokenValue: 'oauth-access-token',
actualModel: 'gpt-5.4',
});
selectNextChannelMock.mockReturnValue(null);
});
afterAll(async () => {
await app.close();
});
it('strips codex-unsupported responses fields and aggregates the SSE result for /v1/messages callers', async () => {
fetchMock.mockResolvedValue(createSseResponse([
'event: response.created\n',
'data: {"type":"response.created","response":{"id":"resp_codex_claude","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
'event: response.output_item.added\n',
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_claude","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n',
'event: response.output_text.delta\n',
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_claude","delta":"pong from codex"}\n\n',
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_codex_claude","model":"gpt-5.4","status":"completed","usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12}}}\n\n',
'data: [DONE]\n\n',
]));
const response = await app.inject({
method: 'POST',
url: '/v1/messages',
payload: {
model: 'gpt-5.4',
max_tokens: 256,
messages: [{ role: 'user', content: 'hello codex' }],
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, options] = fetchMock.mock.calls[0] as [string, any];
const forwardedBody = JSON.parse(options.body);
expect(forwardedBody.stream).toBe(true);
expect(forwardedBody.instructions).toBe('');
expect(forwardedBody.store).toBe(false);
expect(forwardedBody.parallel_tool_calls).toBe(true);
expect(forwardedBody.include).toEqual(['reasoning.encrypted_content']);
expect(forwardedBody.max_output_tokens).toBeUndefined();
expect(forwardedBody.max_completion_tokens).toBeUndefined();
const body = response.json();
expect(body.type).toBe('message');
expect(body.role).toBe('assistant');
expect(body.model).toBe('gpt-5.4');
expect(body.content?.[0]?.type).toBe('text');
expect(body.content?.[0]?.text).toContain('pong from codex');
});
it('translates codex SSE into Claude messages stream events instead of leaking raw response events', async () => {
fetchMock.mockResolvedValue(createSseResponse([
'event: response.created\n',
'data: {"type":"response.created","response":{"id":"resp_codex_claude_stream","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
'event: response.output_item.added\n',
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_claude_stream","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n',
'event: response.content_part.added\n',
'data: {"type":"response.content_part.added","output_index":0,"content_index":0,"item_id":"msg_codex_claude_stream","part":{"type":"output_text","text":""}}\n\n',
'event: response.output_text.delta\n',
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_claude_stream","content_index":0,"delta":"pong from codex"}\n\n',
'event: response.content_part.done\n',
'data: {"type":"response.content_part.done","output_index":0,"content_index":0,"item_id":"msg_codex_claude_stream","part":{"type":"output_text","text":"pong from codex"}}\n\n',
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_codex_claude_stream","model":"gpt-5.4","status":"completed","usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12}}}\n\n',
'data: [DONE]\n\n',
]));
const response = await app.inject({
method: 'POST',
url: '/v1/messages',
payload: {
model: 'gpt-5.4',
stream: true,
max_tokens: 256,
messages: [{ role: 'user', content: 'hello codex' }],
},
});
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toContain('text/event-stream');
expect(response.body).toContain('event: message_start');
expect(response.body).toContain('event: content_block_delta');
expect(response.body).toContain('pong from codex');
expect(response.body).not.toContain('event: response.created');
expect(response.body).not.toContain('"type":"response.output_text.delta"');
});
it('translates codex SSE into OpenAI chat completion chunks instead of leaking raw response events', async () => {
fetchMock.mockResolvedValue(createSseResponse([
'event: response.created\n',
'data: {"type":"response.created","response":{"id":"resp_codex_openai_stream","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
'event: response.output_item.added\n',
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_openai_stream","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n',
'event: response.output_text.delta\n',
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_openai_stream","delta":"pong from codex"}\n\n',
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_codex_openai_stream","model":"gpt-5.4","status":"completed","usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12}}}\n\n',
'data: [DONE]\n\n',
]));
const response = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'gpt-5.4',
stream: true,
messages: [{ role: 'user', content: 'hello codex' }],
},
});
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toContain('text/event-stream');
expect(response.body).toContain('"object":"chat.completion.chunk"');
expect(response.body).toContain('pong from codex');
expect(response.body).not.toContain('event: response.created');
expect(response.body).not.toContain('"type":"response.output_text.delta"');
});
});
@@ -0,0 +1,157 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const fetchMock = vi.fn();
const selectChannelMock = vi.fn();
const selectNextChannelMock = vi.fn();
const recordSuccessMock = vi.fn();
const recordFailureMock = vi.fn();
const refreshModelsAndRebuildRoutesMock = vi.fn();
const reportProxyAllFailedMock = vi.fn();
const reportTokenExpiredMock = vi.fn();
const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => ({
...usage,
estimatedCostFromQuota: 0,
recoveredFromSelfLog: false,
}));
const dbInsertMock = vi.fn((_arg?: any) => ({
values: () => ({
run: () => undefined,
}),
}));
vi.mock('undici', () => ({
fetch: (...args: unknown[]) => fetchMock(...args),
}));
vi.mock('../../services/tokenRouter.js', () => ({
tokenRouter: {
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
recordSuccess: (...args: unknown[]) => recordSuccessMock(...args),
recordFailure: (...args: unknown[]) => recordFailureMock(...args),
},
}));
vi.mock('../../services/modelService.js', () => ({
refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args),
}));
vi.mock('../../services/alertService.js', () => ({
reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args),
reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args),
}));
vi.mock('../../services/alertRules.js', () => ({
isTokenExpiredError: () => false,
}));
vi.mock('../../services/modelPricingService.js', () => ({
estimateProxyCost: async () => 0,
buildProxyBillingDetails: async () => null,
fetchModelPricingCatalog: async () => null,
}));
vi.mock('../../services/proxyRetryPolicy.js', () => ({
shouldRetryProxyRequest: () => false,
}));
vi.mock('../../services/proxyUsageFallbackService.js', () => ({
resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg),
}));
vi.mock('../../services/oauth/quota.js', () => ({
recordOauthQuotaResetHint: async () => undefined,
}));
vi.mock('../../db/index.js', () => ({
db: {
insert: (arg: any) => dbInsertMock(arg),
},
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
schema: {
proxyLogs: {},
},
}));
describe('claude count_tokens proxy route', () => {
let app: FastifyInstance;
beforeAll(async () => {
const { claudeMessagesProxyRoute } = await import('./chat.js');
app = Fastify();
await app.register(claudeMessagesProxyRoute);
});
beforeEach(() => {
fetchMock.mockReset();
selectChannelMock.mockReset();
selectNextChannelMock.mockReset();
recordSuccessMock.mockReset();
recordFailureMock.mockReset();
refreshModelsAndRebuildRoutesMock.mockReset();
reportProxyAllFailedMock.mockReset();
reportTokenExpiredMock.mockReset();
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
dbInsertMock.mockClear();
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'claude-site', url: 'https://api.anthropic.com', platform: 'claude' },
account: { id: 33, username: 'claude-user@example.com' },
tokenName: 'default',
tokenValue: 'sk-claude',
actualModel: 'claude-opus-4-6',
});
selectNextChannelMock.mockReturnValue(null);
});
afterAll(async () => {
await app.close();
});
it('forwards /v1/messages/count_tokens to the claude count_tokens upstream path', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
input_tokens: 42,
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/messages/count_tokens',
payload: {
model: 'claude-opus-4-6',
tools: [
{ name: 'lookup', input_schema: { type: 'object' } },
],
messages: [
{
role: 'user',
content: [{ type: 'text', text: 'count these tokens' }],
},
],
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ input_tokens: 42 });
expect(fetchMock).toHaveBeenCalledTimes(1);
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
expect(targetUrl).toBe('https://api.anthropic.com/v1/messages/count_tokens?beta=true');
expect(options.headers['anthropic-version']).toBe('2023-06-01');
expect(options.headers['anthropic-beta']).toContain('claude-code-20250219');
expect(options.headers['Accept-Encoding']).toBe('gzip, deflate, br, zstd');
const forwardedBody = JSON.parse(String(options.body));
expect(forwardedBody.model).toBe('claude-opus-4-6');
expect(forwardedBody.messages).toEqual([
{
role: 'user',
content: [{ type: 'text', text: 'count these tokens', cache_control: { type: 'ephemeral' } }],
},
]);
});
});
+19 -2
View File
@@ -1384,6 +1384,15 @@ describe('chat proxy stream behavior', () => {
status: 400,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
error: {
message: 'request validation failed',
type: 'invalid_request_error',
},
}), {
status: 400,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'resp_retry_minimal_headers',
object: 'response',
@@ -1413,21 +1422,27 @@ describe('chat proxy stream behavior', () => {
const body = response.json();
expect(body.output_text).toContain('ok after minimal headers retry');
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(fetchMock).toHaveBeenCalledTimes(4);
const [, firstOptions] = fetchMock.mock.calls[0] as [string, any];
const [, secondOptions] = fetchMock.mock.calls[1] as [string, any];
const [, thirdOptions] = fetchMock.mock.calls[2] as [string, any];
const [, fourthOptions] = fetchMock.mock.calls[3] as [string, any];
expect(firstOptions.headers['openai-beta']).toBe('responses-2025-03-11');
expect(secondOptions.headers['openai-beta']).toBe('responses-2025-03-11');
expect(thirdOptions.headers['openai-beta']).toBeUndefined();
expect(thirdOptions.headers['openai-beta']).toBe('responses-2025-03-11');
expect(fourthOptions.headers['openai-beta']).toBeUndefined();
const firstBody = JSON.parse(firstOptions.body);
const secondBody = JSON.parse(secondOptions.body);
const thirdBody = JSON.parse(thirdOptions.body);
const fourthBody = JSON.parse(fourthOptions.body);
expect(firstBody.user).toBe('user-123');
expect(secondBody.user).toBeUndefined();
expect(thirdBody.user).toBeUndefined();
expect(secondBody.include).toEqual(['reasoning.encrypted_content']);
expect(thirdBody.include).toBeUndefined();
expect(fourthBody.user).toBeUndefined();
});
it('returns concise Cloudflare host error on /v1/responses 502 html failures', async () => {
@@ -3193,6 +3208,8 @@ describe('chat proxy stream behavior', () => {
expect(response.statusCode).toBe(200);
expect(response.body).toContain('"finish_reason":"error"');
expect(response.body).toContain('[DONE]');
expect(recordSuccessMock).not.toHaveBeenCalled();
expect(recordFailureMock).toHaveBeenCalledTimes(1);
});
it('preserves non-stream function_call output when /v1/chat/completions falls back to /v1/responses', async () => {
+441 -32
View File
@@ -1,7 +1,6 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { tokenRouter } from '../../services/tokenRouter.js';
import { db, schema } from '../../db/index.js';
import { fetch } from 'undici';
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
import { isTokenExpiredError } from '../../services/alertRules.js';
@@ -12,6 +11,7 @@ import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../se
import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js';
import { type DownstreamFormat } from '../../transformers/shared/normalized.js';
import {
buildClaudeCountTokensUpstreamRequest,
buildUpstreamEndpointRequest,
resolveUpstreamEndpointCandidates,
} from './upstreamEndpoint.js';
@@ -21,21 +21,41 @@ import {
recordDownstreamCostUsage,
} from './downstreamPolicy.js';
import { composeProxyLogMessage } from './logPathMeta.js';
import { executeEndpointFlow } from './endpointFlow.js';
import { executeEndpointFlow, type BuiltEndpointRequest } from './endpointFlow.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from './proxyBilling.js';
import { openAiChatTransformer } from '../../transformers/openai/chat/index.js';
import { anthropicMessagesTransformer } from '../../transformers/anthropic/messages/index.js';
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
import { getProxyResourceOwner } from '../../middleware/auth.js';
import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js';
import {
ProxyInputFileResolutionError,
hasNonImageFileInputInOpenAiBody,
resolveOpenAiBodyInputFiles,
} from '../../services/proxyInputFileResolver.js';
import {
buildOauthProviderHeaders,
} from '../../services/oauth/service.js';
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js';
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js';
import { collectResponsesFinalPayloadFromSse } from './responsesSseFinal.js';
import {
createGeminiCliStreamReader,
unwrapGeminiCliPayload,
} from './geminiCliCompat.js';
import { dispatchRuntimeRequest } from './runtimeExecutor.js';
const MAX_RETRIES = 2;
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
export async function chatProxyRoute(app: FastifyInstance) {
app.post('/v1/chat/completions', async (request: FastifyRequest, reply: FastifyReply) =>
handleChatProxyRequest(request, reply, 'openai'));
@@ -44,6 +64,8 @@ export async function chatProxyRoute(app: FastifyInstance) {
export async function claudeMessagesProxyRoute(app: FastifyInstance) {
app.post('/v1/messages', async (request: FastifyRequest, reply: FastifyReply) =>
handleChatProxyRequest(request, reply, 'claude'));
app.post('/v1/messages/count_tokens', async (request: FastifyRequest, reply: FastifyReply) =>
handleClaudeCountTokensRequest(request, reply));
}
async function handleChatProxyRequest(
@@ -87,6 +109,15 @@ async function handleChatProxyRequest(
}
}
const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(resolvedOpenAiBody);
const codexSessionCacheKey = deriveCodexSessionCacheKey({
downstreamFormat,
body: downstreamFormat === 'claude' ? claudeOriginalBody : request.body,
requestedModel,
proxyToken: getProxyAuthContext(request)?.token || null,
});
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
const logDownstreamApiKeyId = downstreamApiKeyId !== null
&& await hasProxyLogDownstreamApiKeyIdColumn();
const excludeChannelIds: number[] = [];
let retryCount = 0;
@@ -114,6 +145,8 @@ async function handleChatProxyRequest(
excludeChannelIds.push(selected.channel.id);
const modelName = selected.actualModel || requestedModel;
const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
const isCodexSite = String(selected.site.platform || '').trim().toLowerCase() === 'codex';
const endpointCandidates = [
...await resolveUpstreamEndpointCandidates(
{
@@ -128,15 +161,24 @@ async function handleChatProxyRequest(
},
),
];
const buildProviderHeaders = () => (
buildOauthProviderHeaders({
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
downstreamHeaders: request.headers as Record<string, unknown>,
})
);
const buildEndpointRequest = (
endpoint: 'chat' | 'messages' | 'responses',
options: { forceNormalizeClaudeBody?: boolean } = {},
) => {
const upstreamStream = isStream || (isCodexSite && endpoint === 'responses');
const endpointRequest = buildUpstreamEndpointRequest({
endpoint,
modelName,
stream: isStream,
stream: upstreamStream,
tokenValue: selected.tokenValue,
oauthProvider: oauth?.provider,
oauthProjectId: oauth?.projectId,
sitePlatform: selected.site.platform,
siteUrl: selected.site.url,
openaiBody: resolvedOpenAiBody,
@@ -144,34 +186,73 @@ async function handleChatProxyRequest(
claudeOriginalBody,
forceNormalizeClaudeBody: options.forceNormalizeClaudeBody,
downstreamHeaders: request.headers as Record<string, unknown>,
providerHeaders: buildProviderHeaders(),
codexSessionCacheKey,
});
return {
endpoint,
path: endpointRequest.path,
headers: endpointRequest.headers,
body: endpointRequest.body as Record<string, unknown>,
runtime: endpointRequest.runtime,
};
};
const dispatchRequest = (
compatibilityRequest: BuiltEndpointRequest,
targetUrl?: string,
) => (
dispatchRuntimeRequest({
siteUrl: selected.site.url,
targetUrl,
request: compatibilityRequest,
buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: requestForFetch.headers,
body: JSON.stringify(requestForFetch.body),
}),
})
);
const endpointStrategy = downstreamTransformer.compatibility.createEndpointStrategy({
downstreamFormat,
endpointCandidates,
modelName,
requestedModelHint: requestedModel,
sitePlatform: selected.site.platform,
isStream,
isStream: isStream || isCodexSite,
buildRequest: ({ endpoint, forceNormalizeClaudeBody }) => buildEndpointRequest(
endpoint,
{ forceNormalizeClaudeBody },
),
dispatchRequest: (compatibilityRequest, targetUrl) => fetch(
targetUrl ?? `${selected.site.url}${compatibilityRequest.path}`,
withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: compatibilityRequest.headers,
body: JSON.stringify(compatibilityRequest.body),
}, getProxyUrlFromExtraConfig(selected.account.extraConfig)),
),
dispatchRequest,
});
const tryRecover = async (ctx: Parameters<NonNullable<typeof endpointStrategy.tryRecover>>[0]) => {
if (ctx.response.status === 401 && oauth) {
try {
const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id);
selected.tokenValue = refreshed.accessToken;
selected.account = {
...selected.account,
accessToken: refreshed.accessToken,
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
};
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint);
const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`;
const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl);
if (refreshedResponse.ok) {
return {
upstream: refreshedResponse,
upstreamPath: refreshedRequest.path,
};
}
ctx.request = refreshedRequest;
ctx.response = refreshedResponse;
ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error');
} catch {
return endpointStrategy.tryRecover(ctx);
}
}
return endpointStrategy.tryRecover(ctx);
};
let startTime = Date.now();
try {
@@ -180,7 +261,8 @@ async function handleChatProxyRequest(
proxyUrl: resolveChannelProxyUrl(selected.site, selected.account.extraConfig),
endpointCandidates,
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
tryRecover: endpointStrategy.tryRecover,
dispatchRequest,
tryRecover,
shouldDowngrade: endpointStrategy.shouldDowngrade,
onDowngrade: (ctx) => {
logProxy(
@@ -192,20 +274,22 @@ async function handleChatProxyRequest(
ctx.errText,
retryCount,
downstreamPath,
0,
0,
0,
0,
null,
null,
clientContext,
);
},
0,
0,
0,
0,
null,
null,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
},
});
if (!endpointResult.ok) {
const status = endpointResult.status || 502;
const errText = endpointResult.errText || 'unknown error';
const rawErrText = endpointResult.rawErrText || errText;
tokenRouter.recordFailure(selected.channel.id);
logProxy(
selected,
@@ -223,7 +307,13 @@ async function handleChatProxyRequest(
null,
null,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
await recordOauthQuotaResetHint({
accountId: selected.account.id,
statusCode: status,
errorText: rawErrText,
});
if (isTokenExpiredError({ status, message: errText })) {
await reportTokenExpired({
@@ -277,6 +367,7 @@ async function handleChatProxyRequest(
const streamSession = openAiChatTransformer.proxyStream.createSession({
downstreamFormat,
modelName,
successfulUpstreamPath,
onParsedPayload: (payload) => {
if (payload && typeof payload === 'object') {
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload));
@@ -297,9 +388,35 @@ async function handleChatProxyRequest(
} catch {
fallbackData = fallbackText;
}
streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw);
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
fallbackData = unwrapGeminiCliPayload(fallbackData);
}
const streamResult = streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw);
const latency = Date.now() - startTime;
if (streamResult.status === 'failed') {
tokenRouter.recordFailure(selected.channel.id);
logProxy(
selected,
requestedModel,
'failed',
200,
latency,
streamResult.errorMessage,
retryCount,
downstreamPath,
parsedUsage.promptTokens,
parsedUsage.completionTokens,
parsedUsage.totalTokens,
0,
null,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
return;
}
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
site: selected.site,
account: selected.account,
@@ -341,14 +458,42 @@ async function handleChatProxyRequest(
estimatedCost,
billingDetails,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
return;
}
const reader = upstream.body?.getReader();
await streamSession.run(reader, reply.raw);
const upstreamReader = upstream.body?.getReader();
const reader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader
? createGeminiCliStreamReader(upstreamReader)
: upstreamReader;
const streamResult = await streamSession.run(reader, reply.raw);
const latency = Date.now() - startTime;
if (streamResult.status === 'failed') {
tokenRouter.recordFailure(selected.channel.id);
logProxy(
selected,
requestedModel,
'failed',
200,
latency,
streamResult.errorMessage,
retryCount,
downstreamPath,
parsedUsage.promptTokens,
parsedUsage.completionTokens,
parsedUsage.totalTokens,
0,
null,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
return;
}
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
site: selected.site,
account: selected.account,
@@ -390,16 +535,30 @@ async function handleChatProxyRequest(
estimatedCost,
billingDetails,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
return;
}
const rawText = await upstream.text();
let upstreamData: unknown = rawText;
try {
upstreamData = JSON.parse(rawText);
} catch {
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
let rawText = '';
let upstreamData: unknown;
if (upstreamContentType.includes('text/event-stream') && successfulUpstreamPath.endsWith('/responses')) {
const collected = await collectResponsesFinalPayloadFromSse(upstream, modelName);
rawText = collected.rawText;
upstreamData = collected.payload;
} else {
rawText = await upstream.text();
upstreamData = rawText;
try {
upstreamData = JSON.parse(rawText);
} catch {
upstreamData = rawText;
}
}
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
upstreamData = unwrapGeminiCliPayload(upstreamData);
}
const latency = Date.now() - startTime;
@@ -448,6 +607,8 @@ async function handleChatProxyRequest(
estimatedCost,
billingDetails,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
return reply.send(downstreamResponse);
@@ -469,6 +630,7 @@ async function handleChatProxyRequest(
null,
null,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
if (retryCount < MAX_RETRIES) {
@@ -491,6 +653,251 @@ async function handleChatProxyRequest(
}
}
function deriveCodexSessionCacheKey(input: {
downstreamFormat: DownstreamFormat | 'responses';
body: unknown;
requestedModel: string;
proxyToken: string | null;
}): string | null {
if (isRecord(input.body)) {
if (input.downstreamFormat === 'claude' && isRecord(input.body.metadata)) {
const userId = asTrimmedString(input.body.metadata.user_id);
if (userId) return `${input.requestedModel}:claude:${userId}`;
}
const promptCacheKey = asTrimmedString(input.body.prompt_cache_key);
if (promptCacheKey) return `${input.requestedModel}:responses:${promptCacheKey}`;
}
const proxyToken = asTrimmedString(input.proxyToken);
if (proxyToken) {
return `${input.requestedModel}:proxy:${proxyToken}`;
}
return null;
}
async function handleClaudeCountTokensRequest(
request: FastifyRequest,
reply: FastifyReply,
) {
const rawBody = isRecord(request.body) ? { ...request.body } : null;
if (!rawBody) {
return reply.code(400).send({
error: {
message: 'Request body must be a JSON object',
type: 'invalid_request_error',
},
});
}
const requestedModel = asTrimmedString(rawBody.model);
if (!requestedModel) {
return reply.code(400).send({
error: {
message: 'model is required',
type: 'invalid_request_error',
},
});
}
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPath = '/v1/messages/count_tokens';
const clientContext = detectDownstreamClientContext({
downstreamPath,
headers: request.headers as Record<string, unknown>,
body: rawBody,
});
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
const logDownstreamApiKeyId = downstreamApiKeyId !== null
&& await hasProxyLogDownstreamApiKeyIdColumn();
const excludeChannelIds: number[] = [];
let retryCount = 0;
while (retryCount <= MAX_RETRIES) {
let selected = retryCount === 0
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
if (!selected && retryCount === 0) {
await refreshModelsAndRebuildRoutes();
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
}
if (!selected) {
await reportProxyAllFailed({
model: requestedModel,
reason: 'No available channels after retries',
});
return reply.code(503).send({
error: { message: 'No available channels for this model', type: 'server_error' },
});
}
excludeChannelIds.push(selected.channel.id);
if (String(selected.site.platform || '').trim().toLowerCase() !== 'claude') {
if (retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
}
return reply.code(501).send({
error: {
message: 'Claude count_tokens compatibility is not implemented for this upstream',
type: 'invalid_request_error',
},
});
}
const modelName = selected.actualModel || requestedModel;
const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
const startTime = Date.now();
const buildRequest = () => {
const upstreamRequest = buildClaudeCountTokensUpstreamRequest({
modelName,
tokenValue: selected.tokenValue,
oauthProvider: oauth?.provider,
sitePlatform: selected.site.platform,
claudeBody: rawBody,
downstreamHeaders: request.headers as Record<string, unknown>,
});
return {
endpoint: 'messages' as const,
path: upstreamRequest.path,
headers: upstreamRequest.headers,
body: upstreamRequest.body,
runtime: upstreamRequest.runtime,
};
};
try {
let upstreamRequest = buildRequest();
let upstream = await dispatchRuntimeRequest({
siteUrl: selected.site.url,
request: upstreamRequest,
buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: requestForFetch.headers,
body: JSON.stringify(requestForFetch.body),
}),
});
if (upstream.status === 401 && oauth) {
try {
const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id);
selected.tokenValue = refreshed.accessToken;
selected.account = {
...selected.account,
accessToken: refreshed.accessToken,
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
};
upstreamRequest = buildRequest();
upstream = await dispatchRuntimeRequest({
siteUrl: selected.site.url,
request: upstreamRequest,
buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: requestForFetch.headers,
body: JSON.stringify(requestForFetch.body),
}),
});
} catch {
// Fall through to the regular upstream error handling below.
}
}
const latency = Date.now() - startTime;
const contentType = upstream.headers.get('content-type') || 'application/json';
const text = await upstream.text();
let payload: unknown = text;
try {
payload = JSON.parse(text);
} catch {
payload = text;
}
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(
selected,
requestedModel,
'failed',
upstream.status,
latency,
typeof payload === 'string' ? payload : JSON.stringify(payload),
retryCount,
downstreamPath,
0,
0,
0,
0,
null,
upstreamRequest.path,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
if (shouldRetryProxyRequest(upstream.status, typeof payload === 'string' ? payload : text) && retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
}
return reply.code(upstream.status).type(contentType).send(payload);
}
tokenRouter.recordSuccess(selected.channel.id, latency, 0);
recordDownstreamCostUsage(request, 0);
logProxy(
selected,
requestedModel,
'success',
upstream.status,
latency,
null,
retryCount,
downstreamPath,
0,
0,
0,
0,
null,
upstreamRequest.path,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
return reply.code(upstream.status).type(contentType).send(payload);
} catch (error: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(
selected,
requestedModel,
'failed',
0,
Date.now() - startTime,
error?.message || 'network error',
retryCount,
downstreamPath,
0,
0,
0,
0,
null,
null,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
}
return reply.code(502).send({
error: {
message: `Upstream error: ${error?.message || 'network failure'}`,
type: 'upstream_error',
},
});
}
}
}
async function logProxy(
selected: any,
modelRequested: string,
@@ -507,6 +914,7 @@ async function logProxy(
billingDetails: unknown = null,
upstreamPath: string | null = null,
clientContext: DownstreamClientContext | null = null,
downstreamApiKeyId: number | null = null,
) {
try {
const createdAt = formatUtcSqlDateTime(new Date());
@@ -524,6 +932,7 @@ async function logProxy(
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
modelRequested,
modelActual: selected.actualModel,
status,
+11 -5
View File
@@ -1,6 +1,6 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { fetch } from 'undici';
import { db, schema } from '../../db/index.js';
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
import { tokenRouter } from '../../services/tokenRouter.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
@@ -14,6 +14,7 @@ import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js
import { composeProxyLogMessage } from './logPathMeta.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from './proxyBilling.js';
import { getProxyAuthContext } from '../../middleware/auth.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
const MAX_RETRIES = 2;
@@ -27,6 +28,9 @@ export async function completionsProxyRoute(app: FastifyInstance) {
}
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
const logDownstreamApiKeyId = downstreamApiKeyId !== null
&& await hasProxyLogDownstreamApiKeyIdColumn();
const isStream = body.stream === true;
const excludeChannelIds: number[] = [];
@@ -71,7 +75,7 @@ export async function completionsProxyRoute(app: FastifyInstance) {
if (!upstream.ok) {
const errText = await upstream.text().catch(() => 'unknown error');
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, errText, retryCount);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, errText, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
if (isTokenExpiredError({ status: upstream.status, message: errText })) {
await reportTokenExpired({
@@ -172,7 +176,7 @@ export async function completionsProxyRoute(app: FastifyInstance) {
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount,
selected, requestedModel, 'success', 200, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
);
return;
@@ -207,13 +211,13 @@ export async function completionsProxyRoute(app: FastifyInstance) {
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount,
selected, requestedModel, 'success', 200, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
);
return reply.send(data);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
@@ -238,6 +242,7 @@ async function logProxy(
latencyMs: number,
errorMessage: string | null,
retryCount: number,
downstreamApiKeyId: number | null = null,
promptTokens = 0,
completionTokens = 0,
totalTokens = 0,
@@ -254,6 +259,7 @@ async function logProxy(
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
modelRequested,
modelActual: selected.actualModel,
status,
+10 -31
View File
@@ -1,6 +1,6 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { fetch } from 'undici';
import { db, schema } from '../../db/index.js';
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
import { tokenRouter } from '../../services/tokenRouter.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
@@ -14,6 +14,7 @@ import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js
import { composeProxyLogMessage } from './logPathMeta.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from './proxyBilling.js';
import { getProxyAuthContext } from '../../middleware/auth.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
const MAX_RETRIES = 2;
@@ -27,6 +28,9 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
}
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
const logDownstreamApiKeyId = downstreamApiKeyId !== null
&& await hasProxyLogDownstreamApiKeyIdColumn();
const excludeChannelIds: number[] = [];
let retryCount = 0;
@@ -68,7 +72,7 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
const text = await upstream.text();
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
if (isTokenExpiredError({ status: upstream.status, message: text })) {
await reportTokenExpired({
@@ -91,43 +95,16 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
return reply.code(upstream.status).send({ error: { message: text, type: 'upstream_error' } });
}
let data: any = {};
try { data = JSON.parse(text); } catch { data = {}; }
const latency = Date.now() - startTime;
const parsedUsage = parseProxyUsage(data);
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
site: selected.site,
account: selected.account,
tokenValue: selected.tokenValue,
tokenName: selected.tokenName,
modelName: selected.actualModel || requestedModel,
requestStartedAtMs: startTime,
requestEndedAtMs: startTime + latency,
localLatencyMs: latency,
usage: {
promptTokens: parsedUsage.promptTokens,
completionTokens: parsedUsage.completionTokens,
totalTokens: parsedUsage.totalTokens,
},
});
const { estimatedCost, billingDetails } = await resolveProxyLogBilling({
site: selected.site,
account: selected.account,
modelName: selected.actualModel || requestedModel,
parsedUsage,
resolvedUsage,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', upstream.status, latency, null, retryCount,
selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
);
return reply.code(upstream.status).send(data);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
@@ -150,6 +127,7 @@ async function logProxy(
latencyMs: number,
errorMessage: string | null,
retryCount: number,
downstreamApiKeyId: number | null = null,
promptTokens = 0,
completionTokens = 0,
totalTokens = 0,
@@ -166,6 +144,7 @@ async function logProxy(
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
modelRequested,
modelActual: selected.actualModel,
status,
@@ -58,6 +58,24 @@ describe('executeEndpointFlow', () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('uses the injected dispatchRequest hook instead of the default fetch path', async () => {
const dispatchRequest = vi.fn(async () => toUndiciResponse(new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
})));
const result = await executeEndpointFlow({
siteUrl: 'https://example.com',
endpointCandidates: ['responses'],
buildRequest: () => requestFor('/v1/responses'),
dispatchRequest,
});
expect(result.ok).toBe(true);
expect(dispatchRequest).toHaveBeenCalledTimes(1);
expect(fetchMock).not.toHaveBeenCalled();
});
it('avoids duplicated /v1 when base url already ends with /v1', async () => {
fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), {
status: 200,
@@ -167,6 +185,22 @@ describe('executeEndpointFlow', () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('uses proxyUrl for the default fetch path when no dispatch hook is provided', async () => {
fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
})));
await executeEndpointFlow({
siteUrl: 'https://example.com',
proxyUrl: 'https://proxy.internal/base',
endpointCandidates: ['responses'],
buildRequest: () => requestFor('/v1/responses'),
});
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://proxy.internal/base/v1/responses');
});
it('returns normalized final error when all endpoints fail', async () => {
fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
error: { message: 'upstream_error', type: 'upstream_error' },
+29 -9
View File
@@ -1,5 +1,5 @@
import { fetch } from 'undici';
import { withExplicitProxyRequestInit, withSiteProxyRequestInit } from '../../services/siteProxy.js';
import { withSiteProxyRequestInit } from '../../services/siteProxy.js';
import { summarizeUpstreamError } from './upstreamError.js';
import type { UpstreamEndpoint } from './upstreamEndpoint.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
@@ -9,6 +9,13 @@ export type BuiltEndpointRequest = {
path: string;
headers: Record<string, string>;
body: Record<string, unknown>;
runtime?: {
executor: 'default' | 'codex' | 'gemini-cli' | 'antigravity' | 'claude';
modelName?: string;
stream?: boolean;
oauthProjectId?: string | null;
action?: 'generateContent' | 'streamGenerateContent' | 'countTokens';
};
};
export type EndpointAttemptContext = {
@@ -35,6 +42,7 @@ export type EndpointFlowResult =
ok: false;
status: number;
errText: string;
rawErrText?: string;
};
export function withUpstreamPath(path: string, message: string): string {
@@ -46,11 +54,21 @@ type ExecuteEndpointFlowInput = {
proxyUrl?: string | null;
endpointCandidates: UpstreamEndpoint[];
buildRequest: (endpoint: UpstreamEndpoint, endpointIndex: number) => BuiltEndpointRequest;
dispatchRequest?: (
request: BuiltEndpointRequest,
targetUrl: string,
) => Promise<Awaited<ReturnType<typeof fetch>>>;
tryRecover?: (ctx: EndpointAttemptContext) => Promise<EndpointRecoverResult>;
shouldDowngrade?: (ctx: EndpointAttemptContext) => boolean;
onDowngrade?: (ctx: EndpointAttemptContext & { errText: string }) => void | Promise<void>;
};
function buildAbsoluteUrl(base: string, path: string): string {
const normalizedBase = base.replace(/\/+$/, '');
const normalizedPath = path.replace(/^\/+/, '');
return normalizedPath ? `${normalizedBase}/${normalizedPath}` : normalizedBase;
}
export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Promise<EndpointFlowResult> {
const endpointCount = input.endpointCandidates.length;
if (endpointCount <= 0) {
@@ -63,19 +81,19 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
let finalStatus = 0;
let finalErrText = 'unknown error';
let finalRawErrText: string | undefined;
for (let endpointIndex = 0; endpointIndex < endpointCount; endpointIndex += 1) {
const endpoint = input.endpointCandidates[endpointIndex] as UpstreamEndpoint;
const request = input.buildRequest(endpoint, endpointIndex);
const targetUrl = buildUpstreamUrl(input.siteUrl, request.path);
const defaultTarget = buildUpstreamUrl(input.siteUrl, request.path);
const targetUrl = input.proxyUrl
? buildAbsoluteUrl(input.proxyUrl, request.path)
: defaultTarget;
let response = await fetch(targetUrl, input.proxyUrl
? withExplicitProxyRequestInit(input.proxyUrl, {
method: 'POST',
headers: request.headers,
body: JSON.stringify(request.body),
})
: await withSiteProxyRequestInit(targetUrl, {
let response = input.dispatchRequest
? await input.dispatchRequest(request, targetUrl)
: await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, {
method: 'POST',
headers: request.headers,
body: JSON.stringify(request.body),
@@ -130,6 +148,7 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
finalStatus = response.status;
finalErrText = errText;
finalRawErrText = rawErrText;
break;
}
@@ -137,5 +156,6 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
ok: false,
status: finalStatus || 502,
errText: finalErrText || 'unknown error',
rawErrText: finalRawErrText,
};
}
File diff suppressed because it is too large Load Diff
+685 -94
View File
@@ -1,11 +1,34 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { TextDecoder } from 'node:util';
import { fetch } from 'undici';
import { and, eq } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { parseProxyUsage } from '../../services/proxyUsageParser.js';
import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js';
import { tokenRouter } from '../../services/tokenRouter.js';
import { buildOauthProviderHeaders } from '../../services/oauth/service.js';
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
import { withSiteProxyRequestInit } from '../../services/siteProxy.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { getDownstreamRoutingPolicy } from './downstreamPolicy.js';
import { executeEndpointFlow, type BuiltEndpointRequest } from './endpointFlow.js';
import { composeProxyLogMessage } from './logPathMeta.js';
import {
buildUpstreamEndpointRequest,
resolveUpstreamEndpointCandidates,
} from './upstreamEndpoint.js';
import {
geminiGenerateContentTransformer,
} from '../../transformers/gemini/generate-content/index.js';
import { createChatEndpointStrategy } from '../../transformers/shared/chatEndpointStrategy.js';
import { normalizeUpstreamFinalResponse } from '../../transformers/shared/normalized.js';
import {
createGeminiCliStreamReader,
unwrapGeminiCliPayload,
wrapGeminiCliRequest,
} from './geminiCliCompat.js';
import { dispatchRuntimeRequest } from './runtimeExecutor.js';
const MAX_RETRIES = 2;
const GEMINI_MODEL_PROBES = [
@@ -14,6 +37,52 @@ const GEMINI_MODEL_PROBES = [
'gemini-1.5-flash',
'gemini-pro',
];
const GEMINI_CLI_STATIC_MODELS = [
{ name: 'models/gemini-2.5-pro', displayName: 'Gemini 2.5 Pro' },
{ name: 'models/gemini-2.5-flash', displayName: 'Gemini 2.5 Flash' },
{ name: 'models/gemini-2.5-flash-lite', displayName: 'Gemini 2.5 Flash Lite' },
{ name: 'models/gemini-3-pro-preview', displayName: 'Gemini 3 Pro Preview' },
{ name: 'models/gemini-3.1-pro-preview', displayName: 'Gemini 3.1 Pro Preview' },
{ name: 'models/gemini-3-flash-preview', displayName: 'Gemini 3 Flash Preview' },
{ name: 'models/gemini-3.1-flash-lite-preview', displayName: 'Gemini 3.1 Flash Lite Preview' },
];
const EMPTY_PROXY_USAGE = {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
};
function isGeminiCliPlatform(platform: unknown): boolean {
return String(platform || '').trim().toLowerCase() === 'gemini-cli';
}
function isAntigravityPlatform(platform: unknown): boolean {
return String(platform || '').trim().toLowerCase() === 'antigravity';
}
function isInternalGeminiPlatform(platform: unknown): boolean {
return isGeminiCliPlatform(platform) || isAntigravityPlatform(platform);
}
function buildGeminiCliActionPath(input: {
isStreamAction: boolean;
isCountTokensAction: boolean;
}) {
if (input.isCountTokensAction) return '/v1internal:countTokens';
if (input.isStreamAction) return '/v1internal:streamGenerateContent?alt=sse';
return '/v1internal:generateContent';
}
function isDirectGeminiFamilyPlatform(platform: unknown): boolean {
const normalized = String(platform || '').trim().toLowerCase();
return normalized === 'gemini' || normalized === 'gemini-cli' || normalized === 'antigravity';
}
function omitGeminiCliModelField(body: unknown): Record<string, unknown> {
if (!body || typeof body !== 'object' || Array.isArray(body)) return {};
const { model: _model, ...rest } = body as Record<string, unknown>;
return rest;
}
async function selectGeminiChannel(request: FastifyRequest) {
const policy = getDownstreamRoutingPolicy(request);
@@ -33,6 +102,141 @@ async function selectNextGeminiProbeChannel(request: FastifyRequest, excludeChan
return null;
}
function resolveDownstreamPath(request: FastifyRequest): string {
const rawUrl = request.raw.url || request.url || '';
const withoutQuery = rawUrl.split('?')[0] || rawUrl;
return withoutQuery || '/v1beta/models';
}
function resolveUpstreamPath(apiVersion: string, modelActionPath: string): string {
const normalizedVersion = apiVersion.replace(/^\/+/, '');
const normalizedAction = modelActionPath.replace(/^\/+/, '');
return `/${normalizedVersion}/${normalizedAction}`;
}
function hasDownstreamModelRestrictions(policy: { supportedModels?: unknown; allowedRouteIds?: unknown }): boolean {
const supportedModels = Array.isArray(policy.supportedModels) ? policy.supportedModels : [];
const allowedRouteIds = Array.isArray(policy.allowedRouteIds) ? policy.allowedRouteIds : [];
return supportedModels.length > 0 || allowedRouteIds.length > 0;
}
function extractGeminiListedModelName(item: unknown): string {
if (!item || typeof item !== 'object') return '';
const rawName = typeof (item as { name?: unknown }).name === 'string'
? (item as { name: string }).name.trim()
: '';
if (!rawName) return '';
return rawName.startsWith('models/') ? rawName.slice('models/'.length) : rawName;
}
async function filterGeminiListedModelsForPolicy(
payload: unknown,
request: FastifyRequest,
): Promise<unknown> {
if (!payload || typeof payload !== 'object' || !Array.isArray((payload as { models?: unknown[] }).models)) {
return payload;
}
const policy = getDownstreamRoutingPolicy(request);
if (!hasDownstreamModelRestrictions(policy)) {
return payload;
}
const filteredModels: unknown[] = [];
for (const item of (payload as { models: unknown[] }).models) {
const modelName = extractGeminiListedModelName(item);
if (!modelName) continue;
if (!await isModelAllowedByPolicyOrAllowedRoutes(modelName, policy)) continue;
const decision = await tokenRouter.explainSelection?.(modelName, [], policy);
if (decision && typeof decision.selectedChannelId !== 'number') continue;
filteredModels.push(item);
}
return {
...(payload as Record<string, unknown>),
models: filteredModels,
};
}
async function readRouteAwareGeminiModels(request: FastifyRequest): Promise<Array<{ name: string; displayName: string }>> {
const policy = getDownstreamRoutingPolicy(request);
const rows = await db.select({ modelName: schema.modelAvailability.modelName })
.from(schema.modelAvailability)
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(and(
eq(schema.modelAvailability.available, true),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
))
.all();
const routeAliases = await db.select({ displayName: schema.tokenRoutes.displayName })
.from(schema.tokenRoutes)
.where(eq(schema.tokenRoutes.enabled, true))
.all();
const deduped = Array.from(new Set([
...rows.map((row) => String(row.modelName || '').trim()).filter(Boolean),
...routeAliases.map((row) => String(row.displayName || '').trim()).filter(Boolean),
])).sort();
const allowed: Array<{ name: string; displayName: string }> = [];
for (const modelName of deduped) {
if (!await isModelAllowedByPolicyOrAllowedRoutes(modelName, policy)) continue;
const decision = await tokenRouter.explainSelection?.(modelName, [], policy);
if (decision && typeof decision.selectedChannelId !== 'number') continue;
allowed.push({
name: `models/${modelName}`,
displayName: modelName,
});
}
return allowed;
}
async function logProxy(
selected: any,
modelRequested: string,
status: string,
httpStatus: number,
latencyMs: number,
errorMessage: string | null,
retryCount: number,
downstreamPath: string,
upstreamPath: string | null,
promptTokens = 0,
completionTokens = 0,
totalTokens = 0,
) {
try {
const createdAt = formatUtcSqlDateTime(new Date());
const normalizedErrorMessage = composeProxyLogMessage({
downstreamPath,
upstreamPath,
errorMessage,
});
await db.insert(schema.proxyLogs).values({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
modelRequested,
modelActual: selected.actualModel || modelRequested,
status,
httpStatus,
latencyMs,
promptTokens,
completionTokens,
totalTokens,
estimatedCost: 0,
billingDetails: null,
errorMessage: normalizedErrorMessage,
retryCount,
createdAt,
}).run();
} catch (error) {
console.warn('[proxy/gemini] failed to write proxy log', error);
}
}
export async function geminiProxyRoute(app: FastifyInstance) {
const listModels = async (request: FastifyRequest, reply: FastifyReply) => {
const apiVersion = geminiGenerateContentTransformer.resolveProxyApiVersion(
@@ -55,6 +259,23 @@ export async function geminiProxyRoute(app: FastifyInstance) {
excludeChannelIds.push(selected.channel.id);
try {
if (!isDirectGeminiFamilyPlatform(selected.site.platform)) {
let models = await readRouteAwareGeminiModels(request);
if (models.length <= 0) {
await refreshModelsAndRebuildRoutes();
models = await readRouteAwareGeminiModels(request);
}
return reply.code(200).send({ models });
}
if (isGeminiCliPlatform(selected.site.platform)) {
const filtered = await filterGeminiListedModelsForPolicy(
{ models: GEMINI_CLI_STATIC_MODELS },
request,
);
return reply.code(200).send(filtered);
}
const upstream = await fetch(
geminiGenerateContentTransformer.resolveModelsUrl(selected.site.url, apiVersion, selected.tokenValue),
{ method: 'GET' },
@@ -72,7 +293,9 @@ export async function geminiProxyRoute(app: FastifyInstance) {
}
try {
return reply.code(upstream.status).send(JSON.parse(text));
const parsed = JSON.parse(text);
const filtered = await filterGeminiListedModelsForPolicy(parsed, request);
return reply.code(upstream.status).send(filtered);
} catch {
return reply.code(upstream.status).type(upstream.headers.get('content-type') || 'application/json').send(text);
}
@@ -94,12 +317,36 @@ export async function geminiProxyRoute(app: FastifyInstance) {
}
};
const generateContent = async (request: FastifyRequest, reply: FastifyReply) => {
const parsedPath = geminiGenerateContentTransformer.parseProxyRequestPath({
rawUrl: request.raw.url || request.url || '',
params: request.params as { geminiApiVersion?: string } | undefined,
});
const handleGenerateContent = async (
request: FastifyRequest,
reply: FastifyReply,
options?: {
downstreamProtocol?: 'gemini' | 'gemini-cli';
action?: 'generateContent' | 'streamGenerateContent' | 'countTokens';
},
) => {
const downstreamProtocol = options?.downstreamProtocol || 'gemini';
const isGeminiCliDownstream = downstreamProtocol === 'gemini-cli';
const cliRequestedModel = isGeminiCliDownstream
? (typeof (request.body as Record<string, unknown> | null | undefined)?.model === 'string'
? String((request.body as Record<string, unknown>).model).trim()
: '')
: '';
const parsedPath = isGeminiCliDownstream
? {
apiVersion: 'v1beta',
modelActionPath: `models/${cliRequestedModel}:${options?.action || 'generateContent'}`,
isStreamAction: options?.action === 'streamGenerateContent',
requestedModel: cliRequestedModel,
}
: geminiGenerateContentTransformer.parseProxyRequestPath({
rawUrl: request.raw.url || request.url || '',
params: request.params as { geminiApiVersion?: string } | undefined,
});
const { apiVersion, modelActionPath, isStreamAction, requestedModel } = parsedPath;
const isCountTokensAction = isGeminiCliDownstream
? options?.action === 'countTokens'
: modelActionPath.endsWith(':countTokens');
if (!requestedModel) {
return reply.code(400).send({
error: { message: 'Gemini model path is required', type: 'invalid_request_error' },
@@ -107,6 +354,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
}
const policy = getDownstreamRoutingPolicy(request);
const downstreamPath = resolveDownstreamPath(request);
const excludeChannelIds: number[] = [];
let retryCount = 0;
let lastStatus = 503;
@@ -123,109 +371,424 @@ export async function geminiProxyRoute(app: FastifyInstance) {
excludeChannelIds.push(selected.channel.id);
const body = geminiGenerateContentTransformer.inbound.normalizeRequest(
request.body || {},
selected.actualModel || requestedModel,
const actualModel = selected.actualModel || requestedModel;
const normalizedBody = geminiGenerateContentTransformer.inbound.normalizeRequest(
isGeminiCliDownstream ? omitGeminiCliModelField(request.body) : (request.body || {}),
actualModel,
);
const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
const isGeminiCli = isGeminiCliPlatform(selected.site.platform);
const isInternalGemini = isInternalGeminiPlatform(selected.site.platform);
const isDirectGeminiFamily = isDirectGeminiFamilyPlatform(selected.site.platform);
const startTime = Date.now();
let upstreamPath = '';
const actualModelAction = modelActionPath.replace(
/^models\/[^:]+/,
`models/${selected.actualModel || requestedModel}`,
);
const query = new URLSearchParams(request.query as Record<string, string>).toString();
try {
const upstream = await fetch(
geminiGenerateContentTransformer.resolveActionUrl(
selected.site.url,
apiVersion,
actualModelAction,
selected.tokenValue,
query,
),
{
method: 'POST',
headers: {
if (isDirectGeminiFamily) {
if (isGeminiCli && !oauth?.projectId) {
lastStatus = 500;
lastContentType = 'application/json';
lastText = JSON.stringify({
error: {
message: 'Gemini CLI OAuth project is missing',
type: 'server_error',
},
});
await tokenRouter.recordFailure?.(selected.channel.id);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
}
return reply.code(lastStatus).type(lastContentType).send(lastText);
}
const actualModelAction = modelActionPath.replace(
/^models\/[^:]+/,
`models/${actualModel}`,
);
upstreamPath = isInternalGemini
? buildGeminiCliActionPath({ isStreamAction, isCountTokensAction })
: resolveUpstreamPath(apiVersion, actualModelAction);
const query = new URLSearchParams(request.query as Record<string, string>).toString();
const requestBody = isInternalGemini
? (
isCountTokensAction
? { request: normalizedBody }
: wrapGeminiCliRequest({
modelName: actualModel,
projectId: oauth?.projectId || '',
request: normalizedBody as Record<string, unknown>,
})
)
: normalizedBody;
const requestHeaders = isInternalGemini
? {
'Content-Type': 'application/json',
...(isStreamAction ? { Accept: 'text/event-stream' } : {}),
Authorization: `Bearer ${selected.tokenValue}`,
...buildOauthProviderHeaders({
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
downstreamHeaders: request.headers as Record<string, unknown>,
}),
}
: {
'Content-Type': 'application/json',
};
const targetUrl = isInternalGemini
? `${selected.site.url}${upstreamPath}`
: geminiGenerateContentTransformer.resolveActionUrl(
selected.site.url,
apiVersion,
actualModelAction,
selected.tokenValue,
query,
);
const upstream = isInternalGemini
? await dispatchRuntimeRequest({
siteUrl: selected.site.url,
targetUrl,
request: {
endpoint: 'chat',
path: upstreamPath,
headers: requestHeaders,
body: requestBody as Record<string, unknown>,
runtime: {
executor: isGeminiCli ? 'gemini-cli' : 'antigravity',
modelName: actualModel,
stream: isStreamAction,
oauthProjectId: oauth?.projectId || null,
action: isCountTokensAction
? 'countTokens'
: (isStreamAction ? 'streamGenerateContent' : 'generateContent'),
},
},
buildInit: async (requestUrl, requestForFetch) => withSiteProxyRequestInit(requestUrl, {
method: 'POST',
headers: requestForFetch.headers,
body: JSON.stringify(requestForFetch.body),
}),
})
: await fetch(targetUrl, {
method: 'POST',
headers: requestHeaders,
body: JSON.stringify(requestBody),
});
const contentType = upstream.headers.get('content-type') || 'application/json';
if (!upstream.ok) {
lastStatus = upstream.status;
lastContentType = contentType;
lastText = await upstream.text();
await tokenRouter.recordFailure?.(selected.channel.id);
await logProxy(
selected,
requestedModel,
'failed',
lastStatus,
Date.now() - startTime,
lastText,
retryCount,
downstreamPath,
upstreamPath,
);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
}
try {
return reply.code(lastStatus).send(JSON.parse(lastText));
} catch {
return reply.code(lastStatus).type(lastContentType).send(lastText);
}
}
if (geminiGenerateContentTransformer.stream.isSseContentType(contentType)) {
reply.hijack();
reply.raw.statusCode = upstream.status;
reply.raw.setHeader('Content-Type', contentType || 'text/event-stream');
const upstreamReader = upstream.body?.getReader();
const reader = isInternalGemini && !isGeminiCliDownstream && upstreamReader
? createGeminiCliStreamReader(upstreamReader)
: upstreamReader;
if (!reader) {
const latency = Date.now() - startTime;
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
await logProxy(
selected,
requestedModel,
'success',
upstream.status,
latency,
null,
retryCount,
downstreamPath,
upstreamPath,
);
reply.raw.end();
return;
}
const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState();
const decoder = new TextDecoder();
let rest = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
const chunkText = decoder.decode(value, { stream: true });
const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer(
aggregateState,
rest + chunkText,
);
rest = consumed.rest;
for (const line of consumed.lines) {
reply.raw.write(line);
}
}
const tail = decoder.decode();
if (tail) {
const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer(
aggregateState,
rest + tail,
);
for (const line of consumed.lines) {
reply.raw.write(line);
}
}
} finally {
reader.releaseLock();
reply.raw.end();
}
const parsedUsage = parseProxyUsage(aggregateState);
const latency = Date.now() - startTime;
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
await logProxy(
selected,
requestedModel,
'success',
upstream.status,
latency,
null,
retryCount,
downstreamPath,
upstreamPath,
parsedUsage.promptTokens,
parsedUsage.completionTokens,
parsedUsage.totalTokens,
);
return;
}
const text = await upstream.text();
const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState();
let parsedUsage = EMPTY_PROXY_USAGE;
try {
const parsed = JSON.parse(text);
const unwrappedPayload = isInternalGemini && !isGeminiCliDownstream
? unwrapGeminiCliPayload(parsed)
: parsed;
const responsePayload = isCountTokensAction
? unwrappedPayload
: geminiGenerateContentTransformer.stream.serializeUpstreamJsonPayload(
aggregateState,
unwrappedPayload,
isStreamAction,
);
parsedUsage = parseProxyUsage(aggregateState);
const latency = Date.now() - startTime;
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
await logProxy(
selected,
requestedModel,
'success',
upstream.status,
latency,
null,
retryCount,
downstreamPath,
upstreamPath,
parsedUsage.promptTokens,
parsedUsage.completionTokens,
parsedUsage.totalTokens,
);
return reply.code(upstream.status).send(
isGeminiCliDownstream && !isCountTokensAction
? { response: responsePayload }
: responsePayload,
);
} catch {
const latency = Date.now() - startTime;
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
await logProxy(
selected,
requestedModel,
'success',
upstream.status,
latency,
null,
retryCount,
downstreamPath,
upstreamPath,
);
return reply.code(upstream.status).type(contentType || 'application/json').send(text);
}
}
if (isCountTokensAction) {
lastStatus = 501;
lastContentType = 'application/json';
lastText = JSON.stringify({
error: {
message: 'Gemini countTokens compatibility is not implemented for this upstream',
type: 'invalid_request_error',
},
body: JSON.stringify(body),
});
return reply.code(lastStatus).type(lastContentType).send(lastText);
}
const openAiBody = geminiGenerateContentTransformer.compatibility.buildOpenAiBodyFromGeminiRequest({
body: normalizedBody as Record<string, unknown>,
modelName: actualModel,
stream: isStreamAction,
});
const endpointCandidates = await resolveUpstreamEndpointCandidates(
{
site: selected.site,
account: selected.account,
},
actualModel,
'openai',
requestedModel,
);
const contentType = upstream.headers.get('content-type') || 'application/json';
if (!upstream.ok) {
lastStatus = upstream.status;
lastContentType = contentType;
lastText = await upstream.text();
const buildEndpointRequest = (
endpoint: 'chat' | 'messages' | 'responses',
requestOptions: { forceNormalizeClaudeBody?: boolean } = {},
) => {
const endpointRequest = buildUpstreamEndpointRequest({
endpoint,
modelName: actualModel,
stream: isStreamAction,
tokenValue: selected.tokenValue,
oauthProvider: oauth?.provider,
oauthProjectId: oauth?.projectId,
sitePlatform: selected.site.platform,
siteUrl: selected.site.url,
openaiBody: openAiBody,
downstreamFormat: 'openai',
forceNormalizeClaudeBody: requestOptions.forceNormalizeClaudeBody,
downstreamHeaders: request.headers as Record<string, unknown>,
providerHeaders: buildOauthProviderHeaders({
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
downstreamHeaders: request.headers as Record<string, unknown>,
}),
});
return {
endpoint,
path: endpointRequest.path,
headers: endpointRequest.headers,
body: endpointRequest.body as Record<string, unknown>,
runtime: endpointRequest.runtime,
};
};
const dispatchRequest = (compatibilityRequest: BuiltEndpointRequest, targetUrl?: string) => (
dispatchRuntimeRequest({
siteUrl: selected.site.url,
targetUrl,
request: compatibilityRequest,
buildInit: async (requestUrl, requestForFetch) => withSiteProxyRequestInit(requestUrl, {
method: 'POST',
headers: requestForFetch.headers,
body: JSON.stringify(requestForFetch.body),
}),
})
);
const endpointStrategy = createChatEndpointStrategy({
downstreamFormat: 'openai',
endpointCandidates,
modelName: actualModel,
requestedModelHint: requestedModel,
sitePlatform: selected.site.platform,
isStream: isStreamAction,
buildRequest: ({ endpoint, forceNormalizeClaudeBody }) => buildEndpointRequest(
endpoint,
{ forceNormalizeClaudeBody },
),
dispatchRequest,
});
const endpointResult = await executeEndpointFlow({
siteUrl: selected.site.url,
endpointCandidates,
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
dispatchRequest,
tryRecover: endpointStrategy.tryRecover,
shouldDowngrade: endpointStrategy.shouldDowngrade,
});
if (!endpointResult.ok) {
lastStatus = endpointResult.status;
lastContentType = 'application/json';
lastText = JSON.stringify({
error: {
message: endpointResult.errText,
type: 'upstream_error',
},
});
await tokenRouter.recordFailure?.(selected.channel.id);
await logProxy(
selected,
requestedModel,
'failed',
lastStatus,
Date.now() - startTime,
endpointResult.errText,
retryCount,
downstreamPath,
null,
);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
}
try {
return reply.code(lastStatus).send(JSON.parse(lastText));
} catch {
return reply.code(lastStatus).type(lastContentType).send(lastText);
}
return reply.code(lastStatus).type(lastContentType).send(lastText);
}
if (geminiGenerateContentTransformer.stream.isSseContentType(contentType)) {
reply.hijack();
reply.raw.statusCode = upstream.status;
reply.raw.setHeader('Content-Type', contentType || 'text/event-stream');
const reader = upstream.body?.getReader();
if (!reader) {
reply.raw.end();
return;
}
const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState();
const decoder = new TextDecoder();
let rest = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
const chunkText = decoder.decode(value, { stream: true });
const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer(
aggregateState,
rest + chunkText,
);
rest = consumed.rest;
for (const line of consumed.lines) {
reply.raw.write(line);
}
}
const tail = decoder.decode();
if (tail) {
const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer(
aggregateState,
rest + tail,
);
for (const line of consumed.lines) {
reply.raw.write(line);
}
}
} finally {
reader.releaseLock();
reply.raw.end();
}
return;
}
const text = await upstream.text();
upstreamPath = endpointResult.upstreamPath;
const upstream = endpointResult.upstream;
const rawText = await upstream.text();
let upstreamData: unknown = rawText;
try {
const parsed = JSON.parse(text);
const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState();
return reply.code(upstream.status).send(
geminiGenerateContentTransformer.stream.serializeUpstreamJsonPayload(
aggregateState,
parsed,
isStreamAction,
),
);
} catch {
return reply.code(upstream.status).type(contentType || 'application/json').send(text);
}
upstreamData = JSON.parse(rawText);
} catch {}
const parsedUsage = parseProxyUsage(upstreamData);
const normalizedFinal = normalizeUpstreamFinalResponse(upstreamData, actualModel, rawText);
const geminiResponse = geminiGenerateContentTransformer.compatibility.serializeNormalizedFinalToGemini({
normalized: normalizedFinal,
usage: {
promptTokens: parsedUsage.promptTokens,
completionTokens: parsedUsage.completionTokens,
totalTokens: parsedUsage.totalTokens,
},
});
const latency = Date.now() - startTime;
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
await logProxy(
selected,
requestedModel,
'success',
upstream.status,
latency,
null,
retryCount,
downstreamPath,
upstreamPath,
parsedUsage.promptTokens,
parsedUsage.completionTokens,
parsedUsage.totalTokens,
);
return reply.code(upstream.status).send(
isGeminiCliDownstream
? { response: geminiResponse }
: geminiResponse,
);
} catch (error) {
lastStatus = 502;
lastContentType = 'application/json';
@@ -236,6 +799,17 @@ export async function geminiProxyRoute(app: FastifyInstance) {
},
});
await tokenRouter.recordFailure?.(selected.channel.id);
await logProxy(
selected,
requestedModel,
'failed',
0,
Date.now() - startTime,
error instanceof Error ? error.message : 'Gemini upstream request failed',
retryCount,
downstreamPath,
upstreamPath || null,
);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
@@ -245,8 +819,25 @@ export async function geminiProxyRoute(app: FastifyInstance) {
}
};
const generateContent = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply);
const geminiCliGenerateContent = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply, {
downstreamProtocol: 'gemini-cli',
action: 'generateContent',
});
const geminiCliStreamGenerateContent = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply, {
downstreamProtocol: 'gemini-cli',
action: 'streamGenerateContent',
});
const geminiCliCountTokens = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply, {
downstreamProtocol: 'gemini-cli',
action: 'countTokens',
});
app.get('/v1beta/models', listModels);
app.get('/gemini/:geminiApiVersion/models', listModels);
app.post('/v1beta/models/*', generateContent);
app.post('/gemini/:geminiApiVersion/models/*', generateContent);
app.post('/v1internal::generateContent', geminiCliGenerateContent);
app.post('/v1internal::streamGenerateContent', geminiCliStreamGenerateContent);
app.post('/v1internal::countTokens', geminiCliCountTokens);
}
@@ -0,0 +1,196 @@
import { describe, it, expect } from 'vitest';
import { buildGeminiGenerateContentRequestFromOpenAi } from './geminiCliCompat.js';
describe('thoughtSignature injection in OpenAI→Gemini conversion', () => {
it('injects thoughtSignature from provider_specific_fields into functionCall parts', () => {
const result = buildGeminiGenerateContentRequestFromOpenAi({
body: {
model: 'gemini-3-flash-preview',
messages: [
{ role: 'user', content: 'What is the weather?' },
{
role: 'assistant',
content: 'Let me check.',
tool_calls: [
{
id: 'call_123',
type: 'function',
function: { name: 'get_weather', arguments: '{"city":"Tokyo"}' },
provider_specific_fields: { thought_signature: 'real_sig_abc' },
},
],
},
{ role: 'tool', tool_call_id: 'call_123', content: '{"temp":"22C"}' },
],
},
modelName: 'gemini-3-flash-preview',
}) as Record<string, unknown>;
const contents = result.contents as Array<Record<string, unknown>>;
// Find the model message with functionCall
const modelMsgs = contents.filter((c) => c.role === 'model');
const fcParts = modelMsgs
.flatMap((m) => (m.parts as Array<Record<string, unknown>>))
.filter((p) => 'functionCall' in p);
expect(fcParts.length).toBe(1);
expect(fcParts[0].thoughtSignature).toBe('real_sig_abc');
});
it('splits text and signed functionCall parts into separate model messages', () => {
const result = buildGeminiGenerateContentRequestFromOpenAi({
body: {
model: 'gemini-3-flash-preview',
messages: [
{ role: 'user', content: 'Read the file.' },
{
role: 'assistant',
content: 'I will read it.',
tool_calls: [
{
id: 'call_456',
type: 'function',
function: { name: 'Read', arguments: '{"path":"/tmp/x"}' },
provider_specific_fields: { thought_signature: 'sig_split_test' },
},
],
},
{ role: 'tool', tool_call_id: 'call_456', content: 'file content here' },
],
},
modelName: 'gemini-3-flash-preview',
}) as Record<string, unknown>;
const contents = result.contents as Array<Record<string, unknown>>;
const modelMsgs = contents.filter((c) => c.role === 'model');
// Should be 2 model messages: text (unsigned) + functionCall (signed)
expect(modelMsgs.length).toBe(2);
const firstParts = modelMsgs[0].parts as Array<Record<string, unknown>>;
const secondParts = modelMsgs[1].parts as Array<Record<string, unknown>>;
// First: text only, no thoughtSignature
expect(firstParts.every((p) => 'text' in p)).toBe(true);
expect(firstParts.every((p) => !('thoughtSignature' in p))).toBe(true);
// Second: functionCall with thoughtSignature
expect(secondParts.every((p) => 'functionCall' in p)).toBe(true);
expect(secondParts[0].thoughtSignature).toBe('sig_split_test');
});
it('injects dummy sentinel when thinking enabled but no signature available', () => {
const result = buildGeminiGenerateContentRequestFromOpenAi({
body: {
model: 'gemini-3-flash-preview',
reasoning_effort: 'high',
messages: [
{ role: 'user', content: 'Do something.' },
{
role: 'assistant',
tool_calls: [
{
id: 'call_no_sig',
type: 'function',
function: { name: 'Bash', arguments: '{"command":"ls"}' },
// No provider_specific_fields — signature missing
},
],
},
{ role: 'tool', tool_call_id: 'call_no_sig', content: 'file1\nfile2' },
],
},
modelName: 'gemini-3-flash-preview',
}) as Record<string, unknown>;
const contents = result.contents as Array<Record<string, unknown>>;
const modelMsgs = contents.filter((c) => c.role === 'model');
const fcParts = modelMsgs
.flatMap((m) => (m.parts as Array<Record<string, unknown>>))
.filter((p) => 'functionCall' in p);
expect(fcParts.length).toBe(1);
// Should have a dummy sentinel, not be missing
expect(typeof fcParts[0].thoughtSignature).toBe('string');
expect((fcParts[0].thoughtSignature as string).length).toBeGreaterThan(0);
});
it('does not inject thoughtSignature when thinking is not enabled', () => {
const result = buildGeminiGenerateContentRequestFromOpenAi({
body: {
model: 'gemini-2.5-flash',
messages: [
{ role: 'user', content: 'Hello' },
{
role: 'assistant',
tool_calls: [
{
id: 'call_no_think',
type: 'function',
function: { name: 'Read', arguments: '{"path":"/x"}' },
},
],
},
{ role: 'tool', tool_call_id: 'call_no_think', content: 'data' },
],
},
modelName: 'gemini-2.5-flash',
}) as Record<string, unknown>;
const contents = result.contents as Array<Record<string, unknown>>;
const modelMsgs = contents.filter((c) => c.role === 'model');
const fcParts = modelMsgs
.flatMap((m) => (m.parts as Array<Record<string, unknown>>))
.filter((p) => 'functionCall' in p);
expect(fcParts.length).toBe(1);
// No thinking → no signature injected
expect(fcParts[0].thoughtSignature).toBeUndefined();
});
it('preserves functionResponse count matching functionCall count', () => {
const result = buildGeminiGenerateContentRequestFromOpenAi({
body: {
model: 'gemini-3-flash-preview',
messages: [
{ role: 'user', content: 'Read two files.' },
{
role: 'assistant',
tool_calls: [
{
id: 'call_a',
type: 'function',
function: { name: 'Read', arguments: '{"path":"/a"}' },
provider_specific_fields: { thought_signature: 'sig_a' },
},
{
id: 'call_b',
type: 'function',
function: { name: 'Read', arguments: '{"path":"/b"}' },
provider_specific_fields: { thought_signature: 'sig_b' },
},
],
},
{ role: 'tool', tool_call_id: 'call_a', content: 'content a' },
{ role: 'tool', tool_call_id: 'call_b', content: 'content b' },
],
},
modelName: 'gemini-3-flash-preview',
}) as Record<string, unknown>;
const contents = result.contents as Array<Record<string, unknown>>;
// Count functionCall and functionResponse parts
let fcCount = 0;
let frCount = 0;
for (const content of contents) {
for (const part of (content.parts as Array<Record<string, unknown>>)) {
if ('functionCall' in part) fcCount++;
if ('functionResponse' in part) frCount++;
}
}
expect(fcCount).toBe(2);
expect(frCount).toBe(2);
});
});
+396
View File
@@ -0,0 +1,396 @@
import { TextDecoder, TextEncoder } from 'node:util';
import { resolveGeminiThinkingConfigFromRequest } from '../../transformers/gemini/generate-content/convert.js';
// Dummy sentinel used when no real thoughtSignature is available but thinking
// mode is enabled. Gemini accepts any base64 string and won't reject this.
const DUMMY_THOUGHT_SIGNATURE = 'c2tpcF90aG91Z2h0X3NpZ25hdHVyZV92YWxpZGF0b3I=';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function parseDataUrl(url: string): { mimeType: string; data: string } | null {
if (!url.startsWith('data:')) return null;
const [, rest] = url.split('data:', 2);
const [meta, data] = rest.split(',', 2);
if (!meta || !data) return null;
const [mimeType] = meta.split(';', 1);
return {
mimeType: mimeType || 'application/octet-stream',
data,
};
}
function normalizeFunctionResponseResult(value: unknown): unknown {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
if (!trimmed) return value;
try {
return JSON.parse(trimmed);
} catch {
return value;
}
}
function convertContentToGeminiParts(content: unknown): Array<Record<string, unknown>> {
if (typeof content === 'string') {
const trimmed = content.trim();
return trimmed ? [{ text: trimmed }] : [];
}
if (isRecord(content)) {
if (typeof content.text === 'string') {
const trimmed = content.text.trim();
return trimmed ? [{ text: trimmed }] : [];
}
return [];
}
if (!Array.isArray(content)) return [];
const parts: Array<Record<string, unknown>> = [];
for (const item of content) {
if (!isRecord(item)) continue;
const type = asTrimmedString(item.type).toLowerCase();
if (type === 'text') {
const text = asTrimmedString(item.text);
if (text) parts.push({ text });
continue;
}
if (type === 'image_url') {
const imageUrl = asTrimmedString(item.image_url && isRecord(item.image_url) ? item.image_url.url : item.url);
const parsed = imageUrl ? parseDataUrl(imageUrl) : null;
if (parsed) {
parts.push({
inlineData: {
mime_type: parsed.mimeType,
data: parsed.data,
},
});
}
continue;
}
if (type === 'input_audio') {
const data = asTrimmedString(item.data);
if (data) {
parts.push({
inlineData: {
mime_type: 'audio/wav',
data,
},
});
}
}
}
return parts;
}
function buildGeminiTools(tools: unknown): Array<Record<string, unknown>> | undefined {
if (!Array.isArray(tools)) return undefined;
const declarations = tools
.filter((item) => isRecord(item))
.flatMap((item) => {
if (asTrimmedString(item.type) !== 'function' || !isRecord(item.function)) return [];
const fn = item.function as Record<string, unknown>;
const name = asTrimmedString(fn.name);
if (!name) return [];
return [{
name,
...(asTrimmedString(fn.description) ? { description: asTrimmedString(fn.description) } : {}),
parametersJsonSchema: isRecord(fn.parameters) ? fn.parameters : { type: 'object', properties: {} },
}];
});
if (declarations.length <= 0) return undefined;
return [{ functionDeclarations: declarations }];
}
function buildGeminiToolConfig(toolChoice: unknown): Record<string, unknown> | undefined {
if (typeof toolChoice === 'string') {
const normalized = toolChoice.trim().toLowerCase();
if (normalized === 'none') {
return { functionCallingConfig: { mode: 'NONE' } };
}
if (normalized === 'required') {
return { functionCallingConfig: { mode: 'ANY' } };
}
return { functionCallingConfig: { mode: 'AUTO' } };
}
return undefined;
}
export function buildGeminiGenerateContentRequestFromOpenAi(input: {
body: Record<string, unknown>;
modelName: string;
instructions?: string;
}) {
const request: Record<string, unknown> = {
contents: [],
};
const messages = Array.isArray(input.body.messages) ? input.body.messages : [];
const toolNameById = new Map<string, string>();
for (const message of messages) {
if (!isRecord(message) || asTrimmedString(message.role) !== 'assistant') continue;
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
for (const toolCall of toolCalls) {
if (!isRecord(toolCall) || !isRecord(toolCall.function)) continue;
const id = asTrimmedString(toolCall.id);
const name = asTrimmedString(toolCall.function.name);
if (id && name) {
toolNameById.set(id, name);
}
}
}
// Detect if thinking mode is enabled (needed for dummy signature fallback)
const hasThinkingEnabled = !!resolveGeminiThinkingConfigFromRequest(input.modelName, input.body);
// Collect thoughtSignatures from assistant tool_calls for injection.
// OpenAI format stores signatures in provider_specific_fields or encoded in tool call IDs.
const thoughtSignatureById = new Map<string, string>();
for (const message of messages) {
if (!isRecord(message) || asTrimmedString(message.role) !== 'assistant') continue;
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
for (const toolCall of toolCalls) {
if (!isRecord(toolCall)) continue;
const id = asTrimmedString(toolCall.id);
if (!id) continue;
// Check provider_specific_fields first
const providerFields = isRecord(toolCall.provider_specific_fields) ? toolCall.provider_specific_fields : null;
if (providerFields && typeof providerFields.thought_signature === 'string') {
thoughtSignatureById.set(id, providerFields.thought_signature);
}
}
}
const systemParts: Array<Record<string, unknown>> = [];
if (typeof input.instructions === 'string' && input.instructions.trim()) {
systemParts.push({ text: input.instructions.trim() });
}
for (const message of messages) {
if (!isRecord(message)) continue;
const role = asTrimmedString(message.role).toLowerCase();
if (role === 'system' || role === 'developer') {
systemParts.push(...convertContentToGeminiParts(message.content));
continue;
}
if (role === 'tool') {
const toolCallId = asTrimmedString(message.tool_call_id);
const name = toolNameById.get(toolCallId) || 'unknown';
const result = normalizeFunctionResponseResult(message.content);
request.contents = [
...(Array.isArray(request.contents) ? request.contents : []),
{
role: 'user',
parts: [{
functionResponse: {
name,
response: {
result,
},
},
}],
},
];
continue;
}
const textParts = convertContentToGeminiParts(message.content);
const fcParts: Array<Record<string, unknown>> = [];
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
for (const toolCall of toolCalls) {
if (!isRecord(toolCall) || !isRecord(toolCall.function)) continue;
const name = asTrimmedString(toolCall.function.name);
if (!name) continue;
const rawArguments = toolCall.function.arguments;
let args: unknown = {};
if (typeof rawArguments === 'string' && rawArguments.trim()) {
try {
args = JSON.parse(rawArguments);
} catch {
args = { raw: rawArguments };
}
} else if (isRecord(rawArguments)) {
args = rawArguments;
}
const fcPart: Record<string, unknown> = {
functionCall: { name, args },
};
// Inject thoughtSignature if available from provider_specific_fields.
// If no signature is found, use a dummy sentinel so Gemini doesn't reject
// the request outright. Gemini 3+ requires thoughtSignature on all
// functionCall parts when thinking is enabled.
// Ref: https://ai.google.dev/gemini-api/docs/thought-signatures
const id = asTrimmedString(toolCall.id);
const signature = thoughtSignatureById.get(id);
if (signature) {
fcPart.thoughtSignature = signature;
} else if (hasThinkingEnabled) {
fcPart.thoughtSignature = DUMMY_THOUGHT_SIGNATURE;
}
fcParts.push(fcPart);
}
const geminiRole = role === 'assistant' ? 'model' : 'user';
// Gemini requires that parts WITH thoughtSignature and parts WITHOUT
// are not mixed in the same message. Split if needed.
// Ref: https://ai.google.dev/gemini-api/docs/thought-signatures
const hasSigned = fcParts.some((p) => 'thoughtSignature' in p);
if (hasSigned && textParts.length > 0 && fcParts.length > 0) {
// Emit text parts first, then functionCall parts in a separate message
request.contents = [
...(Array.isArray(request.contents) ? request.contents : []),
{ role: geminiRole, parts: textParts },
{ role: geminiRole, parts: fcParts },
];
} else {
const allParts = [...textParts, ...fcParts];
if (allParts.length <= 0) continue;
request.contents = [
...(Array.isArray(request.contents) ? request.contents : []),
{ role: geminiRole, parts: allParts },
];
}
}
if (systemParts.length > 0) {
request.systemInstruction = {
role: 'user',
parts: systemParts,
};
}
const generationConfig: Record<string, unknown> = {};
const maxOutputTokens = Number(
input.body.max_output_tokens
?? input.body.max_completion_tokens
?? input.body.max_tokens
?? 0,
);
if (Number.isFinite(maxOutputTokens) && maxOutputTokens > 0) {
generationConfig.maxOutputTokens = Math.trunc(maxOutputTokens);
}
const temperature = Number(input.body.temperature);
if (Number.isFinite(temperature)) generationConfig.temperature = temperature;
const topP = Number(input.body.top_p);
if (Number.isFinite(topP)) generationConfig.topP = topP;
const topK = Number(input.body.top_k);
if (Number.isFinite(topK)) generationConfig.topK = topK;
if (Array.isArray(input.body.stop) && input.body.stop.length > 0) {
generationConfig.stopSequences = input.body.stop.filter((item): item is string => typeof item === 'string' && item.trim().length > 0);
}
const thinkingConfig = resolveGeminiThinkingConfigFromRequest(input.modelName, input.body);
if (thinkingConfig) {
generationConfig.thinkingConfig = thinkingConfig;
}
if (Object.keys(generationConfig).length > 0) {
request.generationConfig = generationConfig;
}
const geminiTools = buildGeminiTools(input.body.tools);
if (geminiTools) {
request.tools = geminiTools;
}
const toolConfig = buildGeminiToolConfig(input.body.tool_choice);
if (toolConfig) {
request.toolConfig = toolConfig;
}
return request;
}
export function wrapGeminiCliRequest(input: {
modelName: string;
projectId: string;
request: Record<string, unknown>;
}) {
const { model, ...requestPayload } = input.request;
return {
project: input.projectId,
model: input.modelName,
request: requestPayload,
};
}
export function unwrapGeminiCliPayload<T>(payload: T): unknown {
if (!isRecord(payload)) return payload;
if (payload.response !== undefined) {
return payload.response;
}
return payload;
}
function rewriteGeminiCliSseEventBlock(block: string): string {
const lines = block.split(/\r?\n/g);
return lines.map((line) => {
if (!line.startsWith('data:')) return line;
const data = line.slice(5).trim();
if (!data || data === '[DONE]') return line;
try {
const parsed = JSON.parse(data);
return `data: ${JSON.stringify(unwrapGeminiCliPayload(parsed))}`;
} catch {
return line;
}
}).join('\n');
}
export function createGeminiCliStreamReader(reader: {
read(): Promise<{ done: boolean; value?: Uint8Array }>;
cancel(reason?: unknown): Promise<unknown>;
releaseLock(): void;
}) {
const decoder = new TextDecoder();
const encoder = new TextEncoder();
const outputQueue: Uint8Array[] = [];
let buffer = '';
let done = false;
async function fillQueue() {
while (outputQueue.length <= 0 && !done) {
const result = await reader.read();
if (result.done) {
done = true;
const tail = decoder.decode();
if (tail) buffer += tail;
if (buffer.trim()) {
outputQueue.push(encoder.encode(`${rewriteGeminiCliSseEventBlock(buffer)}\n\n`));
buffer = '';
}
break;
}
if (!result.value) continue;
buffer += decoder.decode(result.value, { stream: true });
let separatorIndex = buffer.indexOf('\n\n');
while (separatorIndex >= 0) {
const block = buffer.slice(0, separatorIndex);
buffer = buffer.slice(separatorIndex + 2);
outputQueue.push(encoder.encode(`${rewriteGeminiCliSseEventBlock(block)}\n\n`));
separatorIndex = buffer.indexOf('\n\n');
}
}
}
return {
async read() {
await fillQueue();
if (outputQueue.length > 0) {
return { done: false, value: outputQueue.shift() };
}
return { done: true, value: undefined };
},
cancel(reason?: unknown) {
return reader.cancel(reason);
},
releaseLock() {
reader.releaseLock();
},
};
}
+18 -26
View File
@@ -1,6 +1,6 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { fetch } from 'undici';
import { db, schema } from '../../db/index.js';
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
import { tokenRouter } from '../../services/tokenRouter.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
@@ -13,6 +13,7 @@ import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js
import { composeProxyLogMessage } from './logPathMeta.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js';
import { getProxyAuthContext } from '../../middleware/auth.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
const MAX_RETRIES = 2;
@@ -25,6 +26,9 @@ export async function imagesProxyRoute(app: FastifyInstance) {
const requestedModel = body?.model || 'gpt-image-1';
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
const logDownstreamApiKeyId = downstreamApiKeyId !== null
&& await hasProxyLogDownstreamApiKeyIdColumn();
const excludeChannelIds: number[] = [];
let retryCount = 0;
@@ -67,7 +71,7 @@ export async function imagesProxyRoute(app: FastifyInstance) {
const text = await upstream.text();
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
if (isTokenExpiredError({ status: upstream.status, message: text })) {
await reportTokenExpired({
accountId: selected.account.id,
@@ -101,27 +105,11 @@ export async function imagesProxyRoute(app: FastifyInstance) {
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, estimatedCost);
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, estimatedCost);
return reply.code(upstream.status).send(data);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount);
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
}
await reportProxyAllFailed({
model: requestedModel,
reason: err.message || 'network failure',
});
return reply.code(502).send({
error: { message: `Upstream error: ${err.message}`, type: 'upstream_error' },
});
}
}
});
app.post('/v1/images/edits', async (request: FastifyRequest, reply: FastifyReply) => {
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);, async (request: FastifyRequest, reply: FastifyReply) => {
const multipartForm = await parseMultipartFormData(request);
const jsonBody = (!multipartForm && request.body && typeof request.body === 'object')
? request.body as Record<string, unknown>
@@ -132,6 +120,9 @@ export async function imagesProxyRoute(app: FastifyInstance) {
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
const logDownstreamApiKeyId = downstreamApiKeyId !== null
&& await hasProxyLogDownstreamApiKeyIdColumn();
const excludeChannelIds: number[] = [];
let retryCount = 0;
@@ -160,7 +151,6 @@ export async function imagesProxyRoute(app: FastifyInstance) {
const startTime = Date.now();
try {
const accountProxy = getProxyUrlFromExtraConfig(selected.account.extraConfig);
const requestInit = multipartForm
? withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
@@ -170,7 +160,7 @@ export async function imagesProxyRoute(app: FastifyInstance) {
body: cloneFormDataWithOverrides(multipartForm, {
model: selected.actualModel || requestedModel,
}) as any,
}, accountProxy)
})
: withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: {
@@ -181,13 +171,13 @@ export async function imagesProxyRoute(app: FastifyInstance) {
...(jsonBody || {}),
model: selected.actualModel || requestedModel,
}),
}, accountProxy);
});
const upstream = await fetch(targetUrl, requestInit);
const text = await upstream.text();
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, 0, '/v1/images/edits');
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, 0, '/v1/images/edits');
if (isTokenExpiredError({ status: upstream.status, message: text })) {
await reportTokenExpired({
accountId: selected.account.id,
@@ -221,11 +211,11 @@ export async function imagesProxyRoute(app: FastifyInstance) {
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, estimatedCost, '/v1/images/edits');
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, estimatedCost, '/v1/images/edits');
return reply.code(upstream.status).send(data);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, 0, '/v1/images/edits');
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, 0, '/v1/images/edits');
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
@@ -259,6 +249,7 @@ async function logProxy(
latencyMs: number,
errorMessage: string | null,
retryCount: number,
downstreamApiKeyId: number | null = null,
estimatedCost = 0,
downstreamPath = '/v1/images/generations',
) {
@@ -272,6 +263,7 @@ async function logProxy(
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
modelRequested,
modelActual: selected.actualModel,
status,
+105
View File
@@ -7,11 +7,13 @@ import { join } from 'node:path';
type DbModule = typeof import('../../db/index.js');
type ProxyRouterModule = typeof import('./router.js');
type TokenRouterModule = typeof import('../../services/tokenRouter.js');
type TokensRoutesModule = typeof import('../api/tokens.js');
describe('/v1/models route', () => {
let db: DbModule['db'];
let schema: DbModule['schema'];
let proxyRoutes: ProxyRouterModule['proxyRoutes'];
let tokensRoutes: TokensRoutesModule['tokensRoutes'];
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
let app: FastifyInstance;
let dataDir = '';
@@ -24,13 +26,16 @@ describe('/v1/models route', () => {
const dbModule = await import('../../db/index.js');
const proxyRouterModule = await import('./router.js');
const tokenRouterModule = await import('../../services/tokenRouter.js');
const tokensRoutesModule = await import('../api/tokens.js');
db = dbModule.db;
schema = dbModule.schema;
proxyRoutes = proxyRouterModule.proxyRoutes;
tokensRoutes = tokensRoutesModule.tokensRoutes;
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
app = Fastify();
await app.register(tokensRoutes);
await app.register(proxyRoutes);
});
@@ -285,6 +290,106 @@ describe('/v1/models route', () => {
expect(ids).not.toContain('claude-sonnet-4-5');
});
it('returns only explicit-group public name while hiding source exact routes', async () => {
const site = await db.insert(schema.sites).values({
name: 'explicit-group-site',
url: 'https://explicit-group.example.com',
platform: 'openai',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
accessToken: 'explicit-group-access-token',
status: 'active',
}).returning().get();
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'default',
token: 'explicit-group-api-token',
enabled: true,
isDefault: true,
}).returning().get();
await db.insert(schema.modelAvailability).values([
{
accountId: account.id,
modelName: 'claude-opus-4-5',
available: true,
},
{
accountId: account.id,
modelName: 'claude-sonnet-4-5',
available: true,
},
]).run();
const sourceRouteA = await db.insert(schema.tokenRoutes).values({
modelPattern: 'claude-opus-4-5',
enabled: true,
}).returning().get();
const sourceRouteB = await db.insert(schema.tokenRoutes).values({
modelPattern: 'claude-sonnet-4-5',
enabled: true,
}).returning().get();
await db.insert(schema.routeChannels).values([
{
routeId: sourceRouteA.id,
accountId: account.id,
tokenId: token.id,
sourceModel: 'claude-opus-4-5',
enabled: true,
},
{
routeId: sourceRouteB.id,
accountId: account.id,
tokenId: token.id,
sourceModel: 'claude-sonnet-4-5',
enabled: true,
},
]).run();
const groupResponse = await app.inject({
method: 'POST',
url: '/api/routes',
payload: {
routeMode: 'explicit_group',
displayName: 'claude-opus-4-6',
sourceRouteIds: [sourceRouteA.id, sourceRouteB.id],
},
});
expect(groupResponse.statusCode).toBe(200);
const groupId = (groupResponse.json() as { id: number }).id;
await db.insert(schema.downstreamApiKeys).values({
name: 'managed-explicit-group-key',
key: 'sk-managed-explicit-group',
enabled: true,
allowedRouteIds: JSON.stringify([groupId]),
}).run();
const response = await app.inject({
method: 'GET',
url: '/v1/models',
headers: {
authorization: 'Bearer sk-managed-explicit-group',
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
object: 'list';
data: Array<{ id: string }>;
};
const ids = body.data.map((item) => item.id);
expect(ids).toContain('claude-opus-4-6');
expect(ids).not.toContain('claude-opus-4-5');
expect(ids).not.toContain('claude-sonnet-4-5');
});
it('filters search pseudo models out of /v1/models', async () => {
const site = await db.insert(schema.sites).values({
name: 'search-site',
+1 -24
View File
@@ -1,6 +1,4 @@
import { FastifyInstance } from 'fastify';
import { db, schema } from '../../db/index.js';
import { and, eq } from 'drizzle-orm';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { getDownstreamRoutingPolicy } from './downstreamPolicy.js';
import { tokenRouter } from '../../services/tokenRouter.js';
@@ -17,28 +15,7 @@ export async function modelsProxyRoute(app: FastifyInstance) {
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const readModels = async () => {
const rows = await db.select({ modelName: schema.modelAvailability.modelName })
.from(schema.modelAvailability)
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(
and(
eq(schema.modelAvailability.available, true),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.all();
const routeAliases = (await db.select({ displayName: schema.tokenRoutes.displayName })
.from(schema.tokenRoutes)
.where(eq(schema.tokenRoutes.enabled, true))
.all())
.map((row) => (row.displayName || '').trim())
.filter((name) => name.length > 0);
const deduped = Array.from(new Set([
...rows.map((r) => r.modelName),
...routeAliases,
]))
const deduped = Array.from(new Set(await tokenRouter.getAvailableModels()))
.filter((modelName) => !isSearchPseudoModel(modelName))
.sort();
const allowed: string[] = [];
@@ -0,0 +1,470 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const fetchMock = vi.fn();
const selectChannelMock = vi.fn();
const selectNextChannelMock = vi.fn();
const recordSuccessMock = vi.fn();
const recordFailureMock = vi.fn();
const refreshModelsAndRebuildRoutesMock = vi.fn();
const reportProxyAllFailedMock = vi.fn();
const reportTokenExpiredMock = vi.fn();
const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => ({
...usage,
estimatedCostFromQuota: 0,
recoveredFromSelfLog: false,
}));
const refreshOauthAccessTokenSingleflightMock = vi.fn();
const recordOauthQuotaResetHintMock = vi.fn();
const insertedProxyLogs: Record<string, unknown>[] = [];
const dbInsertMock = vi.fn((_arg?: any) => ({
values: (values: Record<string, unknown>) => {
insertedProxyLogs.push(values);
return {
run: () => undefined,
};
},
}));
vi.mock('undici', () => ({
fetch: (...args: unknown[]) => fetchMock(...args),
}));
vi.mock('../../services/tokenRouter.js', () => ({
tokenRouter: {
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
recordSuccess: (...args: unknown[]) => recordSuccessMock(...args),
recordFailure: (...args: unknown[]) => recordFailureMock(...args),
},
}));
vi.mock('../../services/modelService.js', () => ({
refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args),
}));
vi.mock('../../services/alertService.js', () => ({
reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args),
reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args),
}));
vi.mock('../../services/alertRules.js', () => ({
isTokenExpiredError: ({ status }: { status?: number }) => status === 401,
}));
vi.mock('../../services/modelPricingService.js', () => ({
estimateProxyCost: async () => 0,
buildProxyBillingDetails: async () => null,
fetchModelPricingCatalog: async () => null,
}));
vi.mock('../../services/proxyRetryPolicy.js', () => ({
shouldRetryProxyRequest: () => false,
}));
vi.mock('../../services/proxyUsageFallbackService.js', () => ({
resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg),
}));
vi.mock('../../services/oauth/refreshSingleflight.js', () => ({
refreshOauthAccessTokenSingleflight: (...args: unknown[]) => refreshOauthAccessTokenSingleflightMock(...args),
}));
vi.mock('../../services/oauth/quota.js', () => ({
recordOauthQuotaResetHint: (...args: unknown[]) => recordOauthQuotaResetHintMock(...args),
}));
vi.mock('../../db/index.js', () => ({
db: {
insert: (arg: any) => dbInsertMock(arg),
},
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
schema: {
proxyLogs: {},
},
}));
describe('responses proxy codex oauth refresh', () => {
let app: FastifyInstance;
const createSseResponse = (chunks: string[], status = 200) => {
const encoder = new TextEncoder();
return new Response(new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
}), {
status,
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
});
};
beforeAll(async () => {
const { responsesProxyRoute } = await import('./responses.js');
app = Fastify();
await app.register(responsesProxyRoute);
});
beforeEach(() => {
fetchMock.mockReset();
selectChannelMock.mockReset();
selectNextChannelMock.mockReset();
recordSuccessMock.mockReset();
recordFailureMock.mockReset();
refreshModelsAndRebuildRoutesMock.mockReset();
reportProxyAllFailedMock.mockReset();
reportTokenExpiredMock.mockReset();
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
refreshOauthAccessTokenSingleflightMock.mockReset();
recordOauthQuotaResetHintMock.mockReset();
dbInsertMock.mockClear();
insertedProxyLogs.length = 0;
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'codex-site', url: 'https://chatgpt.com/backend-api/codex', platform: 'codex' },
account: {
id: 33,
username: 'codex-user@example.com',
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-123',
email: 'codex-user@example.com',
planType: 'plus',
},
}),
},
tokenName: 'default',
tokenValue: 'expired-access-token',
actualModel: 'gpt-5.2-codex',
});
selectNextChannelMock.mockReturnValue(null);
refreshOauthAccessTokenSingleflightMock.mockResolvedValue({
accessToken: 'fresh-access-token',
accountId: 33,
accountKey: 'chatgpt-account-123',
});
});
afterAll(async () => {
await app.close();
});
it('refreshes codex oauth token and retries the same responses request on 401', async () => {
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
error: { message: 'expired token', type: 'invalid_request_error' },
}), {
status: 401,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'resp_codex_refreshed',
object: 'response',
model: 'gpt-5.2-codex',
status: 'completed',
output_text: 'ok after codex token refresh',
usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
headers: {
'user-agent': 'CodexClient/1.0',
},
payload: {
model: 'gpt-5.2-codex',
input: 'hello codex',
},
});
expect(response.statusCode).toBe(200);
expect(refreshOauthAccessTokenSingleflightMock).toHaveBeenCalledWith(33);
expect(fetchMock).toHaveBeenCalledTimes(2);
const [firstUrl, firstOptions] = fetchMock.mock.calls[0] as [string, any];
const [secondUrl, secondOptions] = fetchMock.mock.calls[1] as [string, any];
expect(firstUrl).toBe('https://chatgpt.com/backend-api/codex/responses');
expect(secondUrl).toBe('https://chatgpt.com/backend-api/codex/responses');
expect(firstOptions.headers.Authorization).toBe('Bearer expired-access-token');
expect(secondOptions.headers.Authorization).toBe('Bearer fresh-access-token');
expect(secondOptions.headers.Originator || secondOptions.headers.originator).toBe('codex_cli_rs');
expect(secondOptions.headers['Chatgpt-Account-Id'] || secondOptions.headers['chatgpt-account-id']).toBe('chatgpt-account-123');
expect(secondOptions.headers.Version || secondOptions.headers.version).toBe('0.101.0');
expect(String(secondOptions.headers.Session_id || secondOptions.headers.session_id || '')).toMatch(/^[0-9a-f-]{36}$/i);
expect(secondOptions.headers.Conversation_id || secondOptions.headers.conversation_id).toBeUndefined();
expect(secondOptions.headers['User-Agent'] || secondOptions.headers['user-agent']).toBe('CodexClient/1.0');
expect(secondOptions.headers.Accept || secondOptions.headers.accept).toBe('text/event-stream');
expect(secondOptions.headers.Connection || secondOptions.headers.connection).toBe('Keep-Alive');
expect(response.json()?.output_text).toContain('ok after codex token refresh');
});
it('sends an explicit empty instructions field to codex responses when downstream body has no system prompt', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'resp_codex_no_system',
object: 'response',
model: 'gpt-5.2-codex',
status: 'completed',
output_text: 'ok without system prompt',
usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'gpt-5.2-codex',
input: 'hello codex',
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, options] = fetchMock.mock.calls[0] as [string, any];
const forwardedBody = JSON.parse(options.body);
expect(forwardedBody.instructions).toBe('');
expect(forwardedBody.prompt_cache_key).toBeUndefined();
expect(forwardedBody.stream).toBe(true);
expect(forwardedBody.input).toEqual([
{
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'hello codex' }],
},
]);
});
it('preserves explicit prompt_cache_key for codex responses requests', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'resp_codex_with_cache_key',
object: 'response',
model: 'gpt-5.2-codex',
status: 'completed',
output_text: 'ok with cache key',
usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'gpt-5.2-codex',
prompt_cache_key: 'codex-cache-123',
input: 'hello codex',
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, options] = fetchMock.mock.calls[0] as [string, any];
const forwardedBody = JSON.parse(options.body);
expect(options.headers.Session_id || options.headers.session_id).toBe('codex-cache-123');
expect(options.headers.Conversation_id || options.headers.conversation_id).toBe('codex-cache-123');
expect(forwardedBody.prompt_cache_key).toBe('codex-cache-123');
});
it('strips generic downstream headers before forwarding codex responses upstream', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'resp_codex_header_filter',
object: 'response',
model: 'gpt-5.2-codex',
status: 'completed',
output_text: 'ok with filtered headers',
usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
headers: {
'openai-beta': 'responses-2025-03-11',
'x-openai-client-user-agent': '{"client":"openclaw"}',
origin: 'https://openclaw.example',
referer: 'https://openclaw.example/app',
'user-agent': 'OpenClaw/1.0',
version: '0.202.0',
session_id: 'session-from-client',
},
payload: {
model: 'gpt-5.2-codex',
input: 'hello codex',
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, options] = fetchMock.mock.calls[0] as [string, any];
expect(options.headers.Version || options.headers.version).toBe('0.202.0');
expect(options.headers.Session_id || options.headers.session_id).toBe('session-from-client');
expect(options.headers['User-Agent'] || options.headers['user-agent']).toBe('OpenClaw/1.0');
expect(options.headers['openai-beta']).toBeUndefined();
expect(options.headers['x-openai-client-user-agent']).toBeUndefined();
expect(options.headers.origin).toBeUndefined();
expect(options.headers.referer).toBeUndefined();
});
it('records codex usage_limit_reached reset hints on upstream 429 failures', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
error: {
type: 'usage_limit_reached',
resets_at: 1773800400,
message: 'quota exceeded',
},
}), {
status: 429,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'gpt-5.2-codex',
input: 'hello codex',
},
});
expect(response.statusCode).toBe(429);
expect(recordOauthQuotaResetHintMock).toHaveBeenCalledWith({
accountId: 33,
statusCode: 429,
errorText: JSON.stringify({
error: {
type: 'usage_limit_reached',
resets_at: 1773800400,
message: 'quota exceeded',
},
}),
});
});
it('forces codex upstream responses requests to stream and aggregates the SSE payload for non-stream downstream callers', async () => {
fetchMock.mockResolvedValue(createSseResponse([
'event: response.created\n',
'data: {"type":"response.created","response":{"id":"resp_codex_stream","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
'event: response.output_item.added\n',
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_stream","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n',
'event: response.output_text.delta\n',
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_stream","delta":"pong"}\n\n',
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_codex_stream","model":"gpt-5.4","status":"completed","usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n',
'data: [DONE]\n\n',
]));
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'gpt-5.4',
input: 'hello codex',
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, options] = fetchMock.mock.calls[0] as [string, any];
const forwardedBody = JSON.parse(options.body);
expect(forwardedBody.stream).toBe(true);
expect(forwardedBody.instructions).toBe('');
expect(forwardedBody.store).toBe(false);
expect(response.json()).toMatchObject({
id: 'resp_codex_stream',
model: 'gpt-5.4',
status: 'completed',
output_text: 'pong',
usage: {
input_tokens: 3,
output_tokens: 1,
total_tokens: 4,
},
});
});
it('preserves codex-required instructions and store fields across responses compatibility retries', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
error: { message: 'upstream_error', type: 'upstream_error' },
}), {
status: 400,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'gpt-5.2-codex',
input: 'hello codex',
metadata: { trace: 'compatibility-retry' },
},
});
expect(response.statusCode).toBe(400);
expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(2);
const [, firstOptions] = fetchMock.mock.calls[0] as [string, any];
const [, secondOptions] = fetchMock.mock.calls[1] as [string, any];
const firstBody = JSON.parse(firstOptions.body);
const secondBody = JSON.parse(secondOptions.body);
expect(firstBody.instructions).toBe('');
expect(firstBody.store).toBe(false);
expect(firstBody.stream).toBe(true);
expect(secondBody.instructions).toBe('');
expect(secondBody.store).toBe(false);
expect(secondBody.stream).toBe(true);
});
it('does not record success when a streaming responses request ends with response.failed', async () => {
fetchMock.mockResolvedValue(createSseResponse([
'event: response.created\n',
'data: {"type":"response.created","response":{"id":"resp_codex_failed","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
'event: response.failed\n',
'data: {"type":"response.failed","response":{"id":"resp_codex_failed","model":"gpt-5.4","status":"failed","error":{"message":"tool execution failed"}}}\n\n',
'data: [DONE]\n\n',
]));
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'gpt-5.4',
input: 'hello codex',
stream: true,
},
});
expect(response.statusCode).toBe(200);
expect(response.body).toContain('response.failed');
expect(response.body).not.toContain('response.completed');
expect(recordSuccessMock).not.toHaveBeenCalled();
expect(recordFailureMock).toHaveBeenCalledTimes(1);
expect(insertedProxyLogs.at(-1)).toMatchObject({
status: 'failed',
httpStatus: 200,
});
expect(String(insertedProxyLogs.at(-1)?.errorMessage || '')).toContain('tool execution failed');
});
});
+163 -25
View File
@@ -1,6 +1,5 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { fetch } from 'undici';
import { db, schema } from '../../db/index.js';
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
import { tokenRouter } from '../../services/tokenRouter.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
@@ -17,17 +16,30 @@ import {
} from './upstreamEndpoint.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
import { composeProxyLogMessage } from './logPathMeta.js';
import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js';
import { executeEndpointFlow, withUpstreamPath, type BuiltEndpointRequest } from './endpointFlow.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from './proxyBilling.js';
import { getProxyResourceOwner } from '../../middleware/auth.js';
import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js';
import { detectDownstreamClientContext, isCodexResponsesSurface, type DownstreamClientContext } from './downstreamClientContext.js';
import { dispatchRuntimeRequest } from './runtimeExecutor.js';
import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js';
import {
ProxyInputFileResolutionError,
hasNonImageFileInputInOpenAiBody,
resolveResponsesBodyInputFiles,
} from '../../services/proxyInputFileResolver.js';
import {
buildOauthProviderHeaders,
} from '../../services/oauth/service.js';
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js';
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js';
import { collectResponsesFinalPayloadFromSse } from './responsesSseFinal.js';
import {
createGeminiCliStreamReader,
unwrapGeminiCliPayload,
} from './geminiCliCompat.js';
import { ensureResponsesWebsocketTransport } from './responsesWebsocket.js';
const MAX_RETRIES = 2;
@@ -113,7 +125,15 @@ function carriesResponsesFileUrlInput(value: unknown): boolean {
type UsageSummary = ReturnType<typeof parseProxyUsage>;
function deriveCodexExplicitSessionId(body: Record<string, unknown>): string | null {
const promptCacheKey = typeof body.prompt_cache_key === 'string'
? body.prompt_cache_key.trim()
: '';
return promptCacheKey || null;
}
export async function responsesProxyRoute(app: FastifyInstance) {
ensureResponsesWebsocketTransport(app);
const handleResponsesRequest = async (
request: FastifyRequest,
reply: FastifyReply,
@@ -148,6 +168,9 @@ export async function responsesProxyRoute(app: FastifyInstance) {
}
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
const logDownstreamApiKeyId = downstreamApiKeyId !== null
&& await hasProxyLogDownstreamApiKeyIdColumn();
const excludeChannelIds: number[] = [];
let retryCount = 0;
@@ -174,12 +197,17 @@ export async function responsesProxyRoute(app: FastifyInstance) {
excludeChannelIds.push(selected.channel.id);
const modelName = selected.actualModel || requestedModel;
const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
const isCodexSite = String(selected.site.platform || '').trim().toLowerCase() === 'codex';
const owner = getProxyResourceOwner(request);
let normalizedResponsesBody: Record<string, unknown> = {
...requestEnvelope.parsed.normalizedBody,
model: modelName,
stream: isStream,
};
if (body.generate === false) {
normalizedResponsesBody.generate = false;
}
if (owner) {
try {
normalizedResponsesBody = await resolveResponsesBodyInputFiles(normalizedResponsesBody, owner);
@@ -226,18 +254,30 @@ export async function responsesProxyRoute(app: FastifyInstance) {
if (endpointCandidates.length === 0) {
endpointCandidates.push('responses', 'chat', 'messages');
}
const buildProviderHeaders = () => (
buildOauthProviderHeaders({
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
downstreamHeaders: request.headers as Record<string, unknown>,
})
);
const buildEndpointRequest = (endpoint: 'chat' | 'messages' | 'responses') => {
const upstreamStream = isStream || (isCodexSite && endpoint === 'responses');
const codexExplicitSessionId = deriveCodexExplicitSessionId(normalizedResponsesBody);
const endpointRequest = buildUpstreamEndpointRequest({
endpoint,
modelName,
stream: isStream,
stream: upstreamStream,
tokenValue: selected.tokenValue,
oauthProvider: oauth?.provider,
oauthProjectId: oauth?.projectId,
sitePlatform: selected.site.platform,
siteUrl: selected.site.url,
openaiBody: openAiBody,
downstreamFormat: 'responses',
responsesOriginalBody: normalizedResponsesBody,
downstreamHeaders: request.headers as Record<string, unknown>,
providerHeaders: buildProviderHeaders(),
codexExplicitSessionId,
});
const upstreamPath = (
isCompactRequest && endpoint === 'responses'
@@ -249,20 +289,54 @@ export async function responsesProxyRoute(app: FastifyInstance) {
path: upstreamPath,
headers: endpointRequest.headers,
body: endpointRequest.body as Record<string, unknown>,
runtime: endpointRequest.runtime,
};
};
const endpointStrategy = openAiResponsesTransformer.compatibility.createEndpointStrategy({
isStream,
requiresNativeResponsesFileUrl,
dispatchRequest: (compatibilityRequest, targetUrl) => fetch(
targetUrl ?? `${selected.site.url}${compatibilityRequest.path}`,
withSiteRecordProxyRequestInit(selected.site, {
const dispatchRequest = (compatibilityRequest: BuiltEndpointRequest, targetUrl?: string) => (
dispatchRuntimeRequest({
siteUrl: selected.site.url,
targetUrl,
request: compatibilityRequest,
buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: compatibilityRequest.headers,
body: JSON.stringify(compatibilityRequest.body),
}, getProxyUrlFromExtraConfig(selected.account.extraConfig)),
),
headers: requestForFetch.headers,
body: JSON.stringify(requestForFetch.body),
}),
})
);
const endpointStrategy = openAiResponsesTransformer.compatibility.createEndpointStrategy({
isStream: isStream || isCodexSite,
requiresNativeResponsesFileUrl,
dispatchRequest,
});
const tryRecover = async (ctx: Parameters<NonNullable<typeof endpointStrategy.tryRecover>>[0]) => {
if (ctx.response.status === 401 && oauth) {
try {
const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id);
selected.tokenValue = refreshed.accessToken;
selected.account = {
...selected.account,
accessToken: refreshed.accessToken,
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
};
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint);
const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`;
const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl);
if (refreshedResponse.ok) {
return {
upstream: refreshedResponse,
upstreamPath: refreshedRequest.path,
};
}
ctx.request = refreshedRequest;
ctx.response = refreshedResponse;
ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error');
} catch {
return endpointStrategy.tryRecover(ctx);
}
}
return endpointStrategy.tryRecover(ctx);
};
const startTime = Date.now();
@@ -272,7 +346,8 @@ export async function responsesProxyRoute(app: FastifyInstance) {
proxyUrl: resolveChannelProxyUrl(selected.site, selected.account.extraConfig),
endpointCandidates,
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
tryRecover: endpointStrategy.tryRecover,
dispatchRequest,
tryRecover,
shouldDowngrade: endpointStrategy.shouldDowngrade,
onDowngrade: (ctx) => {
logProxy(
@@ -291,6 +366,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
null,
null,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
},
});
@@ -298,6 +374,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
if (!endpointResult.ok) {
const status = endpointResult.status || 502;
const errText = endpointResult.errText || 'unknown error';
const rawErrText = endpointResult.rawErrText || errText;
tokenRouter.recordFailure(selected.channel.id);
logProxy(
selected,
@@ -315,7 +392,13 @@ export async function responsesProxyRoute(app: FastifyInstance) {
null,
null,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
await recordOauthQuotaResetHint({
accountId: selected.account.id,
statusCode: status,
errorText: rawErrText,
});
if (isTokenExpiredError({ status, message: errText })) {
await reportTokenExpired({
@@ -338,8 +421,8 @@ export async function responsesProxyRoute(app: FastifyInstance) {
return reply.code(status).send({ error: { message: errText, type: 'upstream_error' } });
}
const upstream = endpointResult.upstream;
const successfulUpstreamPath = endpointResult.upstreamPath;
const upstream = endpointResult.upstream;
const successfulUpstreamPath = endpointResult.upstreamPath;
if (isStream) {
reply.hijack();
@@ -349,7 +432,10 @@ export async function responsesProxyRoute(app: FastifyInstance) {
reply.raw.setHeader('Connection', 'keep-alive');
reply.raw.setHeader('X-Accel-Buffering', 'no');
const reader = upstream.body?.getReader();
const upstreamReader = upstream.body?.getReader();
const reader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader
? createGeminiCliStreamReader(upstreamReader)
: upstreamReader;
let parsedUsage: UsageSummary = {
promptTokens: 0,
completionTokens: 0,
@@ -364,6 +450,9 @@ export async function responsesProxyRoute(app: FastifyInstance) {
const streamSession = openAiResponsesTransformer.proxyStream.createSession({
modelName,
successfulUpstreamPath,
strictTerminalEvents: Object.entries(request.headers as Record<string, unknown>)
.some(([rawKey, rawValue]) => rawKey.trim().toLowerCase() === 'x-metapi-responses-websocket-transport'
&& String(rawValue).trim() === '1'),
getUsage: () => parsedUsage,
onParsedPayload: (payload) => {
if (payload && typeof payload === 'object') {
@@ -375,9 +464,32 @@ export async function responsesProxyRoute(app: FastifyInstance) {
reply.raw.write(chunk);
},
});
await streamSession.run(reader, reply.raw);
const streamResult = await streamSession.run(reader, reply.raw);
const latency = Date.now() - startTime;
if (streamResult.status === 'failed') {
tokenRouter.recordFailure(selected.channel.id);
logProxy(
selected,
requestedModel,
'failed',
200,
latency,
streamResult.errorMessage,
retryCount,
downstreamPath,
parsedUsage.promptTokens,
parsedUsage.completionTokens,
parsedUsage.totalTokens,
0,
null,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
return;
}
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
site: selected.site,
account: selected.account,
@@ -406,16 +518,30 @@ export async function responsesProxyRoute(app: FastifyInstance) {
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
return;
}
const rawText = await upstream.text();
let upstreamData: unknown = rawText;
try {
upstreamData = JSON.parse(rawText);
} catch {
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
let rawText = '';
let upstreamData: unknown;
if (upstreamContentType.includes('text/event-stream') && successfulUpstreamPath.endsWith('/responses')) {
const collected = await collectResponsesFinalPayloadFromSse(upstream, modelName);
rawText = collected.rawText;
upstreamData = collected.payload;
} else {
rawText = await upstream.text();
upstreamData = rawText;
try {
upstreamData = JSON.parse(rawText);
} catch {
upstreamData = rawText;
}
}
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
upstreamData = unwrapGeminiCliPayload(upstreamData);
}
const latency = Date.now() - startTime;
const parsedUsage = parseProxyUsage(upstreamData);
@@ -459,6 +585,8 @@ export async function responsesProxyRoute(app: FastifyInstance) {
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
return reply.send(downstreamData);
} catch (err: any) {
@@ -479,6 +607,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
null,
null,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
@@ -497,6 +626,13 @@ export async function responsesProxyRoute(app: FastifyInstance) {
app.post('/v1/responses', async (request: FastifyRequest, reply: FastifyReply) =>
handleResponsesRequest(request, reply, '/v1/responses'));
app.get('/v1/responses', async (_request: FastifyRequest, reply: FastifyReply) =>
reply.code(426).send({
error: {
message: 'WebSocket upgrade required for GET /v1/responses',
type: 'invalid_request_error',
},
}));
app.post('/v1/responses/compact', async (request: FastifyRequest, reply: FastifyReply) =>
handleResponsesRequest(request, reply, '/v1/responses/compact'));
}
@@ -517,6 +653,7 @@ async function logProxy(
billingDetails: unknown = null,
upstreamPath: string | null = null,
clientContext: DownstreamClientContext | null = null,
downstreamApiKeyId: number | null = null,
) {
try {
const createdAt = formatUtcSqlDateTime(new Date());
@@ -534,6 +671,7 @@ async function logProxy(
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
modelRequested,
modelActual: selected.actualModel,
status,
@@ -0,0 +1,554 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { AddressInfo } from 'node:net';
import WebSocket from 'ws';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const fetchMock = vi.fn();
const selectChannelMock = vi.fn();
const selectNextChannelMock = vi.fn();
const previewSelectedChannelMock = vi.fn();
const recordSuccessMock = vi.fn();
const recordFailureMock = vi.fn();
const refreshModelsAndRebuildRoutesMock = vi.fn();
const reportProxyAllFailedMock = vi.fn();
const reportTokenExpiredMock = vi.fn();
const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => ({
...usage,
estimatedCostFromQuota: 0,
recoveredFromSelfLog: false,
}));
const dbInsertMock = vi.fn((_arg?: any) => ({
values: () => ({
run: () => undefined,
}),
}));
vi.mock('undici', () => ({
fetch: (...args: unknown[]) => fetchMock(...args),
}));
vi.mock('../../services/tokenRouter.js', () => ({
tokenRouter: {
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
previewSelectedChannel: (...args: unknown[]) => previewSelectedChannelMock(...args),
recordSuccess: (...args: unknown[]) => recordSuccessMock(...args),
recordFailure: (...args: unknown[]) => recordFailureMock(...args),
},
}));
vi.mock('../../services/modelService.js', () => ({
refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args),
}));
vi.mock('../../services/alertService.js', () => ({
reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args),
reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args),
}));
vi.mock('../../services/alertRules.js', () => ({
isTokenExpiredError: () => false,
}));
vi.mock('../../services/modelPricingService.js', () => ({
estimateProxyCost: async () => 0,
buildProxyBillingDetails: async () => null,
fetchModelPricingCatalog: async () => null,
}));
vi.mock('../../services/proxyRetryPolicy.js', () => ({
shouldRetryProxyRequest: () => false,
}));
vi.mock('../../services/proxyUsageFallbackService.js', () => ({
resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg),
}));
vi.mock('../../services/oauth/quota.js', () => ({
recordOauthQuotaResetHint: async () => undefined,
}));
vi.mock('../../db/index.js', () => ({
db: {
insert: (arg: any) => dbInsertMock(arg),
},
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
schema: {
proxyLogs: {},
},
}));
function createSseResponse(chunks: string[], status = 200) {
const encoder = new TextEncoder();
return new Response(new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
}), {
status,
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
});
}
function createSelectedChannel(options?: {
siteName?: string;
siteUrl?: string;
sitePlatform?: string;
username?: string;
extraConfig?: string;
tokenValue?: string;
actualModel?: string;
}) {
const sitePlatform = options?.sitePlatform ?? 'codex';
const isCodex = sitePlatform === 'codex';
return {
channel: { id: 11, routeId: 22 },
site: {
name: options?.siteName ?? (isCodex ? 'codex-site' : 'openai-site'),
url: options?.siteUrl ?? (isCodex ? 'https://chatgpt.com/backend-api/codex' : 'https://api.openai.com'),
platform: sitePlatform,
},
account: {
id: 33,
username: options?.username ?? (isCodex ? 'codex-user@example.com' : 'openai-user@example.com'),
extraConfig: options?.extraConfig ?? (isCodex
? JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-123',
email: 'codex-user@example.com',
},
})
: '{}'),
},
tokenName: 'default',
tokenValue: options?.tokenValue ?? (isCodex ? 'oauth-access-token' : 'sk-openai-token'),
actualModel: options?.actualModel ?? (isCodex ? 'gpt-5.4' : 'gpt-4.1'),
};
}
function waitForSocketOpen(socket: WebSocket) {
return new Promise<void>((resolve, reject) => {
socket.once('open', () => resolve());
socket.once('error', reject);
});
}
function waitForSocketUpgrade(socket: WebSocket) {
return new Promise<{ headers: Record<string, string | string[] | undefined> }>((resolve, reject) => {
socket.once('upgrade', (response) => resolve({ headers: response.headers as Record<string, string | string[] | undefined> }));
socket.once('error', reject);
});
}
function waitForSocketMessages(socket: WebSocket, count: number, timeoutMs = 1000) {
return new Promise<any[]>((resolve, reject) => {
const messages: any[] = [];
const timeout = setTimeout(() => {
socket.off('message', onMessage);
socket.off('error', onError);
reject(new Error(`Timed out waiting for ${count} websocket messages`));
}, timeoutMs);
const onMessage = (payload: WebSocket.RawData) => {
messages.push(JSON.parse(String(payload)));
if (messages.length >= count) {
clearTimeout(timeout);
socket.off('message', onMessage);
socket.off('error', onError);
resolve(messages);
}
};
const onError = (error: Error) => {
clearTimeout(timeout);
socket.off('message', onMessage);
reject(error);
};
socket.on('message', onMessage);
socket.once('error', onError);
});
}
describe('responses websocket transport', () => {
let app: FastifyInstance;
let baseUrl: string;
beforeAll(async () => {
const { responsesProxyRoute } = await import('./responses.js');
app = Fastify();
await app.register(responsesProxyRoute);
await app.listen({ port: 0, host: '127.0.0.1' });
const address = app.server.address() as AddressInfo;
baseUrl = `ws://127.0.0.1:${address.port}`;
});
beforeEach(() => {
fetchMock.mockReset();
selectChannelMock.mockReset();
selectNextChannelMock.mockReset();
previewSelectedChannelMock.mockReset();
recordSuccessMock.mockReset();
recordFailureMock.mockReset();
refreshModelsAndRebuildRoutesMock.mockReset();
reportProxyAllFailedMock.mockReset();
reportTokenExpiredMock.mockReset();
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
dbInsertMock.mockClear();
const selectedChannel = createSelectedChannel();
selectChannelMock.mockReturnValue(selectedChannel);
selectNextChannelMock.mockReturnValue(null);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
});
afterAll(async () => {
await app.close();
});
it('accepts response.create over GET /v1/responses websocket and forwards streamed responses events', async () => {
fetchMock.mockResolvedValue(createSseResponse([
'event: response.created\n',
'data: {"type":"response.created","response":{"id":"resp_ws","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
'event: response.output_item.added\n',
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_ws","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n',
'event: response.output_text.delta\n',
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_ws","delta":"pong"}\n\n',
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_ws","model":"gpt-5.4","status":"completed","output":[{"id":"msg_ws","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"pong"}]}],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n',
'data: [DONE]\n\n',
]));
const socket = new WebSocket(`${baseUrl}/v1/responses`);
await waitForSocketOpen(socket);
const messagesPromise = waitForSocketMessages(socket, 4);
socket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-5.4',
input: [
{
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'hello websocket' }],
},
],
}));
const messages = await messagesPromise;
socket.close();
expect(messages.map((message) => message.type)).toEqual([
'response.created',
'response.output_item.added',
'response.output_text.delta',
'response.completed',
]);
expect(messages[3]?.response?.output?.[0]?.content?.[0]?.text).toBe('pong');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('echoes x-codex-turn-state on websocket upgrade responses', async () => {
const socket = new WebSocket(`${baseUrl}/v1/responses`, {
headers: {
'x-codex-turn-state': 'turn-state-123',
},
});
const [upgrade] = await Promise.all([
waitForSocketUpgrade(socket),
waitForSocketOpen(socket),
]);
socket.close();
expect(upgrade.headers['x-codex-turn-state']).toBe('turn-state-123');
});
it('merges follow-up response.create payloads when the selected upstream does not support incremental mode', async () => {
const selectedChannel = createSelectedChannel({
sitePlatform: 'openai',
actualModel: 'gpt-4.1',
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
fetchMock
.mockResolvedValueOnce(createSseResponse([
'event: response.output_item.done\n',
'data: {"type":"response.output_item.done","output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_1"}}\n\n',
'event: response.output_item.done\n',
'data: {"type":"response.output_item.done","output_index":1,"item":{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"call tool"}]}}\n\n',
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_ws_1","model":"gpt-4.1","status":"completed","output":[{"id":"fc_1","type":"function_call","call_id":"call_1"},{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"call tool"}]}],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n',
'data: [DONE]\n\n',
]))
.mockResolvedValueOnce(createSseResponse([
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_ws_2","model":"gpt-4.1","status":"completed","output":[{"id":"msg_2","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"done"}]}],"usage":{"input_tokens":5,"output_tokens":1,"total_tokens":6}}}\n\n',
'data: [DONE]\n\n',
]));
const socket = new WebSocket(`${baseUrl}/v1/responses`);
await waitForSocketOpen(socket);
const firstResponsePromise = waitForSocketMessages(socket, 1);
socket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-4.1',
instructions: 'be helpful',
input: [
{
id: 'msg_user_1',
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'call the tool' }],
},
],
}));
await firstResponsePromise;
const secondResponsePromise = waitForSocketMessages(socket, 1);
socket.send(JSON.stringify({
type: 'response.create',
previous_response_id: 'resp_ws_1',
input: [
{
id: 'tool_out_1',
type: 'function_call_output',
call_id: 'call_1',
output: 'tool result',
},
],
}));
await secondResponsePromise;
socket.close();
expect(fetchMock).toHaveBeenCalledTimes(2);
const [, firstOptions] = fetchMock.mock.calls[0] as [string, RequestInit];
const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit];
const firstBody = JSON.parse(String(firstOptions.body));
const secondBody = JSON.parse(String(secondOptions.body));
expect(firstBody.input).toHaveLength(1);
expect(secondBody.previous_response_id).toBeUndefined();
expect(secondBody.model).toBe('gpt-4.1');
expect(secondBody.instructions).toBe('be helpful');
expect(secondBody.input).toEqual([
{
id: 'msg_user_1',
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'call the tool' }],
},
{
id: 'fc_1',
type: 'function_call',
call_id: 'call_1',
status: 'completed',
},
{
id: 'msg_1',
type: 'message',
role: 'assistant',
status: 'completed',
content: [{ type: 'output_text', text: 'call tool' }],
},
{
id: 'tool_out_1',
type: 'function_call_output',
call_id: 'call_1',
output: 'tool result',
},
]);
});
it('preserves incremental response.create payloads with previous_response_id for websocket-capable upstreams', async () => {
fetchMock
.mockResolvedValueOnce(createSseResponse([
'event: response.output_item.done\n',
'data: {"type":"response.output_item.done","output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_1"}}\n\n',
'event: response.output_item.done\n',
'data: {"type":"response.output_item.done","output_index":1,"item":{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"call tool"}]}}\n\n',
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_ws_1","model":"gpt-5.4","status":"completed","output":[{"id":"fc_1","type":"function_call","call_id":"call_1"},{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"call tool"}]}],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n',
'data: [DONE]\n\n',
]))
.mockResolvedValueOnce(createSseResponse([
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_ws_2","model":"gpt-5.4","status":"completed","output":[{"id":"msg_2","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"done"}]}],"usage":{"input_tokens":5,"output_tokens":1,"total_tokens":6}}}\n\n',
'data: [DONE]\n\n',
]));
const socket = new WebSocket(`${baseUrl}/v1/responses`);
await waitForSocketOpen(socket);
const firstResponsePromise = waitForSocketMessages(socket, 1);
socket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-5.4',
instructions: 'be helpful',
input: [
{
id: 'msg_user_1',
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'call the tool' }],
},
],
}));
await firstResponsePromise;
const secondResponsePromise = waitForSocketMessages(socket, 1);
socket.send(JSON.stringify({
type: 'response.create',
previous_response_id: 'resp_ws_1',
input: [
{
id: 'tool_out_1',
type: 'function_call_output',
call_id: 'call_1',
output: 'tool result',
},
],
}));
await secondResponsePromise;
socket.close();
expect(fetchMock).toHaveBeenCalledTimes(2);
const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit];
const secondBody = JSON.parse(String(secondOptions.body));
expect(secondBody.previous_response_id).toBe('resp_ws_1');
expect(secondBody.model).toBe('gpt-5.4');
expect(secondBody.instructions).toBe('be helpful');
expect(secondBody.input).toEqual([
{
id: 'tool_out_1',
type: 'function_call_output',
call_id: 'call_1',
output: 'tool result',
},
]);
});
it('handles generate=false locally only for non-websocket-capable upstreams', async () => {
const selectedChannel = createSelectedChannel({
sitePlatform: 'openai',
actualModel: 'gpt-4.1',
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
fetchMock.mockResolvedValueOnce(createSseResponse([
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_ws_after_prewarm","model":"gpt-4.1","status":"completed","output":[{"id":"msg_2","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"done"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}\n\n',
'data: [DONE]\n\n',
]));
const socket = new WebSocket(`${baseUrl}/v1/responses`);
await waitForSocketOpen(socket);
const prewarmMessagesPromise = waitForSocketMessages(socket, 2);
socket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-4.1',
generate: false,
}));
const prewarmMessages = await prewarmMessagesPromise;
expect(prewarmMessages.map((message) => message.type)).toEqual(['response.created', 'response.completed']);
expect(fetchMock).toHaveBeenCalledTimes(0);
const secondResponsePromise = waitForSocketMessages(socket, 1);
socket.send(JSON.stringify({
type: 'response.create',
previous_response_id: prewarmMessages[0]?.response?.id,
input: [
{
id: 'msg_followup_1',
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'continue' }],
},
],
}));
await secondResponsePromise;
socket.close();
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit];
const forwardedBody = JSON.parse(String(options.body));
expect(forwardedBody.generate).toBeUndefined();
expect(forwardedBody.previous_response_id).toBeUndefined();
expect(forwardedBody.model).toBe('gpt-4.1');
expect(forwardedBody.input).toEqual([
{
id: 'msg_followup_1',
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'continue' }],
},
]);
});
it('forwards generate=false upstream for websocket-capable upstreams instead of synthesizing prewarm events', async () => {
fetchMock.mockResolvedValueOnce(createSseResponse([
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_capable_generate_false","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}\n\n',
'data: [DONE]\n\n',
]));
const socket = new WebSocket(`${baseUrl}/v1/responses`);
await waitForSocketOpen(socket);
const messagesPromise = waitForSocketMessages(socket, 1);
socket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-5.4',
generate: false,
}));
const messages = await messagesPromise;
socket.close();
expect(messages.map((message) => message.type)).toEqual(['response.completed']);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit];
const forwardedBody = JSON.parse(String(options.body));
expect(forwardedBody.generate).toBe(false);
});
it('emits websocket error when the upstream stream closes before a terminal responses event', async () => {
fetchMock.mockResolvedValue(createSseResponse([
'event: response.created\n',
'data: {"type":"response.created","response":{"id":"resp_incomplete","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
'event: response.output_text.delta\n',
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_ws","delta":"partial"}\n\n',
]));
const socket = new WebSocket(`${baseUrl}/v1/responses`);
await waitForSocketOpen(socket);
const messagesPromise = waitForSocketMessages(socket, 3, 400);
socket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-5.4',
input: [
{
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'hello websocket' }],
},
],
}));
const messages = await messagesPromise;
socket.close();
expect(messages.map((message) => message.type)).toEqual([
'response.created',
'response.output_text.delta',
'error',
]);
expect(messages[2]?.status).toBe(408);
expect(messages[2]?.error?.message).toContain('stream closed before response.completed');
});
});
@@ -0,0 +1,97 @@
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
function parseResponsesSsePayload(data: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(data);
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function getResponsesFailureMessage(payload: Record<string, unknown>): string {
if (isRecord(payload.error) && typeof payload.error.message === 'string' && payload.error.message.trim()) {
return payload.error.message.trim();
}
if (typeof payload.message === 'string' && payload.message.trim()) {
return payload.message.trim();
}
return 'upstream stream failed';
}
export async function collectResponsesFinalPayloadFromSse(
upstream: { text(): Promise<string> },
modelName: string,
): Promise<{ payload: Record<string, unknown>; rawText: string }> {
const rawText = await upstream.text();
const { events } = openAiResponsesTransformer.pullSseEvents(rawText);
const streamContext = openAiResponsesTransformer.createStreamContext(modelName);
const aggregateState = openAiResponsesTransformer.aggregator.createState(modelName);
let usage = {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
promptTokensIncludeCache: null as boolean | null,
};
let completedPayload: Record<string, unknown> | null = null;
const captureCompletedPayload = (lines: string[]) => {
if (completedPayload) return;
const parsed = openAiResponsesTransformer.pullSseEvents(lines.join(''));
for (const event of parsed.events) {
if (event.data === '[DONE]') continue;
const payload = parseResponsesSsePayload(event.data);
if (!payload) continue;
if (payload.type === 'response.failed') {
throw new Error(getResponsesFailureMessage(payload));
}
if (payload.type === 'response.completed' && isRecord(payload.response)) {
completedPayload = payload.response;
return;
}
}
};
for (const event of events) {
if (event.data === '[DONE]') continue;
const payload = parseResponsesSsePayload(event.data);
if (!payload) continue;
if (payload.type === 'response.failed') {
throw new Error(getResponsesFailureMessage(payload));
}
usage = mergeProxyUsage(usage, parseProxyUsage(payload));
const normalizedEvent = openAiResponsesTransformer.transformStreamEvent(payload, streamContext, modelName);
captureCompletedPayload(openAiResponsesTransformer.aggregator.serialize({
state: aggregateState,
streamContext,
event: normalizedEvent,
usage,
}));
}
if (!completedPayload) {
captureCompletedPayload(openAiResponsesTransformer.aggregator.complete(
aggregateState,
streamContext,
usage,
));
}
if (completedPayload) {
return {
payload: completedPayload,
rawText,
};
}
throw new Error('stream disconnected before response.completed');
}
@@ -0,0 +1,401 @@
import { randomUUID } from 'node:crypto';
import type { FastifyInstance } from 'fastify';
import type { IncomingMessage } from 'node:http';
import { WebSocketServer, type RawData, type WebSocket } from 'ws';
import { tokenRouter } from '../../services/tokenRouter.js';
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
const installedApps = new WeakSet<FastifyInstance>();
const WS_TURN_STATE_HEADER = 'x-codex-turn-state';
const RESPONSES_WEBSOCKET_MODE_HEADER = 'x-metapi-responses-websocket-mode';
const RESPONSES_WEBSOCKET_TRANSPORT_HEADER = 'x-metapi-responses-websocket-transport';
type NormalizedResponsesWebsocketRequest =
| {
ok: true;
request: Record<string, unknown>;
nextRequestSnapshot: Record<string, unknown>;
}
| {
ok: false;
status: number;
message: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function headerValueToTrimmedString(value: unknown): string {
if (typeof value === 'string') return value.trim();
if (Array.isArray(value)) {
for (const item of value) {
if (typeof item !== 'string') continue;
const trimmed = item.trim();
if (trimmed) return trimmed;
}
}
return '';
}
function parseJsonObject(raw: RawData): Record<string, unknown> | null {
try {
const parsed = JSON.parse(String(raw));
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function cloneJsonObject<T>(value: T): T {
return structuredClone(value);
}
function toResponseInputArray(value: unknown): unknown[] {
return Array.isArray(value) ? cloneJsonObject(value) : [];
}
function normalizeResponsesWebsocketRequest(
parsed: Record<string, unknown>,
lastRequest: Record<string, unknown> | null,
lastResponseOutput: unknown[],
supportsIncrementalInput: boolean,
): NormalizedResponsesWebsocketRequest {
const requestType = asTrimmedString(parsed.type);
if (requestType !== 'response.create' && requestType !== 'response.append') {
return {
ok: false,
status: 400,
message: `unsupported websocket request type: ${requestType || 'unknown'}`,
};
}
if (!lastRequest) {
if (requestType !== 'response.create') {
return {
ok: false,
status: 400,
message: 'websocket request received before response.create',
};
}
const next = cloneJsonObject(parsed);
delete next.type;
if (!supportsIncrementalInput && parsed.generate === false) {
delete next.generate;
}
next.stream = true;
if (!Array.isArray(next.input)) next.input = [];
const modelName = asTrimmedString(next.model);
if (!modelName) {
return {
ok: false,
status: 400,
message: 'missing model in response.create request',
};
}
return {
ok: true,
request: next,
nextRequestSnapshot: cloneJsonObject(next),
};
}
if (!Array.isArray(parsed.input)) {
return {
ok: false,
status: 400,
message: 'websocket request requires array field: input',
};
}
const next = cloneJsonObject(parsed);
delete next.type;
next.stream = true;
if (!('model' in next) && typeof lastRequest.model === 'string') {
next.model = lastRequest.model;
}
if (!('instructions' in next) && lastRequest.instructions !== undefined) {
next.instructions = cloneJsonObject(lastRequest.instructions);
}
if (supportsIncrementalInput && requestType === 'response.create' && asTrimmedString(parsed.previous_response_id)) {
return {
ok: true,
request: next,
nextRequestSnapshot: cloneJsonObject(next),
};
}
const mergedInput = [
...toResponseInputArray(lastRequest.input),
...cloneJsonObject(lastResponseOutput),
...cloneJsonObject(parsed.input),
];
delete next.previous_response_id;
next.input = mergedInput;
return {
ok: true,
request: next,
nextRequestSnapshot: cloneJsonObject(next),
};
}
function shouldHandleResponsesWebsocketPrewarmLocally(
parsed: Record<string, unknown>,
lastRequest: Record<string, unknown> | null,
supportsIncrementalInput: boolean,
): boolean {
if (supportsIncrementalInput || lastRequest) return false;
if (asTrimmedString(parsed.type) !== 'response.create') return false;
return parsed.generate === false;
}
function writeResponsesWebsocketError(
socket: WebSocket,
status: number,
message: string,
errorPayload?: unknown,
) {
socket.send(JSON.stringify({
type: 'error',
status,
error: isRecord(errorPayload) && isRecord(errorPayload.error)
? errorPayload.error
: {
type: status >= 500 ? 'server_error' : 'invalid_request_error',
message,
},
}));
}
function synthesizePrewarmResponsePayloads(request: Record<string, unknown>) {
const responseId = `resp_prewarm_${randomUUID()}`;
const modelName = asTrimmedString(request.model) || 'unknown';
const createdAt = Math.floor(Date.now() / 1000);
return [
{
type: 'response.created',
response: {
id: responseId,
object: 'response',
created_at: createdAt,
status: 'in_progress',
model: modelName,
output: [],
},
},
{
type: 'response.completed',
response: {
id: responseId,
object: 'response',
created_at: createdAt,
status: 'completed',
model: modelName,
output: [],
usage: {
input_tokens: 0,
output_tokens: 0,
total_tokens: 0,
},
},
},
];
}
function collectResponsesOutput(payloads: unknown[]): unknown[] {
const outputByIndex = new Map<number, unknown>();
let completedOutput: unknown[] | null = null;
for (const payload of payloads) {
if (!isRecord(payload)) continue;
const type = asTrimmedString(payload.type);
if ((type === 'response.output_item.added' || type === 'response.output_item.done')
&& Number.isInteger(payload.output_index)
&& payload.item !== undefined) {
outputByIndex.set(Number(payload.output_index), cloneJsonObject(payload.item));
continue;
}
if (type === 'response.completed' && isRecord(payload.response) && Array.isArray(payload.response.output)) {
completedOutput = cloneJsonObject(payload.response.output);
}
}
if (completedOutput) return completedOutput;
return [...outputByIndex.entries()]
.sort((left, right) => left[0] - right[0])
.map(([, value]) => value);
}
function buildInjectHeaders(request: IncomingMessage): Record<string, string | string[]> {
const headers: Record<string, string | string[]> = {};
for (const [rawKey, rawValue] of Object.entries(request.headers)) {
const key = rawKey.toLowerCase();
if (!rawValue) continue;
if (
key === 'host'
|| key === 'connection'
|| key === 'upgrade'
|| key === 'sec-websocket-key'
|| key === 'sec-websocket-version'
|| key === 'sec-websocket-extensions'
|| key === 'sec-websocket-protocol'
) {
continue;
}
headers[rawKey] = rawValue as string | string[];
}
return headers;
}
async function supportsResponsesWebsocketIncrementalInput(
parsed: Record<string, unknown>,
lastRequest: Record<string, unknown> | null,
): Promise<boolean> {
const requestModel = asTrimmedString(parsed.model) || asTrimmedString(lastRequest?.model);
if (!requestModel) return false;
try {
const selected = await tokenRouter.previewSelectedChannel(requestModel);
return asTrimmedString(selected?.site?.platform).toLowerCase() === 'codex';
} catch {
return false;
}
}
async function handleResponsesWebsocketConnection(
app: FastifyInstance,
socket: WebSocket,
request: IncomingMessage,
) {
let lastRequest: Record<string, unknown> | null = null;
let lastResponseOutput: unknown[] = [];
socket.on('message', async (raw) => {
const parsed = parseJsonObject(raw);
if (!parsed) {
writeResponsesWebsocketError(socket, 400, 'Invalid websocket JSON payload');
return;
}
const supportsIncrementalInput = await supportsResponsesWebsocketIncrementalInput(parsed, lastRequest);
const shouldHandleLocalPrewarm = shouldHandleResponsesWebsocketPrewarmLocally(
parsed,
lastRequest,
supportsIncrementalInput,
);
const normalized = normalizeResponsesWebsocketRequest(
parsed,
lastRequest,
lastResponseOutput,
supportsIncrementalInput,
);
if (!normalized.ok) {
writeResponsesWebsocketError(socket, normalized.status, normalized.message);
return;
}
lastRequest = normalized.nextRequestSnapshot;
if (shouldHandleLocalPrewarm) {
lastResponseOutput = [];
for (const payload of synthesizePrewarmResponsePayloads(normalized.request)) {
socket.send(JSON.stringify(payload));
}
return;
}
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
headers: {
...buildInjectHeaders(request),
[RESPONSES_WEBSOCKET_TRANSPORT_HEADER]: '1',
...(supportsIncrementalInput ? { [RESPONSES_WEBSOCKET_MODE_HEADER]: 'incremental' } : {}),
},
payload: normalized.request,
});
if (response.statusCode < 200 || response.statusCode >= 300) {
let payload: unknown = null;
try {
payload = JSON.parse(response.body);
} catch {
payload = null;
}
writeResponsesWebsocketError(
socket,
response.statusCode,
response.statusMessage || 'Upstream error',
payload,
);
return;
}
const contentType = String(response.headers['content-type'] || '').toLowerCase();
if (!contentType.includes('text/event-stream')) {
try {
const payload = JSON.parse(response.body);
socket.send(JSON.stringify(payload));
lastResponseOutput = isRecord(payload?.response) && Array.isArray(payload.response.output)
? cloneJsonObject(payload.response.output)
: [];
} catch {
writeResponsesWebsocketError(socket, 502, 'Unexpected non-JSON websocket proxy response');
}
return;
}
const pulled = openAiResponsesTransformer.pullSseEvents(response.body);
const forwardedPayloads: unknown[] = [];
let sawTerminalPayload = false;
for (const event of pulled.events) {
if (event.data === '[DONE]') continue;
try {
const payload = JSON.parse(event.data);
forwardedPayloads.push(payload);
const type = isRecord(payload) ? asTrimmedString(payload.type) : '';
if (type === 'response.completed' || type === 'response.failed') {
sawTerminalPayload = true;
}
socket.send(JSON.stringify(payload));
} catch {
// Ignore malformed SSE frames; the HTTP route already normalizes them.
}
}
lastResponseOutput = collectResponsesOutput(forwardedPayloads);
if (!sawTerminalPayload) {
writeResponsesWebsocketError(socket, 408, 'stream closed before response.completed');
}
});
}
export function ensureResponsesWebsocketTransport(app: FastifyInstance) {
if (installedApps.has(app)) return;
installedApps.add(app);
const websocketServer = new WebSocketServer({ noServer: true });
websocketServer.on('headers', (headers, request) => {
const turnState = headerValueToTrimmedString(request.headers[WS_TURN_STATE_HEADER]);
if (!turnState) return;
headers.push(`${WS_TURN_STATE_HEADER}: ${turnState}`);
});
app.server.on('upgrade', (request, socket, head) => {
const url = new URL(request.url || '/', 'http://localhost');
if (url.pathname !== '/v1/responses') return;
websocketServer.handleUpgrade(request, socket, head, (client) => {
void handleResponsesWebsocketConnection(app, client, request);
});
});
app.addHook('onClose', async () => {
await new Promise<void>((resolve) => {
websocketServer.close(() => resolve());
});
});
}
@@ -0,0 +1,221 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fetch } from 'undici';
import type { BuiltEndpointRequest } from './endpointFlow.js';
vi.mock('undici', () => ({
fetch: vi.fn(),
}));
const fetchMock = vi.mocked(fetch);
describe('dispatchRuntimeRequest', () => {
beforeEach(() => {
fetchMock.mockReset();
});
it('routes antigravity runtime requests through daily then sandbox base urls and rewrites the payload fingerprint', async () => {
const { dispatchRuntimeRequest } = await import('./runtimeExecutor.js');
const request: BuiltEndpointRequest = {
endpoint: 'chat',
path: '/v1internal:generateContent',
headers: {
Authorization: 'Bearer antigravity-token',
'Content-Type': 'application/json',
'User-Agent': 'google-api-nodejs-client/9.15.1',
},
body: {
project: 'project-demo',
model: 'gemini-3-pro-preview',
request: {
contents: [
{
role: 'user',
parts: [{ text: 'hello runtime executor' }],
},
],
},
},
runtime: {
executor: 'antigravity',
modelName: 'gemini-3-pro-preview',
stream: false,
},
};
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
error: { message: 'try fallback base url' },
}), {
status: 429,
headers: { 'content-type': 'application/json' },
}) as unknown as Awaited<ReturnType<typeof fetch>>)
.mockResolvedValueOnce(new Response(JSON.stringify({
response: { responseId: 'ok' },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}) as unknown as Awaited<ReturnType<typeof fetch>>);
const response = await dispatchRuntimeRequest({
siteUrl: 'https://cloudcode-pa.googleapis.com',
request,
buildInit: (_url, nextRequest) => ({
method: 'POST',
headers: nextRequest.headers,
body: JSON.stringify(nextRequest.body),
}),
});
expect(response.ok).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent');
expect(fetchMock.mock.calls[1]?.[0]).toBe('https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:generateContent');
const firstInit = fetchMock.mock.calls[0]?.[1] as RequestInit;
expect(firstInit.headers).toMatchObject({
Authorization: 'Bearer antigravity-token',
'Content-Type': 'application/json',
Accept: 'application/json',
'User-Agent': 'antigravity/1.19.6 darwin/arm64',
});
const upstreamBody = JSON.parse(String(firstInit.body));
expect(upstreamBody).toMatchObject({
project: 'project-demo',
model: 'gemini-3-pro-preview',
userAgent: 'antigravity',
requestType: 'agent',
request: {
sessionId: expect.any(String),
contents: [
{
role: 'user',
parts: [{ text: 'hello runtime executor' }],
},
],
},
});
expect(upstreamBody.requestId).toMatch(/^agent-[0-9a-f-]{36}$/i);
});
it('keeps gemini-cli countTokens payload lean while forcing a model-aware user agent', async () => {
const { dispatchRuntimeRequest } = await import('./runtimeExecutor.js');
const request: BuiltEndpointRequest = {
endpoint: 'chat',
path: '/v1internal:countTokens',
headers: {
Authorization: 'Bearer gemini-cli-token',
'Content-Type': 'application/json',
'User-Agent': 'GeminiCLI/0.31.0/unknown (win32; x64)',
'X-Goog-Api-Client': 'google-genai-sdk/1.41.0 gl-node/v22.19.0',
},
body: {
project: 'project-demo',
model: 'gemini-2.5-pro',
request: {
contents: [
{
role: 'user',
parts: [{ text: 'count these tokens' }],
},
],
},
},
runtime: {
executor: 'gemini-cli',
modelName: 'gemini-2.5-pro',
stream: false,
action: 'countTokens',
},
};
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
totalTokens: 12,
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}) as unknown as Awaited<ReturnType<typeof fetch>>);
const response = await dispatchRuntimeRequest({
siteUrl: 'https://cloudcode-pa.googleapis.com',
request,
buildInit: (_url, nextRequest) => ({
method: 'POST',
headers: nextRequest.headers,
body: JSON.stringify(nextRequest.body),
}),
});
expect(response.ok).toBe(true);
const requestInit = fetchMock.mock.calls[0]?.[1] as RequestInit;
expect(requestInit.headers).toMatchObject({
Authorization: 'Bearer gemini-cli-token',
'Content-Type': 'application/json',
'User-Agent': 'GeminiCLI/0.31.0/gemini-2.5-pro (win32; x64)',
'X-Goog-Api-Client': 'google-genai-sdk/1.41.0 gl-node/v22.19.0',
});
expect(JSON.parse(String(requestInit.body))).toEqual({
request: {
contents: [
{
role: 'user',
parts: [{ text: 'count these tokens' }],
},
],
},
});
});
it('retries antigravity runtime requests on transport errors before falling back to the next base url', async () => {
const { dispatchRuntimeRequest } = await import('./runtimeExecutor.js');
const request: BuiltEndpointRequest = {
endpoint: 'chat',
path: '/v1internal:generateContent',
headers: {
Authorization: 'Bearer antigravity-token',
'Content-Type': 'application/json',
},
body: {
project: 'project-demo',
model: 'gemini-3-pro-preview',
request: {
contents: [
{
role: 'user',
parts: [{ text: 'hello runtime executor' }],
},
],
},
},
runtime: {
executor: 'antigravity',
modelName: 'gemini-3-pro-preview',
stream: false,
},
};
fetchMock
.mockRejectedValueOnce(new Error('socket hang up'))
.mockResolvedValueOnce(new Response(JSON.stringify({
response: { responseId: 'ok' },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}) as unknown as Awaited<ReturnType<typeof fetch>>);
const response = await dispatchRuntimeRequest({
siteUrl: 'https://cloudcode-pa.googleapis.com',
request,
buildInit: (_url, nextRequest) => ({
method: 'POST',
headers: nextRequest.headers,
body: JSON.stringify(nextRequest.body),
}),
});
expect(response.ok).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent');
expect(fetchMock.mock.calls[1]?.[0]).toBe('https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:generateContent');
});
});
+346
View File
@@ -0,0 +1,346 @@
import { createHash, randomUUID } from 'node:crypto';
import { fetch, type RequestInit as UndiciRequestInit } from 'undici';
import type { BuiltEndpointRequest } from './endpointFlow.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
const ANTIGRAVITY_RUNTIME_BASE_URLS = [
'https://daily-cloudcode-pa.googleapis.com',
'https://daily-cloudcode-pa.sandbox.googleapis.com',
] as const;
type RuntimeDispatchInput = {
siteUrl: string;
request: BuiltEndpointRequest;
targetUrl?: string;
buildInit: (requestUrl: string, request: BuiltEndpointRequest) => Promise<UndiciRequestInit> | UndiciRequestInit;
};
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function withRequestBody(
request: BuiltEndpointRequest,
body: Record<string, unknown>,
headers?: Record<string, string>,
): BuiltEndpointRequest {
return {
...request,
headers: headers ? { ...headers } : { ...request.headers },
body,
};
}
async function performFetch(
input: RuntimeDispatchInput,
request: BuiltEndpointRequest,
requestUrl = input.targetUrl || buildUpstreamUrl(input.siteUrl, request.path),
): Promise<Awaited<ReturnType<typeof fetch>>> {
const init = await input.buildInit(requestUrl, request);
return fetch(requestUrl, init);
}
async function materializeErrorResponse(
response: Awaited<ReturnType<typeof fetch>>,
): Promise<Awaited<ReturnType<typeof fetch>>> {
if (response.ok) return response;
const text = await response.text().catch(() => '');
return new Response(text, {
status: response.status,
headers: response.headers,
}) as unknown as Awaited<ReturnType<typeof fetch>>;
}
function parseGeminiCliPreviewFallbackOrder(modelName: string): string[] {
switch (modelName.trim()) {
case 'gemini-2.5-pro':
return [];
case 'gemini-2.5-flash':
return [];
case 'gemini-2.5-flash-lite':
return [];
default:
return [];
}
}
function replaceGeminiCliModelInUserAgent(userAgent: string | undefined, modelName: string): string | undefined {
const raw = asTrimmedString(userAgent);
if (!raw) return undefined;
return raw.replace(/^GeminiCLI\/([^/]+)\/[^ ]+ /i, `GeminiCLI/$1/${modelName} `);
}
function buildGeminiCliAttemptRequest(request: BuiltEndpointRequest, modelName: string): BuiltEndpointRequest {
const body = structuredClone(request.body);
const action = request.runtime?.action;
if (action === 'countTokens') {
delete body.model;
delete body.project;
} else {
body.model = modelName;
}
const headers = { ...request.headers };
const nextUserAgent = replaceGeminiCliModelInUserAgent(
headers['User-Agent'] || headers['user-agent'],
modelName,
);
if (nextUserAgent) {
headers['User-Agent'] = nextUserAgent;
delete headers['user-agent'];
}
return withRequestBody(request, body, headers);
}
function antigravityRequestType(modelName: string): 'image_gen' | 'agent' {
return modelName.includes('image') ? 'image_gen' : 'agent';
}
function generateAntigravityProjectId(): string {
const adjectives = ['useful', 'bright', 'swift', 'calm', 'bold'];
const nouns = ['signal', 'river', 'rocket', 'forest', 'bridge'];
const adjective = adjectives[Math.floor(Math.random() * adjectives.length)] || 'useful';
const noun = nouns[Math.floor(Math.random() * nouns.length)] || 'signal';
const suffix = Math.floor(100000 + Math.random() * 900000);
return `${adjective}-${noun}-${suffix}`;
}
function extractFirstUserText(value: unknown): string {
if (!Array.isArray(value)) return '';
for (const content of value) {
if (!content || typeof content !== 'object' || Array.isArray(content)) continue;
const record = content as Record<string, unknown>;
if (asTrimmedString(record.role) !== 'user') continue;
const parts = Array.isArray(record.parts) ? record.parts : [];
for (const part of parts) {
if (!part || typeof part !== 'object' || Array.isArray(part)) continue;
const text = asTrimmedString((part as Record<string, unknown>).text);
if (text) return text;
}
}
return '';
}
function generateStableAntigravitySessionId(payload: Record<string, unknown>): string {
const firstUserText = extractFirstUserText(
payload.request && typeof payload.request === 'object' && !Array.isArray(payload.request)
? (payload.request as Record<string, unknown>).contents
: undefined,
);
if (!firstUserText) {
return `-${BigInt(`0x${randomUUID().replace(/-/g, '').slice(0, 16)}`).toString()}`;
}
const digest = createHash('sha256').update(firstUserText).digest('hex').slice(0, 16);
const bigint = BigInt(`0x${digest}`) & BigInt('0x7fffffffffffffff');
return `-${bigint.toString()}`;
}
function renameParametersJsonSchema(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => renameParametersJsonSchema(item));
}
if (!value || typeof value !== 'object') return value;
const input = value as Record<string, unknown>;
const output: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(input)) {
const nextKey = key === 'parametersJsonSchema' ? 'parameters' : key;
output[nextKey] = renameParametersJsonSchema(entry);
}
return output;
}
function deleteNestedMaxOutputTokens(payload: Record<string, unknown>): void {
const request = payload.request;
if (!request || typeof request !== 'object' || Array.isArray(request)) return;
const generationConfig = (request as Record<string, unknown>).generationConfig;
if (!generationConfig || typeof generationConfig !== 'object' || Array.isArray(generationConfig)) return;
delete (generationConfig as Record<string, unknown>).maxOutputTokens;
}
function buildAntigravityRuntimeBody(
originalBody: Record<string, unknown>,
modelName: string,
action?: NonNullable<BuiltEndpointRequest['runtime']>['action'],
): Record<string, unknown> {
const payload = renameParametersJsonSchema(structuredClone(originalBody)) as Record<string, unknown>;
if (action === 'countTokens') {
return payload;
}
const requestType = antigravityRequestType(modelName);
const projectId = asTrimmedString(payload.project) || generateAntigravityProjectId();
payload.model = modelName;
payload.project = projectId;
payload.userAgent = 'antigravity';
payload.requestType = requestType;
payload.requestId = requestType === 'image_gen'
? `image_gen/${Date.now()}/${randomUUID()}/12`
: `agent-${randomUUID()}`;
const request = payload.request;
if (request && typeof request === 'object' && !Array.isArray(request)) {
delete (request as Record<string, unknown>).safetySettings;
if (requestType !== 'image_gen') {
(request as Record<string, unknown>).sessionId = generateStableAntigravitySessionId(payload);
}
if (modelName.includes('claude')) {
const toolConfig = (
(request as Record<string, unknown>).toolConfig
&& typeof (request as Record<string, unknown>).toolConfig === 'object'
&& !Array.isArray((request as Record<string, unknown>).toolConfig)
)
? (request as Record<string, unknown>).toolConfig as Record<string, unknown>
: (((request as Record<string, unknown>).toolConfig = {}) as Record<string, unknown>);
toolConfig.functionCallingConfig = { mode: 'VALIDATED' };
} else {
deleteNestedMaxOutputTokens(payload);
}
}
return payload;
}
function antigravityShouldRetryNoCapacity(
status: number,
responseText: string,
): boolean {
if (status !== 503) return false;
return responseText.toLowerCase().includes('no capacity available');
}
function antigravityNoCapacityRetryDelay(attempt: number): number {
const delay = Math.min((attempt + 1) * 250, 2000);
return delay;
}
async function waitMs(ms: number): Promise<void> {
if (ms <= 0) return;
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function dispatchGeminiCliRuntimeRequest(
input: RuntimeDispatchInput,
): Promise<Awaited<ReturnType<typeof fetch>>> {
const baseModel = asTrimmedString(input.request.runtime?.modelName) || asTrimmedString(input.request.body.model);
const models = [baseModel, ...parseGeminiCliPreviewFallbackOrder(baseModel)].filter(Boolean);
let lastResponse: Awaited<ReturnType<typeof fetch>> | null = null;
for (const modelName of models.length > 0 ? models : [baseModel || 'unknown']) {
const attemptRequest = buildGeminiCliAttemptRequest(input.request, modelName);
const response = await performFetch(input, attemptRequest);
if (response.ok) return response;
if (response.status === 429) {
lastResponse = await materializeErrorResponse(response);
continue;
}
return materializeErrorResponse(response);
}
return lastResponse || performFetch(input, input.request);
}
async function dispatchCodexRuntimeRequest(
input: RuntimeDispatchInput,
): Promise<Awaited<ReturnType<typeof fetch>>> {
return performFetch(input, input.request);
}
async function dispatchClaudeRuntimeRequest(
input: RuntimeDispatchInput,
): Promise<Awaited<ReturnType<typeof fetch>>> {
return performFetch(input, input.request);
}
async function dispatchAntigravityRuntimeRequest(
input: RuntimeDispatchInput,
): Promise<Awaited<ReturnType<typeof fetch>>> {
const modelName = asTrimmedString(input.request.runtime?.modelName) || asTrimmedString(input.request.body.model);
const runtimeBody = buildAntigravityRuntimeBody(
input.request.body,
modelName,
input.request.runtime?.action,
);
const baseAttempts = 3;
let lastResponse: Awaited<ReturnType<typeof fetch>> | null = null;
attemptLoop:
for (let attempt = 0; attempt < baseAttempts; attempt += 1) {
for (const baseUrl of ANTIGRAVITY_RUNTIME_BASE_URLS) {
const requestUrl = `${baseUrl}${input.request.path}`;
const minimalHeaders: Record<string, string> = {
Authorization: input.request.headers.Authorization || input.request.headers.authorization || '',
'Content-Type': 'application/json',
Accept: input.request.runtime?.stream ? 'text/event-stream' : 'application/json',
'User-Agent': 'antigravity/1.19.6 darwin/arm64',
};
let response: Awaited<ReturnType<typeof fetch>>;
try {
response = await performFetch(
input,
withRequestBody(input.request, runtimeBody, minimalHeaders),
requestUrl,
);
} catch (error) {
if (baseUrl !== ANTIGRAVITY_RUNTIME_BASE_URLS[ANTIGRAVITY_RUNTIME_BASE_URLS.length - 1]) {
continue;
}
if (attempt + 1 < baseAttempts) {
continue attemptLoop;
}
throw error;
}
if (response.ok) return response;
const errorResponse = await materializeErrorResponse(response);
const errorText = await errorResponse.text().catch(() => '');
lastResponse = new Response(errorText, {
status: errorResponse.status,
headers: errorResponse.headers,
}) as unknown as Awaited<ReturnType<typeof fetch>>;
if (errorResponse.status === 429) {
continue;
}
if (antigravityShouldRetryNoCapacity(errorResponse.status, errorText)) {
if (baseUrl !== ANTIGRAVITY_RUNTIME_BASE_URLS[ANTIGRAVITY_RUNTIME_BASE_URLS.length - 1]) {
continue;
}
if (attempt + 1 < baseAttempts) {
await waitMs(antigravityNoCapacityRetryDelay(attempt));
continue attemptLoop;
}
}
return lastResponse;
}
}
return lastResponse || performFetch(input, withRequestBody(input.request, runtimeBody, {
Authorization: input.request.headers.Authorization || input.request.headers.authorization || '',
'Content-Type': 'application/json',
Accept: input.request.runtime?.stream ? 'text/event-stream' : 'application/json',
'User-Agent': 'antigravity/1.19.6 darwin/arm64',
}));
}
export async function dispatchRuntimeRequest(
input: RuntimeDispatchInput,
): Promise<Awaited<ReturnType<typeof fetch>>> {
const executor = input.request.runtime?.executor || 'default';
if (executor === 'codex') {
return dispatchCodexRuntimeRequest(input);
}
if (executor === 'claude') {
return dispatchClaudeRuntimeRequest(input);
}
if (executor === 'gemini-cli') {
return dispatchGeminiCliRuntimeRequest(input);
}
if (executor === 'antigravity') {
return dispatchAntigravityRuntimeRequest(input);
}
return performFetch(input, input.request);
}
+10 -4
View File
@@ -1,6 +1,6 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { fetch } from 'undici';
import { db, schema } from '../../db/index.js';
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
import { tokenRouter } from '../../services/tokenRouter.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
@@ -11,6 +11,7 @@ import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js';
import { composeProxyLogMessage } from './logPathMeta.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { getProxyAuthContext } from '../../middleware/auth.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
const MAX_RETRIES = 2;
@@ -53,6 +54,9 @@ export async function searchProxyRoute(app: FastifyInstance) {
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
const logDownstreamApiKeyId = downstreamApiKeyId !== null
&& await hasProxyLogDownstreamApiKeyIdColumn();
const excludeChannelIds: number[] = [];
let retryCount = 0;
@@ -98,7 +102,7 @@ export async function searchProxyRoute(app: FastifyInstance) {
const text = await upstream.text();
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
if (isTokenExpiredError({ status: upstream.status, message: text })) {
await reportTokenExpired({
accountId: selected.account.id,
@@ -124,11 +128,11 @@ export async function searchProxyRoute(app: FastifyInstance) {
const latency = Date.now() - startTime;
tokenRouter.recordSuccess(selected.channel.id, latency, 0);
recordDownstreamCostUsage(request, 0);
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount);
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
return reply.code(upstream.status).send(data);
} catch (error: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, error?.message || 'network error', retryCount);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, error?.message || 'network error', retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
@@ -153,6 +157,7 @@ async function logProxy(
latencyMs: number,
errorMessage: string | null,
retryCount: number,
downstreamApiKeyId: number | null = null,
) {
try {
const createdAt = formatUtcSqlDateTime(new Date());
@@ -160,6 +165,7 @@ async function logProxy(
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
modelRequested,
modelActual: selected.actualModel,
status,
@@ -151,6 +151,16 @@ describe('resolveUpstreamEndpointCandidates', () => {
'claude',
);
expect(claudeOrder).toEqual(['messages']);
const codexOrder = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'codex', url: 'https://chatgpt.com/backend-api/codex' },
},
'gpt-5.2-codex',
'openai',
);
expect(codexOrder).toEqual(['responses']);
});
it('prefers document-capable endpoints when downstream content contains non-image files', async () => {
@@ -319,6 +329,339 @@ describe('buildUpstreamEndpointRequest', () => {
]);
});
it('builds codex responses requests against backend-api path and preserves oauth provider headers', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: 'gpt-5.2-codex',
stream: false,
tokenValue: 'oauth-access-token',
sitePlatform: 'codex',
siteUrl: 'https://chatgpt.com/backend-api/codex',
openaiBody: {
model: 'gpt-5.2-codex',
messages: [{ role: 'user', content: 'hello codex' }],
temperature: 0.2,
top_p: 0.9,
user: 'drop-me',
service_tier: 'auto',
},
downstreamFormat: 'openai',
providerHeaders: {
Originator: 'codex_cli_rs',
'Chatgpt-Account-Id': 'chatgpt-account-123',
},
codexSessionCacheKey: 'gpt-5.2-codex:proxy:test-key',
} as any);
expect(request.path).toBe('/responses');
expect(request.headers.Authorization).toBe('Bearer oauth-access-token');
expect(request.headers.Originator).toBe('codex_cli_rs');
expect(request.headers['Chatgpt-Account-Id']).toBe('chatgpt-account-123');
expect(request.headers.Version).toBe('0.101.0');
expect(request.headers.Session_id).toMatch(/^[0-9a-f-]{36}$/i);
expect(request.headers.Conversation_id).toBe(request.headers.Session_id);
expect(request.headers['User-Agent']).toBe('codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464');
expect(request.headers.Accept).toBe('text/event-stream');
expect(request.headers.Connection).toBe('Keep-Alive');
expect(request.body.instructions).toBe('');
expect(request.body.prompt_cache_key).toBe(request.headers.Session_id);
expect(request.body.stream).toBe(true);
expect(request.body.store).toBe(false);
expect(request.body.parallel_tool_calls).toBe(true);
expect(request.body.include).toEqual(['reasoning.encrypted_content']);
expect(request.body.max_output_tokens).toBeUndefined();
expect(request.body.temperature).toBeUndefined();
expect(request.body.top_p).toBeUndefined();
expect(request.body.user).toBeUndefined();
expect(request.body.service_tier).toBeUndefined();
});
it('reuses a stable codex session id when the same downstream continuity key is provided', () => {
const firstRequest = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: 'gpt-5.4',
stream: false,
tokenValue: 'oauth-access-token',
sitePlatform: 'codex',
siteUrl: 'https://chatgpt.com/backend-api/codex',
openaiBody: {
model: 'gpt-5.4',
messages: [{ role: 'user', content: 'hello codex' }],
},
downstreamFormat: 'openai',
providerHeaders: {
Originator: 'codex_cli_rs',
},
codexSessionCacheKey: 'gpt-5.4:user-123',
} as any);
const secondRequest = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: 'gpt-5.4',
stream: false,
tokenValue: 'oauth-access-token',
sitePlatform: 'codex',
siteUrl: 'https://chatgpt.com/backend-api/codex',
openaiBody: {
model: 'gpt-5.4',
messages: [{ role: 'user', content: 'hello again codex' }],
},
downstreamFormat: 'openai',
providerHeaders: {
Originator: 'codex_cli_rs',
},
codexSessionCacheKey: 'gpt-5.4:user-123',
} as any);
expect(firstRequest.headers.Session_id).toBe(secondRequest.headers.Session_id);
expect(firstRequest.headers.Conversation_id).toBe(secondRequest.headers.Conversation_id);
expect(firstRequest.body.prompt_cache_key).toBe(secondRequest.body.prompt_cache_key);
});
it('does not synthesize prompt_cache_key or conversation_id for native codex responses requests without one', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: 'gpt-5.4',
stream: false,
tokenValue: 'oauth-access-token',
sitePlatform: 'codex',
siteUrl: 'https://chatgpt.com/backend-api/codex',
openaiBody: {},
downstreamFormat: 'responses',
responsesOriginalBody: {
model: 'gpt-5.4',
input: 'hello codex',
},
providerHeaders: {
Originator: 'codex_cli_rs',
},
} as any);
expect(request.headers.Session_id).toMatch(/^[0-9a-f-]{36}$/i);
expect(request.headers.Conversation_id).toBeUndefined();
expect(request.body.prompt_cache_key).toBeUndefined();
});
it('preserves explicit prompt_cache_key for native codex responses requests', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: 'gpt-5.4',
stream: false,
tokenValue: 'oauth-access-token',
sitePlatform: 'codex',
siteUrl: 'https://chatgpt.com/backend-api/codex',
openaiBody: {},
downstreamFormat: 'responses',
responsesOriginalBody: {
model: 'gpt-5.4',
prompt_cache_key: 'codex-cache-123',
input: 'hello codex',
},
providerHeaders: {
Originator: 'codex_cli_rs',
},
codexExplicitSessionId: 'codex-cache-123',
} as any);
expect(request.headers.Session_id).toBe('codex-cache-123');
expect(request.headers.Conversation_id).toBe('codex-cache-123');
expect(request.body.prompt_cache_key).toBe('codex-cache-123');
});
it('builds gemini-cli native requests with project envelope and bearer headers', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: 'gemini-2.5-pro',
stream: true,
tokenValue: 'oauth-access-token',
oauthProvider: 'gemini-cli',
oauthProjectId: 'project-demo',
sitePlatform: 'gemini-cli',
siteUrl: 'https://cloudcode-pa.googleapis.com',
openaiBody: {
model: 'gemini-2.5-pro',
messages: [
{ role: 'system', content: 'be concise' },
{ role: 'user', content: 'hello gemini cli' },
],
temperature: 0.4,
},
downstreamFormat: 'openai',
providerHeaders: {
'User-Agent': 'GeminiCLI/0.31.0/unknown (win32; x64)',
'X-Goog-Api-Client': 'google-genai-sdk/1.41.0 gl-node/v22.19.0',
},
});
expect(request.path).toBe('/v1internal:streamGenerateContent?alt=sse');
expect(request.headers.Authorization).toBe('Bearer oauth-access-token');
expect(request.headers['User-Agent']).toBe('GeminiCLI/0.31.0/gemini-2.5-pro (win32; x64)');
expect(request.headers['X-Goog-Api-Client']).toContain('google-genai-sdk/');
expect(request.body.project).toBe('project-demo');
expect(request.body.model).toBe('gemini-2.5-pro');
expect(request.body.request).toMatchObject({
generationConfig: {
temperature: 0.4,
},
systemInstruction: {
role: 'user',
},
contents: [
{
role: 'user',
parts: [{ text: 'hello gemini cli' }],
},
],
});
});
it('builds antigravity native requests with the same internal Gemini envelope', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'chat',
modelName: 'gemini-3-pro-preview',
stream: false,
tokenValue: 'oauth-access-token',
oauthProvider: 'antigravity',
oauthProjectId: 'project-demo',
sitePlatform: 'antigravity',
siteUrl: 'https://cloudcode-pa.googleapis.com',
openaiBody: {
model: 'gemini-3-pro-preview',
messages: [
{ role: 'system', content: 'be concise' },
{ role: 'user', content: 'hello antigravity' },
],
},
downstreamFormat: 'openai',
providerHeaders: {
'User-Agent': 'google-api-nodejs-client/9.15.1',
'X-Goog-Api-Client': 'google-cloud-sdk vscode_cloudshelleditor/0.1',
},
});
expect(request.path).toBe('/v1internal:generateContent');
expect(request.headers.Authorization).toBe('Bearer oauth-access-token');
expect(request.headers['User-Agent']).toBe('antigravity/1.19.6 darwin/arm64');
expect(request.headers['X-Goog-Api-Client']).toBeUndefined();
expect(request.headers['Client-Metadata']).toBeUndefined();
expect(request.body).toEqual({
project: 'project-demo',
model: 'gemini-3-pro-preview',
request: {
systemInstruction: {
role: 'user',
parts: [{ text: 'be concise' }],
},
contents: [
{
role: 'user',
parts: [{ text: 'hello antigravity' }],
},
],
},
});
});
it('uses claude-code runtime headers for claude oauth upstream requests', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'messages',
modelName: 'claude-opus-4-6',
stream: true,
tokenValue: 'oauth-access-token',
oauthProvider: 'claude',
sitePlatform: 'claude',
siteUrl: 'https://api.anthropic.com',
openaiBody: {
model: 'claude-opus-4-6',
messages: [{ role: 'user', content: 'hello claude oauth' }],
},
downstreamFormat: 'openai',
});
expect(request.path).toBe('/v1/messages');
expect(request.headers.Authorization).toBe('Bearer oauth-access-token');
expect(request.headers['x-api-key']).toBeUndefined();
expect(request.headers['anthropic-version']).toBe('2023-06-01');
expect(request.headers['Anthropic-Dangerous-Direct-Browser-Access']).toBe('true');
expect(request.headers['X-App']).toBe('cli');
expect(request.headers['X-Stainless-Retry-Count']).toBe('0');
expect(request.headers['X-Stainless-Runtime-Version']).toBe('v24.3.0');
expect(request.headers['X-Stainless-Package-Version']).toBe('0.74.0');
expect(request.headers['X-Stainless-Runtime']).toBe('node');
expect(request.headers['X-Stainless-Lang']).toBe('js');
expect(request.headers['X-Stainless-Arch']).toBe('x64');
expect(request.headers['X-Stainless-Os']).toBe('Windows');
expect(request.headers['X-Stainless-Timeout']).toBe('600');
expect(request.headers['User-Agent']).toBe('claude-cli/2.1.63 (external, cli)');
expect(request.headers.Connection).toBe('keep-alive');
expect(request.headers.Accept).toBe('text/event-stream');
expect(request.headers['Accept-Encoding']).toBe('identity');
});
it('uses claude-code beta headers and compressed non-stream responses for claude upstream requests', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'messages',
modelName: 'claude-opus-4-6',
stream: false,
tokenValue: 'oauth-access-token',
oauthProvider: 'claude',
sitePlatform: 'claude',
siteUrl: 'https://api.anthropic.com',
openaiBody: {
model: 'claude-opus-4-6',
messages: [{ role: 'user', content: 'hello claude oauth' }],
},
downstreamFormat: 'openai',
});
expect(request.headers['anthropic-beta']).toContain('claude-code-20250219');
expect(request.headers['anthropic-beta']).toContain('oauth-2025-04-20');
expect(request.headers['anthropic-beta']).toContain('context-management-2025-06-27');
expect(request.headers.Accept).toBe('application/json');
expect(request.headers['Accept-Encoding']).toBe('gzip, deflate, br, zstd');
});
it('converts system roles to developer in native codex responses bodies', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: 'gpt-5.4',
stream: false,
tokenValue: 'oauth-access-token',
sitePlatform: 'codex',
siteUrl: 'https://chatgpt.com/backend-api/codex',
openaiBody: {},
downstreamFormat: 'responses',
responsesOriginalBody: {
model: 'gpt-5.4',
input: [
{
type: 'message',
role: 'system',
content: [{ type: 'input_text', text: 'be careful' }],
},
{
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'hello' }],
},
],
},
});
expect(request.body.input).toEqual([
{
type: 'message',
role: 'developer',
content: [{ type: 'input_text', text: 'be careful' }],
},
{
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'hello' }],
},
]);
});
it('normalizes downstream responses input string before forwarding upstream', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'responses',
+538 -13
View File
@@ -1,3 +1,4 @@
import { createHash, randomUUID } from 'node:crypto';
import { fetchModelPricingCatalog } from '../../services/modelPricingService.js';
import type { DownstreamFormat } from '../../transformers/shared/normalized.js';
import {
@@ -8,6 +9,10 @@ import {
convertOpenAiBodyToAnthropicMessagesBody,
sanitizeAnthropicMessagesBody,
} from '../../transformers/anthropic/messages/conversion.js';
import {
buildGeminiGenerateContentRequestFromOpenAi,
wrapGeminiCliRequest,
} from './geminiCliCompat.js';
export {
buildMinimalJsonHeadersForCompatibility,
isEndpointDispatchDeniedError,
@@ -94,6 +99,12 @@ const BLOCKED_PASSTHROUGH_HEADERS = new Set([
'sec-websocket-extensions',
]);
const CODEX_CLIENT_VERSION = '0.101.0';
const CODEX_DEFAULT_USER_AGENT = 'codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464';
const ANTIGRAVITY_RUNTIME_USER_AGENT = 'antigravity/1.19.6 darwin/arm64';
const CLAUDE_DEFAULT_USER_AGENT = 'claude-cli/2.1.63 (external, cli)';
const CLAUDE_DEFAULT_BETA_HEADER = 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05';
function shouldSkipPassthroughHeader(key: string): boolean {
return HOP_BY_HOP_HEADERS.has(key) || BLOCKED_PASSTHROUGH_HEADERS.has(key);
}
@@ -164,6 +175,239 @@ function extractResponsesPassthroughHeaders(
return forwarded;
}
function getInputHeader(
headers: Record<string, unknown> | Record<string, string> | undefined,
key: string,
): string | null {
if (!headers) return null;
for (const [candidateKey, candidateValue] of Object.entries(headers)) {
if (candidateKey.toLowerCase() !== key.toLowerCase()) continue;
return headerValueToString(candidateValue);
}
return null;
}
function parseGeminiCliUserAgentRuntime(userAgent: string | null): {
version: string;
platform: string;
arch: string;
} | null {
if (!userAgent) return null;
const match = /^GeminiCLI\/([^/]+)\/[^ ]+ \(([^;]+); ([^)]+)\)$/i.exec(userAgent.trim());
if (!match) return null;
return {
version: match[1] || '0.31.0',
platform: match[2] || 'win32',
arch: match[3] || 'x64',
};
}
function buildGeminiCLIUserAgent(modelName: string, existingUserAgent?: string | null): string {
const parsed = parseGeminiCliUserAgentRuntime(existingUserAgent ?? null);
const version = parsed?.version || '0.31.0';
const platform = parsed?.platform || 'win32';
const arch = parsed?.arch || 'x64';
const effectiveModel = asTrimmedString(modelName) || 'unknown';
return `GeminiCLI/${version}/${effectiveModel} (${platform}; ${arch})`;
}
function uuidFromSeed(seed: string): string {
const hash = createHash('sha1').update(seed).digest();
const bytes = new Uint8Array(hash.subarray(0, 16));
bytes[6] = (bytes[6]! & 0x0f) | 0x50;
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('');
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20, 32),
].join('-');
}
function mergeClaudeBetaHeader(
explicitValue: string | null,
extraBetas: string[] = [],
): string {
const source = explicitValue || CLAUDE_DEFAULT_BETA_HEADER;
const seen = new Set<string>();
const merged: string[] = [];
for (const entry of source.split(',')) {
const normalized = entry.trim();
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
merged.push(normalized);
}
if (!explicitValue) {
for (const entry of extraBetas) {
const normalized = entry.trim();
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
merged.push(normalized);
}
}
return merged.join(',');
}
function extractClaudeBetasFromBody(body: Record<string, unknown>): {
body: Record<string, unknown>;
betas: string[];
} {
const next = { ...body };
const rawBetas = next.betas;
delete next.betas;
if (typeof rawBetas === 'string') {
return {
body: next,
betas: rawBetas.split(',').map((entry) => entry.trim()).filter(Boolean),
};
}
if (Array.isArray(rawBetas)) {
return {
body: next,
betas: rawBetas
.map((entry) => asTrimmedString(entry))
.filter(Boolean),
};
}
return {
body: next,
betas: [],
};
}
function buildCodexRuntimeHeaders(input: {
baseHeaders: Record<string, string>;
providerHeaders?: Record<string, string>;
explicitSessionId?: string | null;
continuityKey?: string | null;
}): Record<string, string> {
const authorization = (
getInputHeader(input.baseHeaders, 'authorization')
|| getInputHeader(input.baseHeaders, 'Authorization')
|| ''
);
const originator = getInputHeader(input.providerHeaders, 'originator') || 'codex_cli_rs';
const accountId = getInputHeader(input.providerHeaders, 'chatgpt-account-id');
const version = getInputHeader(input.baseHeaders, 'version') || CODEX_CLIENT_VERSION;
const userAgent = getInputHeader(input.baseHeaders, 'user-agent') || CODEX_DEFAULT_USER_AGENT;
const explicitSessionId = asTrimmedString(input.explicitSessionId);
const continuityKey = asTrimmedString(input.continuityKey);
const sessionId = (
getInputHeader(input.baseHeaders, 'session_id')
|| getInputHeader(input.baseHeaders, 'session-id')
|| explicitSessionId
|| (continuityKey ? uuidFromSeed(`metapi:codex:${continuityKey}`) : null)
|| randomUUID()
);
const conversationId = (
getInputHeader(input.baseHeaders, 'conversation_id')
|| getInputHeader(input.baseHeaders, 'conversation-id')
|| explicitSessionId
|| (continuityKey ? sessionId : null)
);
return {
Authorization: authorization,
'Content-Type': 'application/json',
...(accountId ? { 'Chatgpt-Account-Id': accountId } : {}),
Originator: originator,
Version: version,
Session_id: sessionId,
...(conversationId ? { Conversation_id: conversationId } : {}),
'User-Agent': userAgent,
Accept: 'text/event-stream',
Connection: 'Keep-Alive',
};
}
function buildGeminiCliRuntimeHeaders(input: {
baseHeaders: Record<string, string>;
providerHeaders?: Record<string, string>;
modelName: string;
stream: boolean;
}): Record<string, string> {
const apiClient = (
getInputHeader(input.providerHeaders, 'x-goog-api-client')
|| getInputHeader(input.baseHeaders, 'x-goog-api-client')
);
const userAgent = buildGeminiCLIUserAgent(
input.modelName,
getInputHeader(input.providerHeaders, 'user-agent') || getInputHeader(input.baseHeaders, 'user-agent'),
);
const headers: Record<string, string> = {
Authorization: input.baseHeaders.Authorization,
'Content-Type': 'application/json',
'User-Agent': userAgent,
};
if (apiClient) {
headers['X-Goog-Api-Client'] = apiClient;
}
if (input.stream) {
headers.Accept = 'text/event-stream';
}
return headers;
}
function buildAntigravityRuntimeHeaders(input: {
baseHeaders: Record<string, string>;
stream: boolean;
}): Record<string, string> {
const headers: Record<string, string> = {
Authorization: input.baseHeaders.Authorization,
'Content-Type': 'application/json',
Accept: input.stream ? 'text/event-stream' : 'application/json',
'User-Agent': ANTIGRAVITY_RUNTIME_USER_AGENT,
};
return headers;
}
function buildClaudeRuntimeHeaders(input: {
baseHeaders: Record<string, string>;
claudeHeaders: Record<string, string>;
anthropicVersion: string;
stream: boolean;
isClaudeOauthUpstream: boolean;
tokenValue: string;
extraBetas?: string[];
}): Record<string, string> {
const anthropicBeta = mergeClaudeBetaHeader(
getInputHeader(input.claudeHeaders, 'anthropic-beta'),
input.extraBetas,
);
const headers: Record<string, string> = {
...input.baseHeaders,
...input.claudeHeaders,
'anthropic-version': input.anthropicVersion,
...(anthropicBeta ? { 'anthropic-beta': anthropicBeta } : {}),
'Anthropic-Dangerous-Direct-Browser-Access': 'true',
'X-App': 'cli',
'X-Stainless-Retry-Count': getInputHeader(input.claudeHeaders, 'x-stainless-retry-count') || '0',
'X-Stainless-Runtime-Version': getInputHeader(input.claudeHeaders, 'x-stainless-runtime-version') || 'v24.3.0',
'X-Stainless-Package-Version': getInputHeader(input.claudeHeaders, 'x-stainless-package-version') || '0.74.0',
'X-Stainless-Runtime': getInputHeader(input.claudeHeaders, 'x-stainless-runtime') || 'node',
'X-Stainless-Lang': getInputHeader(input.claudeHeaders, 'x-stainless-lang') || 'js',
'X-Stainless-Arch': getInputHeader(input.claudeHeaders, 'x-stainless-arch') || 'x64',
'X-Stainless-Os': getInputHeader(input.claudeHeaders, 'x-stainless-os') || 'Windows',
'X-Stainless-Timeout': getInputHeader(input.claudeHeaders, 'x-stainless-timeout') || '600',
'User-Agent': getInputHeader(input.claudeHeaders, 'user-agent') || CLAUDE_DEFAULT_USER_AGENT,
Connection: 'keep-alive',
Accept: input.stream ? 'text/event-stream' : 'application/json',
'Accept-Encoding': input.stream ? 'identity' : 'gzip, deflate, br, zstd',
};
if (input.isClaudeOauthUpstream) {
headers.Authorization = `Bearer ${input.tokenValue}`;
} else {
headers['x-api-key'] = input.tokenValue;
}
return headers;
}
function ensureStreamAcceptHeader(
headers: Record<string, string>,
stream: boolean,
@@ -186,6 +430,89 @@ function toFiniteNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function ensureCodexResponsesInstructions(
body: Record<string, unknown>,
sitePlatform: string,
): Record<string, unknown> {
if (sitePlatform !== 'codex') return body;
if (typeof body.instructions === 'string') return body;
return {
...body,
instructions: '',
};
}
function ensureCodexResponsesStoreFalse(
body: Record<string, unknown>,
sitePlatform: string,
): Record<string, unknown> {
if (sitePlatform !== 'codex') return body;
if (body.store === false) return body;
return {
...body,
store: false,
};
}
function convertCodexSystemRoleToDeveloper(input: unknown): unknown {
if (!Array.isArray(input)) return input;
return input.map((item) => {
if (!isRecord(item)) return item;
if (asTrimmedString(item.type).toLowerCase() !== 'message') return item;
if (asTrimmedString(item.role).toLowerCase() !== 'system') return item;
return {
...item,
role: 'developer',
};
});
}
function applyCodexResponsesCompatibility(
body: Record<string, unknown>,
sitePlatform: string,
options?: {
preservePreviousResponseId?: boolean;
},
): Record<string, unknown> {
if (sitePlatform !== 'codex') return body;
const next: Record<string, unknown> = {
...body,
stream: true,
store: false,
parallel_tool_calls: true,
include: ['reasoning.encrypted_content'],
input: convertCodexSystemRoleToDeveloper(body.input),
};
if (typeof next.instructions !== 'string') {
next.instructions = '';
}
for (const key of [
'max_output_tokens',
'max_completion_tokens',
'temperature',
'top_p',
'truncation',
'user',
'context_management',
'prompt_cache_retention',
'safety_identifier',
]) {
delete next[key];
}
if (!options?.preservePreviousResponseId) {
delete next.previous_response_id;
}
if (asTrimmedString(next.service_tier).toLowerCase() !== 'priority') {
delete next.service_tier;
}
return next;
}
function normalizeEndpointTypes(value: unknown): UpstreamEndpoint[] {
const raw = asTrimmedString(value).toLowerCase();
@@ -237,11 +564,23 @@ function preferredEndpointOrder(
): UpstreamEndpoint[] {
const platform = normalizePlatformName(sitePlatform);
if (platform === 'codex') {
return ['responses'];
}
if (platform === 'gemini') {
// Gemini upstream is routed through OpenAI-compatible chat endpoint.
return ['chat'];
}
if (platform === 'gemini-cli') {
return ['chat'];
}
if (platform === 'antigravity') {
return ['chat'];
}
if (platform === 'openai') {
if (preferMessagesForClaudeModel && downstreamFormat !== 'responses') {
// Some OpenAI-compatible gateways expose Claude natively via /v1/messages.
@@ -319,6 +658,7 @@ export async function resolveUpstreamEndpointCandidates(
? (() => {
if (sitePlatform === 'claude') return ['messages'] as UpstreamEndpoint[];
if (sitePlatform === 'gemini') return ['responses', 'chat'] as UpstreamEndpoint[];
if (sitePlatform === 'gemini-cli' || sitePlatform === 'antigravity') return ['chat'] as UpstreamEndpoint[];
if (preferMessagesForClaudeModel) return ['messages', 'responses', 'chat'] as UpstreamEndpoint[];
return ['responses', 'messages', 'chat'] as UpstreamEndpoint[];
})()
@@ -338,6 +678,8 @@ export async function resolveUpstreamEndpointCandidates(
&& preferMessagesForClaudeModel
&& sitePlatform !== 'openai'
&& sitePlatform !== 'gemini'
&& sitePlatform !== 'antigravity'
&& sitePlatform !== 'gemini-cli'
);
try {
@@ -422,6 +764,8 @@ export function buildUpstreamEndpointRequest(input: {
modelName: string;
stream: boolean;
tokenValue: string;
oauthProvider?: string;
oauthProjectId?: string;
sitePlatform?: string;
siteUrl?: string;
openaiBody: Record<string, unknown>;
@@ -430,14 +774,28 @@ export function buildUpstreamEndpointRequest(input: {
forceNormalizeClaudeBody?: boolean;
responsesOriginalBody?: Record<string, unknown>;
downstreamHeaders?: Record<string, unknown>;
providerHeaders?: Record<string, string>;
codexSessionCacheKey?: string | null;
codexExplicitSessionId?: string | null;
}): {
path: string;
headers: Record<string, string>;
body: Record<string, unknown>;
runtime?: {
executor: 'default' | 'codex' | 'gemini-cli' | 'antigravity' | 'claude';
modelName?: string;
stream?: boolean;
oauthProjectId?: string | null;
action?: 'generateContent' | 'streamGenerateContent' | 'countTokens';
};
} {
const sitePlatform = normalizePlatformName(input.sitePlatform);
const isClaudeUpstream = sitePlatform === 'claude';
const isGeminiUpstream = sitePlatform === 'gemini';
const isGeminiCliUpstream = sitePlatform === 'gemini-cli';
const isAntigravityUpstream = sitePlatform === 'antigravity';
const isInternalGeminiUpstream = isGeminiCliUpstream || isAntigravityUpstream;
const isClaudeOauthUpstream = isClaudeUpstream && input.oauthProvider === 'claude';
const resolveGeminiEndpointPath = (endpoint: UpstreamEndpoint): string => {
const normalizedSiteUrl = asTrimmedString(input.siteUrl).toLowerCase();
@@ -463,6 +821,16 @@ export function buildUpstreamEndpointRequest(input: {
return '/v1/chat/completions';
}
if (sitePlatform === 'codex') {
return '/responses';
}
if (sitePlatform === 'gemini-cli' || sitePlatform === 'antigravity') {
return input.stream
? '/v1internal:streamGenerateContent?alt=sse'
: '/v1internal:generateContent';
}
if (sitePlatform === 'claude') {
return '/v1/messages';
}
@@ -476,6 +844,7 @@ export function buildUpstreamEndpointRequest(input: {
const commonHeaders: Record<string, string> = {
...passthroughHeaders,
'Content-Type': 'application/json',
...(input.providerHeaders || {}),
};
if (!isClaudeUpstream) {
commonHeaders.Authorization = `Bearer ${input.tokenValue}`;
@@ -483,7 +852,7 @@ export function buildUpstreamEndpointRequest(input: {
const stripGeminiUnsupportedFields = (body: Record<string, unknown>) => {
const next = { ...body };
if (isGeminiUpstream) {
if (isGeminiUpstream || isInternalGeminiUpstream) {
for (const key of [
'frequency_penalty',
'presence_penalty',
@@ -499,6 +868,61 @@ export function buildUpstreamEndpointRequest(input: {
};
const openaiBody = stripGeminiUnsupportedFields(input.openaiBody);
const runtime = {
executor: (
sitePlatform === 'codex'
? 'codex'
: sitePlatform === 'gemini-cli'
? 'gemini-cli'
: sitePlatform === 'antigravity'
? 'antigravity'
: sitePlatform === 'claude'
? 'claude'
: 'default'
) as 'default' | 'codex' | 'gemini-cli' | 'antigravity' | 'claude',
modelName: input.modelName,
stream: input.stream,
oauthProjectId: asTrimmedString(input.oauthProjectId) || null,
};
if (isInternalGeminiUpstream) {
const projectId = asTrimmedString(input.oauthProjectId);
if (isGeminiCliUpstream && !projectId) {
throw new Error('gemini-cli oauth project id missing');
}
const instructions = (
input.downstreamFormat === 'responses'
&& typeof input.responsesOriginalBody?.instructions === 'string'
)
? input.responsesOriginalBody.instructions
: undefined;
const geminiRequest = buildGeminiGenerateContentRequestFromOpenAi({
body: openaiBody,
modelName: input.modelName,
instructions,
});
const headers = isGeminiCliUpstream
? buildGeminiCliRuntimeHeaders({
baseHeaders: commonHeaders,
providerHeaders: input.providerHeaders,
modelName: input.modelName,
stream: input.stream,
})
: buildAntigravityRuntimeHeaders({
baseHeaders: commonHeaders,
stream: input.stream,
});
return {
path: resolveEndpointPath(input.endpoint),
headers,
body: wrapGeminiCliRequest({
modelName: input.modelName,
projectId: projectId || '',
request: geminiRequest,
}) as Record<string, unknown>,
runtime,
};
}
if (input.endpoint === 'messages') {
const claudeHeaders = input.downstreamFormat === 'claude'
@@ -537,21 +961,26 @@ export function buildUpstreamEndpointRequest(input: {
convertOpenAiBodyToAnthropicMessagesBody(openaiBody, input.modelName, input.stream),
);
const headers = ensureStreamAcceptHeader({
...commonHeaders,
...claudeHeaders,
'x-api-key': input.tokenValue,
'anthropic-version': anthropicVersion,
}, input.stream);
const headers = buildClaudeRuntimeHeaders({
baseHeaders: commonHeaders,
claudeHeaders,
anthropicVersion,
stream: input.stream,
isClaudeOauthUpstream,
tokenValue: input.tokenValue,
});
return {
path: resolveEndpointPath('messages'),
headers,
body: sanitizedBody,
runtime,
};
}
if (input.endpoint === 'responses') {
const websocketMode = Object.entries(input.downstreamHeaders || {}).find(([rawKey]) => rawKey.trim().toLowerCase() === 'x-metapi-responses-websocket-mode');
const preserveWebsocketIncrementalMode = asTrimmedString(websocketMode?.[1]).toLowerCase() === 'incremental';
const responsesHeaders = input.downstreamFormat === 'responses'
? extractResponsesPassthroughHeaders(input.downstreamHeaders)
: {};
@@ -564,17 +993,56 @@ export function buildUpstreamEndpointRequest(input: {
}
: convertOpenAiBodyToResponsesBodyViaTransformer(openaiBody, input.modelName, input.stream)
);
const body = sanitizeResponsesBodyForProxyViaTransformer(rawBody, input.modelName, input.stream);
const sanitizedResponsesBody = sanitizeResponsesBodyForProxyViaTransformer(rawBody, input.modelName, input.stream);
if (preserveWebsocketIncrementalMode && rawBody.generate === false) {
sanitizedResponsesBody.generate = false;
}
const body = ensureCodexResponsesStoreFalse(
ensureCodexResponsesInstructions(
applyCodexResponsesCompatibility(
sanitizedResponsesBody,
sitePlatform,
{ preservePreviousResponseId: preserveWebsocketIncrementalMode },
),
sitePlatform,
),
sitePlatform,
);
const headers = ensureStreamAcceptHeader({
...commonHeaders,
...responsesHeaders,
}, input.stream);
const headers = sitePlatform === 'codex'
? buildCodexRuntimeHeaders({
baseHeaders: {
...commonHeaders,
...responsesHeaders,
},
providerHeaders: input.providerHeaders,
explicitSessionId: asTrimmedString(input.codexExplicitSessionId) || null,
continuityKey: asTrimmedString(input.codexSessionCacheKey) || null,
})
: ensureStreamAcceptHeader({
...commonHeaders,
...responsesHeaders,
}, input.stream);
const codexSessionId = sitePlatform === 'codex'
? (getInputHeader(headers, 'session_id') || getInputHeader(headers, 'session-id'))
: null;
const shouldInjectDerivedPromptCacheKey = sitePlatform === 'codex'
&& !!codexSessionId
&& !asTrimmedString((body as Record<string, unknown>).prompt_cache_key)
&& !asTrimmedString(input.codexExplicitSessionId)
&& !!asTrimmedString(input.codexSessionCacheKey);
const runtimeBody = shouldInjectDerivedPromptCacheKey
? {
...body,
prompt_cache_key: codexSessionId,
}
: body;
return {
path: resolveEndpointPath('responses'),
headers,
body,
body: runtimeBody,
runtime,
};
}
@@ -587,6 +1055,63 @@ export function buildUpstreamEndpointRequest(input: {
model: input.modelName,
stream: input.stream,
},
runtime,
};
}
export function buildClaudeCountTokensUpstreamRequest(input: {
modelName: string;
tokenValue: string;
oauthProvider?: string;
sitePlatform?: string;
claudeBody: Record<string, unknown>;
downstreamHeaders?: Record<string, unknown>;
}): {
path: string;
headers: Record<string, string>;
body: Record<string, unknown>;
runtime: {
executor: 'claude';
modelName: string;
stream: false;
action: 'countTokens';
};
} {
const sitePlatform = normalizePlatformName(input.sitePlatform);
const isClaudeOauthUpstream = sitePlatform === 'claude' && input.oauthProvider === 'claude';
const claudeHeaders = extractClaudePassthroughHeaders(input.downstreamHeaders);
const anthropicVersion = claudeHeaders['anthropic-version'] || '2023-06-01';
const { body: bodyWithoutBetas, betas } = extractClaudeBetasFromBody({
...input.claudeBody,
model: input.modelName,
});
const sanitizedBody = sanitizeAnthropicMessagesBody(bodyWithoutBetas);
delete sanitizedBody.max_tokens;
delete sanitizedBody.maxTokens;
delete sanitizedBody.stream;
const headers = buildClaudeRuntimeHeaders({
baseHeaders: {
'Content-Type': 'application/json',
},
claudeHeaders,
anthropicVersion,
stream: false,
isClaudeOauthUpstream,
tokenValue: input.tokenValue,
extraBetas: betas,
});
return {
path: '/v1/messages/count_tokens?beta=true',
headers,
body: sanitizedBody,
runtime: {
executor: 'claude',
modelName: input.modelName,
stream: false,
action: 'countTokens',
},
};
}
@@ -8,6 +8,8 @@ import {
mergeAccountExtraConfig,
normalizeCredentialMode,
resolvePlatformUserId,
requiresManagedAccountTokens,
supportsDirectAccountRoutingConnection,
} from './accountExtraConfig.js';
describe('accountExtraConfig', () => {
@@ -82,4 +84,40 @@ describe('accountExtraConfig', () => {
expect(getProxyUrlFromExtraConfig(undefined)).toBeNull();
expect(getProxyUrlFromExtraConfig('invalid-json')).toBeNull();
});
it('treats auto-mode api token connections as direct-account routable', () => {
expect(supportsDirectAccountRoutingConnection({
accessToken: '',
apiToken: 'sk-demo',
extraConfig: null,
})).toBe(true);
expect(requiresManagedAccountTokens({
accessToken: '',
apiToken: 'sk-demo',
extraConfig: null,
})).toBe(false);
});
it('treats oauth and session connections as non-managed-token direct routes only when intended', () => {
expect(supportsDirectAccountRoutingConnection({
accessToken: 'oauth-access-token',
apiToken: null,
extraConfig: JSON.stringify({ credentialMode: 'session', oauth: { provider: 'codex' } }),
})).toBe(true);
expect(requiresManagedAccountTokens({
accessToken: 'oauth-access-token',
apiToken: null,
extraConfig: JSON.stringify({ credentialMode: 'session', oauth: { provider: 'codex' } }),
})).toBe(false);
expect(supportsDirectAccountRoutingConnection({
accessToken: 'session-token',
apiToken: 'sk-default',
extraConfig: JSON.stringify({ credentialMode: 'session' }),
})).toBe(false);
expect(requiresManagedAccountTokens({
accessToken: 'session-token',
apiToken: 'sk-default',
extraConfig: JSON.stringify({ credentialMode: 'session' }),
})).toBe(true);
});
});
+47
View File
@@ -20,6 +20,10 @@ const VALID_CREDENTIAL_MODES = new Set<AccountCredentialMode>([
type AccountExtraConfig = {
platformUserId?: unknown;
credentialMode?: unknown;
oauth?: {
provider?: unknown;
[key: string]: unknown;
};
autoRelogin?: AutoReloginConfig;
sub2apiAuth?: Sub2ApiAuthConfig;
[key: string]: unknown;
@@ -82,6 +86,49 @@ export function getCredentialModeFromExtraConfig(extraConfig?: string | null): A
return normalizeCredentialMode(parsed.credentialMode);
}
export function getOauthProviderFromExtraConfig(extraConfig?: string | null): string | undefined {
const parsed = parseExtraConfig(extraConfig);
return normalizeNonEmptyString(parsed.oauth?.provider);
}
export function hasOauthProvider(extraConfig?: string | null): boolean {
return !!getOauthProviderFromExtraConfig(extraConfig);
}
type DirectAccountRoutingInput = {
accessToken?: string | null;
apiToken?: string | null;
extraConfig?: string | null;
};
function hasCredentialValue(value: string | null | undefined): boolean {
return typeof value === 'string' && value.trim().length > 0;
}
export function supportsDirectAccountRoutingConnection(account: DirectAccountRoutingInput): boolean {
const credentialMode = getCredentialModeFromExtraConfig(account.extraConfig);
if (hasOauthProvider(account.extraConfig)) {
return hasCredentialValue(account.accessToken) || hasCredentialValue(account.apiToken);
}
if (credentialMode === 'apikey') {
return hasCredentialValue(account.apiToken);
}
if (credentialMode === 'session') {
return false;
}
if (hasCredentialValue(account.accessToken)) return false;
return hasCredentialValue(account.apiToken);
}
export function requiresManagedAccountTokens(account: DirectAccountRoutingInput): boolean {
const credentialMode = getCredentialModeFromExtraConfig(account.extraConfig);
if (hasOauthProvider(account.extraConfig)) return false;
if (credentialMode === 'apikey') return false;
if (credentialMode === 'session') return true;
if (hasCredentialValue(account.apiToken) && !hasCredentialValue(account.accessToken)) return false;
return true;
}
export type ManagedSub2ApiAuth = {
refreshToken: string;
tokenExpiresAt?: number;
+121 -13
View File
@@ -9,6 +9,14 @@ type UpstreamApiToken = {
tokenGroup?: string | null;
};
type AccountTokenRow = typeof schema.accountTokens.$inferSelect;
export const ACCOUNT_TOKEN_VALUE_STATUS_READY = 'ready' as const;
export const ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING = 'masked_pending' as const;
export type AccountTokenValueStatus =
| typeof ACCOUNT_TOKEN_VALUE_STATUS_READY
| typeof ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING;
export function normalizeTokenForDisplay(token?: string | null, platform?: string | null): string {
if (!token) return '';
const value = token.trim();
@@ -52,6 +60,45 @@ export function isMaskedTokenValue(token: string | null | undefined): boolean {
return value.includes('*') || value.includes('•');
}
function normalizeTokenValueStatus(value: string | null | undefined): AccountTokenValueStatus {
const normalized = (value || '').trim().toLowerCase();
return normalized === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
: ACCOUNT_TOKEN_VALUE_STATUS_READY;
}
export function resolveAccountTokenValueStatus(
value: Pick<AccountTokenRow, 'token' | 'valueStatus'> | string | null | undefined,
): AccountTokenValueStatus {
if (typeof value === 'string' || value == null) {
return normalizeTokenValueStatus(value);
}
const explicit = normalizeTokenValueStatus(value.valueStatus);
if (explicit === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING) {
return ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING;
}
return isMaskedTokenValue(value.token)
? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
: ACCOUNT_TOKEN_VALUE_STATUS_READY;
}
export function isReadyAccountToken(token: Pick<AccountTokenRow, 'token' | 'valueStatus'> | null | undefined): boolean {
if (!token) return false;
return resolveAccountTokenValueStatus(token) === ACCOUNT_TOKEN_VALUE_STATUS_READY
&& !isMaskedTokenValue(token.token);
}
export function isMaskedPendingAccountToken(token: Pick<AccountTokenRow, 'token' | 'valueStatus'> | null | undefined): boolean {
if (!token) return false;
return resolveAccountTokenValueStatus(token) === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING;
}
export function isUsableAccountToken(token: AccountTokenRow | null | undefined): boolean {
if (!token) return false;
return token.enabled === true && isReadyAccountToken(token);
}
function normalizeTokenGroup(value: string | null | undefined, tokenName?: string | null): string | null {
const explicit = (value || '').trim();
if (explicit.length > 0) return explicit;
@@ -66,6 +113,15 @@ function normalizeTokenGroup(value: string | null | undefined, tokenName?: strin
return name;
}
function sameTokenGroup(
leftGroup: string | null | undefined,
leftName: string | null | undefined,
rightGroup: string | null | undefined,
rightName: string | null | undefined,
): boolean {
return normalizeTokenGroup(leftGroup, leftName) === normalizeTokenGroup(rightGroup, rightName);
}
async function updateAccountApiToken(accountId: number, tokenValue: string | null) {
await db.update(schema.accounts)
.set({ apiToken: tokenValue || null, updatedAt: new Date().toISOString() })
@@ -85,9 +141,10 @@ export async function getPreferredAccountToken(accountId: number) {
.where(and(eq(schema.accountTokens.accountId, accountId), eq(schema.accountTokens.enabled, true)))
.all();
if (tokens.length === 0) return null;
const usableTokens = tokens.filter(isUsableAccountToken);
if (usableTokens.length === 0) return null;
const preferred = tokens.find((t) => t.isDefault) || tokens[0];
const preferred = usableTokens.find((t) => t.isDefault) || usableTokens[0];
return preferred;
}
@@ -98,6 +155,7 @@ export async function ensureDefaultTokenForAccount(
): Promise<number | null> {
const normalizedToken = normalizeTokenValue(tokenValue);
if (!normalizedToken) return null;
if (isMaskedTokenValue(normalizedToken)) return null;
const tokenGroup = normalizeTokenGroup(options?.tokenGroup, options?.name) || 'default';
const now = new Date().toISOString();
@@ -114,6 +172,7 @@ export async function ensureDefaultTokenForAccount(
name: normalizeTokenName(options?.name, tokens.length + 1),
token: normalizedToken,
tokenGroup,
valueStatus: ACCOUNT_TOKEN_VALUE_STATUS_READY,
source: options?.source || 'manual',
enabled: options?.enabled ?? true,
isDefault: true,
@@ -131,6 +190,7 @@ export async function ensureDefaultTokenForAccount(
.set({
name: options?.name ? normalizeTokenName(options.name) : target.name,
tokenGroup,
valueStatus: ACCOUNT_TOKEN_VALUE_STATUS_READY,
source: options?.source || target.source || 'manual',
enabled: options?.enabled ?? target.enabled,
isDefault: true,
@@ -151,7 +211,7 @@ export async function ensureDefaultTokenForAccount(
export async function setDefaultToken(tokenId: number): Promise<boolean> {
const target = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get();
if (!target) return false;
if (!target || !isUsableAccountToken(target)) return false;
const now = new Date().toISOString();
await db.update(schema.accountTokens)
@@ -174,7 +234,7 @@ export async function repairDefaultToken(accountId: number) {
.where(eq(schema.accountTokens.accountId, accountId))
.all();
const enabled = tokens.filter((t) => t.enabled);
const enabled = tokens.filter(isUsableAccountToken);
if (enabled.length === 0) {
await updateAccountApiToken(accountId, null);
return null;
@@ -206,27 +266,30 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
let created = 0;
let updated = 0;
let maskedSkipped = 0;
let maskedPending = 0;
const pendingTokenIds: number[] = [];
let index = existing.length + 1;
for (const upstream of upstreamTokens) {
const tokenValue = normalizeTokenValue(upstream.key);
if (!tokenValue) continue;
if (isMaskedTokenValue(tokenValue)) {
maskedSkipped++;
continue;
}
const tokenName = normalizeTokenName(upstream.name, index);
const enabled = upstream.enabled ?? true;
const tokenGroup = normalizeTokenGroup(upstream.tokenGroup, tokenName);
const nextValueStatus = isMaskedTokenValue(tokenValue)
? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
: ACCOUNT_TOKEN_VALUE_STATUS_READY;
const byToken = existing.find((row) => row.token === tokenValue);
const byToken = existing.find((row) => (
row.token === tokenValue
&& resolveAccountTokenValueStatus(row) === ACCOUNT_TOKEN_VALUE_STATUS_READY
));
if (byToken) {
await db.update(schema.accountTokens)
.set({
name: tokenName,
tokenGroup,
valueStatus: ACCOUNT_TOKEN_VALUE_STATUS_READY,
source: 'sync',
enabled,
updatedAt: now,
@@ -235,6 +298,7 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
.run();
byToken.name = tokenName;
byToken.tokenGroup = tokenGroup;
byToken.valueStatus = ACCOUNT_TOKEN_VALUE_STATUS_READY;
byToken.enabled = enabled;
byToken.source = 'sync';
byToken.updatedAt = now;
@@ -242,14 +306,52 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
continue;
}
const matchingPlaceholder = existing.find((row) => (
isMaskedPendingAccountToken(row)
&& row.name === tokenName
&& sameTokenGroup(row.tokenGroup, row.name, tokenGroup, tokenName)
));
if (matchingPlaceholder) {
const nextEnabled = nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY ? enabled : false;
await db.update(schema.accountTokens)
.set({
name: tokenName,
token: tokenValue,
tokenGroup,
valueStatus: nextValueStatus,
source: 'sync',
enabled: nextEnabled,
isDefault: false,
updatedAt: now,
})
.where(eq(schema.accountTokens.id, matchingPlaceholder.id))
.run();
matchingPlaceholder.name = tokenName;
matchingPlaceholder.token = tokenValue;
matchingPlaceholder.tokenGroup = tokenGroup;
matchingPlaceholder.valueStatus = nextValueStatus;
matchingPlaceholder.source = 'sync';
matchingPlaceholder.enabled = nextEnabled;
matchingPlaceholder.isDefault = false;
matchingPlaceholder.updatedAt = now;
updated++;
if (nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING) {
maskedPending++;
pendingTokenIds.push(matchingPlaceholder.id);
}
continue;
}
const inserted = await db.insert(schema.accountTokens)
.values({
accountId,
name: tokenName,
token: tokenValue,
tokenGroup,
valueStatus: nextValueStatus,
source: 'sync',
enabled,
enabled: nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY ? enabled : false,
isDefault: false,
createdAt: now,
updatedAt: now,
@@ -263,6 +365,10 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
existing.push(createdRow);
created++;
index++;
if (nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING) {
maskedPending++;
pendingTokenIds.push(createdRow.id);
}
}
const repaired = await repairDefaultToken(accountId);
@@ -270,7 +376,8 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
return {
created,
updated,
maskedSkipped,
maskedPending,
pendingTokenIds,
total: existing.length,
defaultTokenId: repaired?.id || null,
};
@@ -292,6 +399,7 @@ export async function listTokensWithRelations(accountId?: number) {
const { token, ...tokenMeta } = row.account_tokens;
return {
...tokenMeta,
valueStatus: resolveAccountTokenValueStatus(row.account_tokens),
tokenMasked: maskToken(token, row.sites.platform),
account: {
id: row.accounts.id,
+107
View File
@@ -28,6 +28,7 @@ describe('backupService', () => {
beforeEach(async () => {
await db.delete(schema.routeChannels).run();
await db.delete(schema.routeGroupSources).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
@@ -68,6 +69,9 @@ describe('backupService', () => {
username: 'roundtrip-user',
accessToken: 'session-token',
apiToken: 'api-token',
oauthProvider: 'codex',
oauthAccountKey: 'roundtrip-account-key',
oauthProjectId: 'roundtrip-project-id',
balance: 12.3,
balanceUsed: 4.5,
quota: 99.9,
@@ -93,16 +97,34 @@ describe('backupService', () => {
updatedAt: now,
}).returning().get();
const sourceRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-source-*',
displayName: 'gpt-source',
routeMode: 'pattern',
enabled: true,
createdAt: now,
updatedAt: now,
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-*',
displayName: 'gpt-route',
displayIcon: 'icon-gpt',
modelMapping: JSON.stringify({ to: 'gpt-4o-mini' }),
routeMode: 'explicit_group',
decisionSnapshot: JSON.stringify({ channelIds: [1, 2] }),
decisionRefreshedAt: now,
routingStrategy: 'round_robin',
enabled: true,
createdAt: now,
updatedAt: now,
}).returning().get();
await db.insert(schema.routeGroupSources).values({
groupRouteId: route.id,
sourceRouteId: sourceRoute.id,
}).run();
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
@@ -141,9 +163,18 @@ describe('backupService', () => {
expect(restoredAccount?.isPinned).toBe(true);
expect(restoredAccount?.sortOrder).toBe(7);
expect(restoredAccount?.oauthProvider).toBe('codex');
expect(restoredAccount?.oauthAccountKey).toBe('roundtrip-account-key');
expect(restoredAccount?.oauthProjectId).toBe('roundtrip-project-id');
expect(restoredRoute?.displayName).toBe('gpt-route');
expect(restoredRoute?.displayIcon).toBe('icon-gpt');
expect(restoredRoute?.routeMode).toBe('explicit_group');
expect(restoredRoute?.decisionSnapshot).toBe('{"channelIds":[1,2]}');
expect(restoredRoute?.decisionRefreshedAt).toBe(now);
expect(restoredRoute?.routingStrategy).toBe('round_robin');
const restoredGroupSource = await db.select().from(schema.routeGroupSources).where(eq(schema.routeGroupSources.groupRouteId, route.id)).get();
expect(restoredGroupSource?.sourceRouteId).toBe(sourceRoute.id);
expect(restoredChannel?.sourceModel).toBe('gpt-4o');
});
@@ -203,4 +234,80 @@ describe('backupService', () => {
expect(accounts[0].username).toBe('legacy-user');
expect(settings.some((row) => row.key === 'legacy_preferences_ref_v2')).toBe(true);
});
it('backfills oauth columns from extraConfig when importing older backups', async () => {
const payload = {
timestamp: Date.now(),
accounts: {
sites: [
{
id: 1,
name: 'codex-site',
url: 'https://codex.example.com',
platform: 'chatgpt-account',
proxyUrl: null,
status: 'active',
isPinned: false,
sortOrder: 0,
apiKey: null,
createdAt: '2026-03-01T00:00:00.000Z',
updatedAt: '2026-03-01T00:00:00.000Z',
externalCheckinUrl: null,
useSystemProxy: false,
globalWeight: 1,
customHeaders: null,
},
],
accounts: [
{
id: 10,
siteId: 1,
username: 'oauth-user',
accessToken: 'oauth-access-token',
apiToken: null,
balance: 0,
balanceUsed: 0,
quota: 0,
unitCost: null,
valueScore: 0,
status: 'active',
isPinned: false,
sortOrder: 0,
checkinEnabled: true,
lastCheckinAt: null,
lastBalanceRefresh: null,
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'gemini-cli',
accountId: 'oauth-user@example.com',
accountKey: 'oauth-user@example.com',
projectId: 'oauth-project-id',
refreshToken: 'oauth-refresh-token',
},
}),
createdAt: '2026-03-01T00:00:00.000Z',
updatedAt: '2026-03-01T00:00:00.000Z',
oauthProvider: null,
oauthAccountKey: null,
oauthProjectId: null,
},
],
accountTokens: [],
tokenRoutes: [],
routeChannels: [],
},
} as Record<string, unknown>;
const result = await backupService.importBackup(payload);
expect(result.allImported).toBe(true);
expect(result.sections.accounts).toBe(true);
const restoredAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, 10)).get();
expect(restoredAccount?.oauthProvider).toBe('gemini-cli');
expect(restoredAccount?.oauthAccountKey).toBe('oauth-user@example.com');
expect(restoredAccount?.oauthProjectId).toBe('oauth-project-id');
});
});
+44
View File
@@ -2,6 +2,7 @@ import { asc } from 'drizzle-orm';
import cron from 'node-cron';
import { db, schema } from '../db/index.js';
import { upsertSetting } from '../db/upsertSetting.js';
import { getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js';
const BACKUP_VERSION = '2.0';
@@ -12,6 +13,7 @@ type AccountRow = typeof schema.accounts.$inferSelect;
type AccountTokenRow = typeof schema.accountTokens.$inferSelect;
type TokenRouteRow = typeof schema.tokenRoutes.$inferSelect;
type RouteChannelRow = typeof schema.routeChannels.$inferSelect;
type RouteGroupSourceRow = typeof schema.routeGroupSources.$inferSelect;
interface AccountsBackupSection {
sites: SiteRow[];
@@ -19,6 +21,7 @@ interface AccountsBackupSection {
accountTokens: AccountTokenRow[];
tokenRoutes: TokenRouteRow[];
routeChannels: RouteChannelRow[];
routeGroupSources: RouteGroupSourceRow[];
}
interface PreferencesBackupSection {
@@ -104,6 +107,18 @@ function normalizeLegacyQuota(raw: unknown): number {
return value;
}
function resolveImportedOauthColumns(row: Pick<AccountRow, 'oauthProvider' | 'oauthAccountKey' | 'oauthProjectId' | 'extraConfig'>) {
const oauth = getOauthInfoFromExtraConfig(row.extraConfig);
const oauthProvider = row.oauthProvider || oauth?.provider || null;
const oauthAccountKey = row.oauthAccountKey || oauth?.accountKey || oauth?.accountId || null;
const oauthProjectId = row.oauthProjectId || oauth?.projectId || null;
return {
oauthProvider,
oauthAccountKey,
oauthProjectId,
};
}
function normalizeLegacyPlatform(raw: string): string {
const value = raw.trim().toLowerCase();
if (!value) return 'new-api';
@@ -215,6 +230,9 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
username,
accessToken: accountAccessToken,
apiToken,
oauthProvider: null,
oauthAccountKey: null,
oauthProjectId: null,
balance: importedBalance,
balanceUsed: importedUsed,
quota: importedQuota > 0 ? importedQuota : importedBalance,
@@ -238,6 +256,7 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
name: 'default',
token: apiToken,
tokenGroup: 'default',
valueStatus: 'ready',
source: 'legacy',
enabled: true,
isDefault: true,
@@ -253,6 +272,7 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
accountTokens,
tokenRoutes,
routeChannels,
routeGroupSources: [],
};
}
@@ -331,6 +351,7 @@ async function exportAccountsSection(): Promise<AccountsBackupSection> {
const accountTokens = await db.select().from(schema.accountTokens).orderBy(asc(schema.accountTokens.id)).all();
const tokenRoutes = await db.select().from(schema.tokenRoutes).orderBy(asc(schema.tokenRoutes.id)).all();
const routeChannels = await db.select().from(schema.routeChannels).orderBy(asc(schema.routeChannels.id)).all();
const routeGroupSources = await db.select().from(schema.routeGroupSources).orderBy(asc(schema.routeGroupSources.id)).all();
return {
sites,
@@ -338,6 +359,7 @@ async function exportAccountsSection(): Promise<AccountsBackupSection> {
accountTokens,
tokenRoutes,
routeChannels,
routeGroupSources,
};
}
@@ -388,6 +410,9 @@ function coerceAccountsSection(input: unknown): AccountsBackupSection | null {
const accountTokens = Array.isArray(input.accountTokens) ? input.accountTokens as AccountTokenRow[] : null;
const tokenRoutes = Array.isArray(input.tokenRoutes) ? input.tokenRoutes as TokenRouteRow[] : null;
const routeChannels = Array.isArray(input.routeChannels) ? input.routeChannels as RouteChannelRow[] : null;
const routeGroupSources = Array.isArray(input.routeGroupSources)
? input.routeGroupSources as RouteGroupSourceRow[]
: [];
if (!sites || !accounts || !accountTokens || !tokenRoutes || !routeChannels) return null;
@@ -397,6 +422,7 @@ function coerceAccountsSection(input: unknown): AccountsBackupSection | null {
accountTokens,
tokenRoutes,
routeChannels,
routeGroupSources,
};
}
@@ -460,6 +486,7 @@ function detectPreferencesSection(data: RawBackupData): PreferencesBackupSection
async function importAccountsSection(section: AccountsBackupSection): Promise<void> {
await db.transaction(async (tx) => {
await tx.delete(schema.routeChannels).run();
await tx.delete(schema.routeGroupSources).run();
await tx.delete(schema.tokenRoutes).run();
await tx.delete(schema.tokenModelAvailability).run();
await tx.delete(schema.modelAvailability).run();
@@ -490,12 +517,16 @@ async function importAccountsSection(section: AccountsBackupSection): Promise<vo
}
for (const row of section.accounts) {
const oauthColumns = resolveImportedOauthColumns(row);
await tx.insert(schema.accounts).values({
id: row.id,
siteId: row.siteId,
username: row.username,
accessToken: row.accessToken,
apiToken: row.apiToken,
oauthProvider: oauthColumns.oauthProvider,
oauthAccountKey: oauthColumns.oauthAccountKey,
oauthProjectId: oauthColumns.oauthProjectId,
balance: row.balance,
balanceUsed: row.balanceUsed,
quota: row.quota,
@@ -520,6 +551,7 @@ async function importAccountsSection(section: AccountsBackupSection): Promise<vo
name: row.name,
token: row.token,
tokenGroup: row.tokenGroup ?? null,
valueStatus: row.valueStatus ?? 'ready',
source: row.source,
enabled: row.enabled,
isDefault: row.isDefault,
@@ -535,12 +567,24 @@ async function importAccountsSection(section: AccountsBackupSection): Promise<vo
displayName: row.displayName ?? null,
displayIcon: row.displayIcon ?? null,
modelMapping: row.modelMapping,
routeMode: row.routeMode ?? 'pattern',
decisionSnapshot: row.decisionSnapshot ?? null,
decisionRefreshedAt: row.decisionRefreshedAt ?? null,
routingStrategy: row.routingStrategy ?? 'weighted',
enabled: row.enabled,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}).run();
}
for (const row of section.routeGroupSources || []) {
await tx.insert(schema.routeGroupSources).values({
id: row.id,
groupRouteId: row.groupRouteId,
sourceRouteId: row.sourceRouteId,
}).run();
}
for (const row of section.routeChannels) {
await tx.insert(schema.routeChannels).values({
id: row.id,
@@ -225,7 +225,20 @@ describe('databaseMigrationService', () => {
checkinLogs: [],
modelAvailability: [],
tokenModelAvailability: [],
tokenRoutes: [],
tokenRoutes: [{
id: 10,
modelPattern: 'claude-opus-4-6',
displayName: 'claude-opus-4-6',
displayIcon: 'icon-claude',
modelMapping: null,
routeMode: 'explicit_group',
decisionSnapshot: '{"channels":[1]}',
decisionRefreshedAt: '2026-03-14T01:30:00.000Z',
routingStrategy: 'round_robin',
enabled: true,
createdAt: '2026-03-14T00:00:00.000Z',
updatedAt: '2026-03-14T01:00:00.000Z',
}],
routeChannels: [],
proxyLogs: [],
proxyVideoTasks: [{
@@ -260,6 +273,11 @@ describe('databaseMigrationService', () => {
updatedAt: '2026-03-14T01:00:00.000Z',
deletedAt: null,
}],
routeGroupSources: [{
id: 9,
groupRouteId: 12,
sourceRouteId: 13,
}],
downstreamApiKeys: [],
events: [],
},
@@ -271,5 +289,10 @@ describe('databaseMigrationService', () => {
expect(statements.some((statement) => statement.table === 'site_disabled_models')).toBe(true);
expect(statements.some((statement) => statement.table === 'proxy_video_tasks')).toBe(true);
expect(statements.some((statement) => statement.table === 'proxy_files')).toBe(true);
expect(statements.some((statement) => statement.table === 'route_group_sources')).toBe(true);
const tokenRouteStatement = statements.find((statement) => statement.table === 'token_routes');
const routeModeIndex = tokenRouteStatement?.columns.indexOf('route_mode') ?? -1;
expect(routeModeIndex).toBeGreaterThanOrEqual(0);
expect(tokenRouteStatement?.values[routeModeIndex]).toBe('explicit_group');
});
});
@@ -36,6 +36,7 @@ type BackupSnapshot = {
tokenModelAvailability: Array<Record<string, unknown>>;
tokenRoutes: Array<Record<string, unknown>>;
routeChannels: Array<Record<string, unknown>>;
routeGroupSources: Array<Record<string, unknown>>;
proxyLogs: Array<Record<string, unknown>>;
proxyVideoTasks: Array<Record<string, unknown>>;
proxyFiles: Array<Record<string, unknown>>;
@@ -60,6 +61,7 @@ export interface DatabaseMigrationSummary {
accountTokens: number;
tokenRoutes: number;
routeChannels: number;
routeGroupSources: number;
checkinLogs: number;
modelAvailability: number;
tokenModelAvailability: number;
@@ -218,6 +220,7 @@ async function toBackupSnapshot(): Promise<BackupSnapshot> {
tokenModelAvailability: await db.select().from(schema.tokenModelAvailability).all() as Array<Record<string, unknown>>,
tokenRoutes: await db.select().from(schema.tokenRoutes).all() as Array<Record<string, unknown>>,
routeChannels: await db.select().from(schema.routeChannels).all() as Array<Record<string, unknown>>,
routeGroupSources: await db.select().from(schema.routeGroupSources).all() as Array<Record<string, unknown>>,
proxyLogs: await db.select().from(schema.proxyLogs).all() as Array<Record<string, unknown>>,
proxyVideoTasks: await db.select().from(schema.proxyVideoTasks).all() as Array<Record<string, unknown>>,
proxyFiles: await db.select().from(schema.proxyFiles).all() as Array<Record<string, unknown>>,
@@ -245,6 +248,7 @@ async function ensureTargetState(client: SqlClient, overwrite: boolean): Promise
async function clearTargetData(client: SqlClient): Promise<void> {
const tables = [
'route_channels',
'route_group_sources',
'token_model_availability',
'model_availability',
'checkin_logs',
@@ -336,13 +340,14 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
for (const row of snapshot.accounts.accountTokens) {
statements.push({
table: 'account_tokens',
columns: ['id', 'account_id', 'name', 'token', 'token_group', 'source', 'enabled', 'is_default', 'created_at', 'updated_at'],
columns: ['id', 'account_id', 'name', 'token', 'token_group', 'value_status', 'source', 'enabled', 'is_default', 'created_at', 'updated_at'],
values: [
asNumber(row.id, 0),
asNumber(row.accountId, 0),
asNullableString(row.name),
asNullableString(row.token),
asNullableString(row.tokenGroup),
asNullableString((row as { valueStatus?: unknown }).valueStatus) ?? 'ready',
asNullableString(row.source) ?? 'manual',
asBoolean(row.enabled, true),
asBoolean(row.isDefault, false),
@@ -400,13 +405,14 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
for (const row of snapshot.accounts.tokenRoutes) {
statements.push({
table: 'token_routes',
columns: ['id', 'model_pattern', 'display_name', 'display_icon', 'model_mapping', 'decision_snapshot', 'decision_refreshed_at', 'routing_strategy', 'enabled', 'created_at', 'updated_at'],
columns: ['id', 'model_pattern', 'display_name', 'display_icon', 'model_mapping', 'route_mode', 'decision_snapshot', 'decision_refreshed_at', 'routing_strategy', 'enabled', 'created_at', 'updated_at'],
values: [
asNumber(row.id, 0),
asNullableString(row.modelPattern),
asNullableString(row.displayName),
asNullableString(row.displayIcon),
asNullableString(row.modelMapping),
asNullableString(row.routeMode) ?? 'pattern',
asNullableString(row.decisionSnapshot),
asNullableString(row.decisionRefreshedAt),
asNullableString(row.routingStrategy),
@@ -445,15 +451,28 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
});
}
for (const row of (snapshot.accounts.routeGroupSources || [])) {
statements.push({
table: 'route_group_sources',
columns: ['id', 'group_route_id', 'source_route_id'],
values: [
asNumber(row.id, 0),
asNumber(row.groupRouteId, 0),
asNumber(row.sourceRouteId, 0),
],
});
}
for (const row of snapshot.accounts.proxyLogs) {
statements.push({
table: 'proxy_logs',
columns: ['id', 'route_id', 'channel_id', 'account_id', 'model_requested', 'model_actual', 'status', 'http_status', 'latency_ms', 'prompt_tokens', 'completion_tokens', 'total_tokens', 'estimated_cost', 'billing_details', 'error_message', 'retry_count', 'created_at'],
columns: ['id', 'route_id', 'channel_id', 'account_id', 'downstream_api_key_id', 'model_requested', 'model_actual', 'status', 'http_status', 'latency_ms', 'prompt_tokens', 'completion_tokens', 'total_tokens', 'estimated_cost', 'billing_details', 'error_message', 'retry_count', 'created_at'],
values: [
asNumber(row.id, 0),
asNumber(row.routeId, null),
asNumber(row.channelId, null),
asNumber(row.accountId, null),
asNumber((row as any).downstreamApiKeyId ?? (row as any).downstream_api_key_id, null),
asNullableString(row.modelRequested),
asNullableString(row.modelActual),
asNullableString(row.status),
@@ -606,6 +625,7 @@ async function syncPostgresSequences(client: SqlClient): Promise<void> {
'token_model_availability',
'token_routes',
'route_channels',
'route_group_sources',
'proxy_logs',
'proxy_video_tasks',
'proxy_files',
@@ -613,9 +633,9 @@ async function syncPostgresSequences(client: SqlClient): Promise<void> {
'events',
];
for (const table of tables) {
await client.execute(`SELECT setval(pg_get_serial_sequence('${table}', 'id'), COALESCE((SELECT MAX(id) FROM "${table}"), 1), TRUE)`);
await client.execute(`SELECT setval(pg_get_serial_sequence('${table}', 'id'), COALESCE((SELECT MAX(id) FROM "${table}"), 1), TRUE)`);
}
}
}
export async function bootstrapRuntimeDatabaseSchema(input: Pick<NormalizedDatabaseMigrationInput, 'dialect' | 'connectionString' | 'ssl'>): Promise<void> {
const client = await createClient({
@@ -678,6 +698,7 @@ export async function migrateCurrentDatabase(input: DatabaseMigrationInput): Pro
downstreamApiKeys: snapshot.accounts.downstreamApiKeys.length,
events: snapshot.accounts.events.length,
settings: snapshot.preferences.settings.length,
routeGroupSources: snapshot.accounts.routeGroupSources.length,
},
};
}
@@ -12,6 +12,8 @@ export type DownstreamApiKeyPolicyView = {
key: string;
keyMasked: string;
description: string | null;
groupName: string | null;
tags: string[];
enabled: boolean;
expiresAt: string | null;
maxCost: number | null;
@@ -97,6 +99,34 @@ function parseJson(value: string | null | undefined): unknown {
}
}
export function normalizeGroupNameInput(input: unknown): string | null {
if (typeof input !== 'string') return null;
const value = input.trim();
if (!value) return null;
return value.slice(0, 64);
}
export function normalizeTagsInput(input: unknown): string[] {
const rawValues = Array.isArray(input)
? input
: (typeof input === 'string' ? input.split(/[\r\n,,]+/g) : []);
const tags: string[] = [];
const seen = new Set<string>();
for (const raw of rawValues) {
const value = String(raw || '').trim();
if (!value) continue;
const normalized = value.slice(0, 32);
const dedupeKey = normalized.toLowerCase();
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
tags.push(normalized);
if (tags.length >= 20) break;
}
return tags;
}
export function normalizeSupportedModelsInput(input: unknown): string[] {
if (Array.isArray(input)) {
return input
@@ -226,6 +256,8 @@ export function toDownstreamApiKeyPolicyView(row: DownstreamApiKeyRow): Downstre
key: row.key,
keyMasked: maskSecret(row.key),
description: row.description || null,
groupName: normalizeGroupNameInput(row.groupName),
tags: normalizeTagsInput(parseJson(row.tags)),
enabled: !!row.enabled,
expiresAt: row.expiresAt || null,
maxCost: row.maxCost ?? null,
@@ -385,6 +417,8 @@ export function normalizeDownstreamApiKeyPayload(input: {
name?: unknown;
key?: unknown;
description?: unknown;
groupName?: unknown;
tags?: unknown;
enabled?: unknown;
expiresAt?: unknown;
maxCost?: unknown;
@@ -398,6 +432,8 @@ export function normalizeDownstreamApiKeyPayload(input: {
const description = typeof input.description === 'string'
? input.description.trim()
: '';
const groupName = normalizeGroupNameInput(input.groupName);
const tags = normalizeTagsInput(input.tags);
const enabled = input.enabled === undefined ? true : !!input.enabled;
const expiresAtRaw = typeof input.expiresAt === 'string'
@@ -422,6 +458,8 @@ export function normalizeDownstreamApiKeyPayload(input: {
name,
key,
description: description || null,
groupName,
tags,
enabled,
expiresAt,
maxCost,
@@ -6,6 +6,7 @@ import { eq } from 'drizzle-orm';
const getApiTokenMock = vi.fn();
const getModelsMock = vi.fn();
const undiciFetchMock = vi.fn();
vi.mock('./platforms/index.js', () => ({
getAdapter: () => ({
@@ -14,6 +15,10 @@ vi.mock('./platforms/index.js', () => ({
}),
}));
vi.mock('undici', () => ({
fetch: (...args: unknown[]) => undiciFetchMock(...args),
}));
type DbModule = typeof import('../db/index.js');
type ModelServiceModule = typeof import('./modelService.js');
@@ -39,6 +44,7 @@ describe('refreshModelsForAccount credential discovery', () => {
beforeEach(async () => {
getApiTokenMock.mockReset();
getModelsMock.mockReset();
undiciFetchMock.mockReset();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
@@ -100,6 +106,42 @@ describe('refreshModelsForAccount credential discovery', () => {
expect(tokenRows).toHaveLength(0);
});
it('deduplicates discovered model names before writing availability rows', async () => {
getApiTokenMock.mockResolvedValue(null);
getModelsMock.mockResolvedValue(['? ', '?', 'GPT-4.1', 'gpt-4.1']);
const site = await db.insert(schema.sites).values({
name: 'site-dedupe',
url: 'https://site-dedupe.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'dedupe-user',
accessToken: 'session-token',
apiToken: null,
status: 'active',
}).returning().get();
const result = await refreshModelsForAccount(account.id);
expect(result).toMatchObject({
accountId: account.id,
refreshed: true,
status: 'success',
modelCount: 2,
modelsPreview: ['?', 'GPT-4.1'],
});
const rows = await db.select().from(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, account.id))
.all();
expect(rows.map((row) => row.modelName).sort()).toEqual(['?', 'GPT-4.1']);
});
it('marks runtime health unhealthy when model discovery fails', async () => {
getApiTokenMock.mockResolvedValue(null);
getModelsMock.mockRejectedValue(new Error('HTTP 401: invalid token'));
@@ -216,4 +258,314 @@ describe('refreshModelsForAccount credential discovery', () => {
reason: 'adapter_or_status',
});
});
it('does not scan masked_pending placeholders as token credentials', async () => {
getApiTokenMock.mockResolvedValue(null);
getModelsMock.mockImplementation(async (_baseUrl: string, token: string) => (
token === 'sk-mask***tail' ? ['gpt-5.2-codex'] : []
));
const site = await db.insert(schema.sites).values({
name: 'site-placeholder',
url: 'https://site-placeholder.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'placeholder-user',
accessToken: '',
apiToken: null,
status: 'active',
extraConfig: JSON.stringify({ credentialMode: 'session' }),
}).returning().get();
const placeholder = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-token',
token: 'sk-mask***tail',
source: 'sync',
enabled: true,
isDefault: false,
valueStatus: 'masked_pending' as any,
}).returning().get();
const result = await refreshModelsForAccount(account.id);
expect(result).toMatchObject({
accountId: account.id,
refreshed: true,
status: 'failed',
tokenScanned: 0,
});
const placeholderModels = await db.select().from(schema.tokenModelAvailability)
.where(eq(schema.tokenModelAvailability.tokenId, placeholder.id))
.all();
expect(placeholderModels).toEqual([]);
expect(getModelsMock).not.toHaveBeenCalledWith(site.url, 'sk-mask***tail', account.username);
});
it('discovers codex models from upstream cloud endpoint without adapter model fetch', async () => {
getApiTokenMock.mockResolvedValue(null);
getModelsMock.mockRejectedValue(new Error('codex plan discovery should not call adapter.getModels'));
undiciFetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
models: [
{ id: 'gpt-5.4' },
{ id: 'gpt-5.3-codex' },
{ id: 'gpt-5.2-codex' },
{ id: 'gpt-5.2' },
{ id: 'gpt-5.1-codex-max' },
{ id: 'gpt-5.1-codex' },
{ id: 'gpt-5.1' },
{ id: 'gpt-5-codex' },
{ id: 'gpt-5' },
{ id: 'gpt-5.1-codex-mini' },
{ id: 'gpt-5-codex-mini' },
],
}),
text: async () => JSON.stringify({ ok: true }),
});
const site = await db.insert(schema.sites).values({
name: 'codex-site',
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: 'codex-user@example.com',
accessToken: 'oauth-access-token',
apiToken: null,
status: 'active',
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-123',
email: 'codex-user@example.com',
planType: 'plus',
},
}),
}).returning().get();
const result = await refreshModelsForAccount(account.id);
expect(result).toMatchObject({
accountId: account.id,
refreshed: true,
status: 'success',
errorCode: null,
tokenScanned: 0,
discoveredByCredential: true,
modelCount: 11,
});
expect(result.modelsPreview).toEqual([
'gpt-5.4',
'gpt-5.3-codex',
'gpt-5.2-codex',
'gpt-5.2',
'gpt-5.1-codex-max',
'gpt-5.1-codex',
'gpt-5.1',
'gpt-5-codex',
'gpt-5',
'gpt-5.1-codex-mini',
]);
expect(getModelsMock).not.toHaveBeenCalled();
expect(undiciFetchMock).toHaveBeenCalledTimes(1);
expect(String(undiciFetchMock.mock.calls[0]?.[0] || '')).toBe('https://chatgpt.com/backend-api/codex/models?client_version=1.0.0');
expect(undiciFetchMock.mock.calls[0]?.[1]).toMatchObject({
method: 'GET',
headers: expect.objectContaining({
Authorization: 'Bearer oauth-access-token',
'Chatgpt-Account-Id': 'chatgpt-account-123',
Originator: 'codex_cli_rs',
}),
});
const rows = await db.select().from(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, account.id))
.all();
const modelNames = rows.map((row) => row.modelName);
expect(modelNames.sort()).toEqual([
'gpt-5',
'gpt-5-codex',
'gpt-5-codex-mini',
'gpt-5.1',
'gpt-5.1-codex',
'gpt-5.1-codex-max',
'gpt-5.1-codex-mini',
'gpt-5.2',
'gpt-5.2-codex',
'gpt-5.3-codex',
'gpt-5.4',
]);
});
it('marks codex oauth account abnormal when upstream cloud discovery fails', async () => {
getApiTokenMock.mockResolvedValue(null);
getModelsMock.mockRejectedValue(new Error('codex plan discovery should not call adapter.getModels'));
undiciFetchMock.mockResolvedValue({
ok: false,
status: 403,
json: async () => ({ error: 'forbidden' }),
text: async () => 'forbidden',
});
const site = await db.insert(schema.sites).values({
name: 'codex-team-site',
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: 'team-user@example.com',
accessToken: 'oauth-access-token',
apiToken: null,
status: 'active',
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-team',
email: 'team-user@example.com',
planType: 'team',
},
}),
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-5.2-codex',
available: true,
checkedAt: '2026-03-16T12:00:00.000Z',
}).run();
const result = await refreshModelsForAccount(account.id);
expect(result).toMatchObject({
accountId: account.id,
refreshed: true,
status: 'failed',
errorCode: 'unauthorized',
tokenScanned: 0,
discoveredByCredential: false,
modelCount: 0,
});
expect(getModelsMock).not.toHaveBeenCalled();
expect(undiciFetchMock).toHaveBeenCalledTimes(1);
const rows = await db.select().from(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, account.id))
.all();
expect(rows).toEqual([]);
const latest = await db.select().from(schema.accounts)
.where(eq(schema.accounts.id, account.id))
.get();
const parsed = JSON.parse(latest!.extraConfig || '{}');
expect(parsed.oauth).toMatchObject({
provider: 'codex',
modelDiscoveryStatus: 'abnormal',
});
expect(parsed.oauth.lastModelSyncError).toContain('HTTP 403');
expect(parsed.oauth.lastModelSyncAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
expect(parsed.runtimeHealth?.state).toBe('unhealthy');
});
it('discovers antigravity oauth models via fetchAvailableModels fallback using the oauth project id', async () => {
getApiTokenMock.mockResolvedValue(null);
getModelsMock.mockRejectedValue(new Error('antigravity oauth discovery should not call adapter.getModels'));
undiciFetchMock
.mockResolvedValueOnce({
ok: false,
status: 503,
json: async () => ({ error: 'unavailable' }),
text: async () => 'unavailable',
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
models: {
'gemini-3-pro-preview': { displayName: 'Gemini 3 Pro Preview' },
'claude-sonnet-4-5-20250929': { displayName: 'Claude Sonnet 4.5' },
},
}),
text: async () => JSON.stringify({ ok: true }),
});
const site = await db.insert(schema.sites).values({
name: 'antigravity-site',
url: 'https://cloudcode-pa.googleapis.com',
platform: 'antigravity',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'antigravity-user@example.com',
accessToken: 'antigravity-access-token',
apiToken: null,
status: 'active',
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'antigravity',
email: 'antigravity-user@example.com',
projectId: 'project-demo',
},
}),
}).returning().get();
const result = await refreshModelsForAccount(account.id);
expect(result).toMatchObject({
accountId: account.id,
refreshed: true,
status: 'success',
errorCode: null,
tokenScanned: 0,
discoveredByCredential: true,
discoveredApiToken: false,
modelCount: 2,
modelsPreview: ['gemini-3-pro-preview', 'claude-sonnet-4-5-20250929'],
});
expect(getModelsMock).not.toHaveBeenCalled();
expect(undiciFetchMock).toHaveBeenCalledTimes(2);
expect(String(undiciFetchMock.mock.calls[0]?.[0] || '')).toBe('https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels');
expect(String(undiciFetchMock.mock.calls[1]?.[0] || '')).toBe('https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels');
expect(undiciFetchMock.mock.calls[0]?.[1]).toMatchObject({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer antigravity-access-token',
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': 'antigravity/1.19.6 darwin/arm64',
}),
});
const discoveryHeaders = undiciFetchMock.mock.calls[0]?.[1]?.headers as Record<string, string>;
expect(discoveryHeaders).not.toHaveProperty('X-Goog-Api-Client');
expect(discoveryHeaders).not.toHaveProperty('Client-Metadata');
expect(JSON.parse(String(undiciFetchMock.mock.calls[0]?.[1]?.body || '{}'))).toEqual({
project: 'project-demo',
});
const rows = await db.select().from(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, account.id))
.all();
expect(rows.map((row) => row.modelName).sort()).toEqual([
'claude-sonnet-4-5-20250929',
'gemini-3-pro-preview',
]);
});
});
+53
View File
@@ -85,6 +85,59 @@ describe('rebuildTokenRoutesFromAvailability', () => {
expect(channels[0]?.manualOverride).toBe(false);
});
it('creates an exact route with an account-direct channel for oauth model availability', async () => {
const site = await db.insert(schema.sites).values({
name: 'codex-site',
url: 'https://chatgpt.com/backend-api/codex',
platform: 'codex',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'codex-user@example.com',
accessToken: 'oauth-access-token',
apiToken: null,
status: 'active',
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-123',
email: 'codex-user@example.com',
planType: 'team',
},
}),
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-5.2-codex',
available: true,
latencyMs: 320,
checkedAt: '2026-03-17T00:00:00.000Z',
}).run();
const rebuild = await rebuildTokenRoutesFromAvailability();
expect(rebuild.models).toBe(1);
const route = await db.select().from(schema.tokenRoutes)
.where(eq(schema.tokenRoutes.modelPattern, 'gpt-5.2-codex'))
.get();
expect(route).toBeDefined();
const channels = await db.select().from(schema.routeChannels)
.where(and(
eq(schema.routeChannels.routeId, route!.id),
eq(schema.routeChannels.accountId, account.id),
))
.all();
expect(channels).toHaveLength(1);
expect(channels[0]?.tokenId ?? null).toBeNull();
expect(channels[0]?.manualOverride).toBe(false);
});
it('removes stale exact routes and keeps wildcard routes on rebuild', async () => {
const site = await db.insert(schema.sites).values({
name: 'site-1',
+734 -58
View File
@@ -1,18 +1,122 @@
import { and, eq } from 'drizzle-orm';
import { fetch } from 'undici';
import { db, schema } from '../db/index.js';
import { getAdapter } from './platforms/index.js';
import { ensureDefaultTokenForAccount, getPreferredAccountToken } from './accountTokenService.js';
import { getCredentialModeFromExtraConfig, getProxyUrlFromExtraConfig, resolvePlatformUserId } from './accountExtraConfig.js';
import {
ACCOUNT_TOKEN_VALUE_STATUS_READY,
ensureDefaultTokenForAccount,
getPreferredAccountToken,
isMaskedTokenValue,
isUsableAccountToken,
} from './accountTokenService.js';
import {
getCredentialModeFromExtraConfig,
getProxyUrlFromExtraConfig,
mergeAccountExtraConfig,
resolvePlatformUserId,
supportsDirectAccountRoutingConnection,
} from './accountExtraConfig.js';
import { invalidateTokenRouterCache } from './tokenRouter.js';
import { setAccountRuntimeHealth } from './accountHealthService.js';
import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js';
import { withAccountProxyOverride } from './siteProxy.js';
import { withAccountProxyOverride, withSiteRecordProxyRequestInit } from './siteProxy.js';
import { getCodexOauthInfoFromExtraConfig, isCodexPlatform } from './oauth/codexAccount.js';
import { buildOauthInfo, getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js';
import { CLAUDE_DEFAULT_ANTHROPIC_VERSION } from './oauth/claudeProvider.js';
import {
ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL,
ANTIGRAVITY_MODELS_USER_AGENT,
ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL,
ANTIGRAVITY_UPSTREAM_BASE_URL,
} from './oauth/antigravityProvider.js';
import {
GEMINI_CLI_GOOGLE_API_CLIENT,
GEMINI_CLI_REQUIRED_SERVICE,
GEMINI_CLI_USER_AGENT,
} from './oauth/geminiCliProvider.js';
const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
const MODEL_REFRESH_BATCH_SIZE = 3;
const CODEX_MODELS_CLIENT_VERSION = '1.0.0';
const CLAUDE_STATIC_MODELS = [
'claude-haiku-4-5-20251001',
'claude-sonnet-4-5-20250929',
'claude-sonnet-4-6',
'claude-opus-4-6',
'claude-opus-4-5-20251101',
'claude-opus-4-1-20250805',
'claude-opus-4-20250514',
'claude-sonnet-4-20250514',
'claude-3-7-sonnet-20250219',
'claude-3-5-haiku-20241022',
];
const GEMINI_CLI_STATIC_MODELS = [
'gemini-2.5-pro',
'gemini-2.5-flash',
'gemini-2.5-flash-lite',
'gemini-3-pro-preview',
'gemini-3.1-pro-preview',
'gemini-3-flash-preview',
'gemini-3.1-flash-lite-preview',
];
type ModelRefreshErrorCode = 'timeout' | 'unauthorized' | 'empty_models' | 'unknown';
type ModelRefreshSkipCode = 'site_disabled' | 'adapter_or_status';
export type ModelRefreshAccountNotFoundResult = {
accountId: number;
refreshed: false;
status: 'failed';
errorCode: 'account_not_found';
errorMessage: '账号不存在';
modelCount: 0;
modelsPreview: string[];
reason: 'account_not_found';
};
export type ModelRefreshSkippedResult = {
accountId: number;
refreshed: false;
status: 'skipped';
errorCode: ModelRefreshSkipCode;
errorMessage: string;
modelCount: 0;
modelsPreview: string[];
reason: ModelRefreshSkipCode;
};
export type ModelRefreshFailureResult = {
accountId: number;
refreshed: true;
status: 'failed';
errorCode: ModelRefreshErrorCode;
errorMessage: string;
modelCount: 0;
modelsPreview: string[];
tokenScanned: number;
discoveredByCredential: boolean;
discoveredApiToken: boolean;
};
export type ModelRefreshSuccessResult = {
accountId: number;
refreshed: true;
status: 'success';
errorCode: null;
errorMessage: '';
modelCount: number;
modelsPreview: string[];
tokenScanned: number;
discoveredByCredential: boolean;
discoveredApiToken: boolean;
};
export type ModelRefreshResult =
| ModelRefreshAccountNotFoundResult
| ModelRefreshSkippedResult
| ModelRefreshFailureResult
| ModelRefreshSuccessResult;
function classifyModelDiscoveryError(message: string): ModelRefreshErrorCode {
const lowered = message.toLowerCase();
@@ -41,14 +145,253 @@ function isApiKeyConnection(account: typeof schema.accounts.$inferSelect): boole
}
function normalizeModels(models: string[]): string[] {
return Array.from(new Set(models.filter((model) => typeof model === 'string' && model.trim().length > 0)));
const normalizedModels: string[] = [];
const seen = new Set<string>();
for (const rawModel of models) {
if (typeof rawModel !== 'string') continue;
const modelName = rawModel.trim();
if (!modelName) continue;
// Keep app/database behavior stable across SQLite/MySQL by deduping with a
// case-insensitive key after trimming whitespace.
const dedupeKey = modelName.toLowerCase();
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
normalizedModels.push(modelName);
}
return normalizedModels;
}
function buildCodexModelsEndpoint(baseUrl: string): string {
const normalized = (baseUrl || '').replace(/\/+$/, '');
return `${normalized}/models?client_version=${encodeURIComponent(CODEX_MODELS_CLIENT_VERSION)}`;
}
function extractCodexModelIds(payload: unknown): string[] {
const collection = (() => {
if (Array.isArray(payload)) return payload;
if (!payload || typeof payload !== 'object') return [];
const record = payload as Record<string, unknown>;
if (Array.isArray(record.models)) return record.models;
if (Array.isArray(record.data)) return record.data;
if (Array.isArray(record.items)) return record.items;
return [];
})();
return collection.flatMap((item) => {
if (typeof item === 'string') return [item];
if (!item || typeof item !== 'object' || Array.isArray(item)) return [];
const record = item as Record<string, unknown>;
const id = typeof record.id === 'string'
? record.id
: (typeof record.slug === 'string' ? record.slug : (typeof record.model === 'string' ? record.model : ''));
return id ? [id] : [];
});
}
function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
}
function extractAntigravityModelIds(payload: unknown): string[] {
if (!payload || typeof payload !== 'object') return [];
const record = payload as { models?: unknown };
if (record.models && typeof record.models === 'object' && !Array.isArray(record.models)) {
return Object.keys(record.models).map((name) => name.trim()).filter(Boolean);
}
if (!Array.isArray(record.models)) return [];
return record.models.flatMap((item) => {
if (typeof item === 'string') {
const trimmed = item.trim();
return trimmed ? [trimmed] : [];
}
if (!item || typeof item !== 'object' || Array.isArray(item)) return [];
const value = item as { id?: unknown; name?: unknown };
const id = typeof value.id === 'string'
? value.id.trim()
: (typeof value.name === 'string' ? value.name.trim() : '');
return id ? [id] : [];
});
}
function buildAntigravityDiscoveryBaseUrls(siteUrl: string): string[] {
const seen = new Set<string>();
return [
siteUrl,
ANTIGRAVITY_UPSTREAM_BASE_URL,
ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL,
ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL,
].flatMap((candidate) => {
const normalized = normalizeBaseUrl(candidate);
if (!normalized || seen.has(normalized)) return [];
seen.add(normalized);
return [normalized];
});
}
async function updateOauthModelDiscoveryState(input: {
account: typeof schema.accounts.$inferSelect;
checkedAt: string;
status: 'healthy' | 'abnormal';
lastModelSyncError?: string;
lastDiscoveredModels?: string[];
}) {
const oauth = getOauthInfoFromExtraConfig(input.account.extraConfig);
if (!oauth) return input.account.extraConfig || null;
const extraConfig = mergeAccountExtraConfig(input.account.extraConfig, {
oauth: buildOauthInfo(input.account.extraConfig, {
provider: oauth.provider,
modelDiscoveryStatus: input.status,
lastModelSyncAt: input.checkedAt,
lastModelSyncError: input.lastModelSyncError,
lastDiscoveredModels: input.lastDiscoveredModels ?? [],
}),
});
await db.update(schema.accounts).set({
extraConfig,
updatedAt: input.checkedAt,
}).where(eq(schema.accounts.id, input.account.id)).run();
return extraConfig;
}
async function discoverCodexModelsFromCloud(input: {
site: typeof schema.sites.$inferSelect;
account: typeof schema.accounts.$inferSelect;
}): Promise<string[]> {
const accessToken = (input.account.accessToken || '').trim();
if (!accessToken) {
throw new Error('codex oauth access token missing');
}
const oauth = getCodexOauthInfoFromExtraConfig(input.account.extraConfig);
const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
Originator: 'codex_cli_rs',
};
if (oauth?.accountId) {
headers['Chatgpt-Account-Id'] = oauth.accountId;
}
const response = await fetch(
buildCodexModelsEndpoint(input.site.url),
withSiteRecordProxyRequestInit(input.site, { method: 'GET', headers }),
);
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`HTTP ${response.status}: ${text || 'codex model discovery failed'}`);
}
return normalizeModels(extractCodexModelIds(await response.json()));
}
async function validateClaudeOauthConnection(input: {
site: typeof schema.sites.$inferSelect;
account: typeof schema.accounts.$inferSelect;
}): Promise<void> {
const accessToken = (input.account.accessToken || '').trim();
if (!accessToken) {
throw new Error('claude oauth access token missing');
}
const response = await fetch(
`${input.site.url.replace(/\/+$/, '')}/v1/models`,
withSiteRecordProxyRequestInit(input.site, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION,
},
}),
);
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`HTTP ${response.status}: ${text || 'claude oauth validation failed'}`);
}
}
async function validateGeminiCliOauthConnection(input: {
account: typeof schema.accounts.$inferSelect;
}): Promise<void> {
const accessToken = (input.account.accessToken || '').trim();
if (!accessToken) {
throw new Error('gemini cli oauth access token missing');
}
const oauth = getOauthInfoFromExtraConfig(input.account.extraConfig);
const projectId = (oauth?.projectId || '').trim();
if (!projectId) {
throw new Error('gemini cli oauth project id missing');
}
const response = await fetch(
`https://serviceusage.googleapis.com/v1/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'User-Agent': GEMINI_CLI_USER_AGENT,
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
},
},
);
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`HTTP ${response.status}: ${text || 'gemini cli oauth validation failed'}`);
}
const payload = await response.json() as { state?: unknown };
if (String(payload.state || '').trim().toUpperCase() !== 'ENABLED') {
throw new Error(`Cloud AI API not enabled for project ${projectId}`);
}
}
async function discoverAntigravityModelsFromCloud(input: {
site: typeof schema.sites.$inferSelect;
account: typeof schema.accounts.$inferSelect;
}): Promise<string[]> {
const accessToken = (input.account.accessToken || '').trim();
if (!accessToken) {
throw new Error('antigravity oauth access token missing');
}
const oauth = getOauthInfoFromExtraConfig(input.account.extraConfig);
const requestBody = oauth?.projectId ? { project: oauth.projectId } : {};
let lastError = '';
for (const baseUrl of buildAntigravityDiscoveryBaseUrls(input.site.url || ANTIGRAVITY_UPSTREAM_BASE_URL)) {
const response = await fetch(
`${baseUrl}/v1internal:fetchAvailableModels`,
withSiteRecordProxyRequestInit(input.site, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': ANTIGRAVITY_MODELS_USER_AGENT,
},
body: JSON.stringify(requestBody),
}),
);
if (!response.ok) {
lastError = await response.text().catch(() => '') || `HTTP ${response.status}`;
continue;
}
const payload = await response.json();
const models = normalizeModels(extractAntigravityModelIds(payload));
if (models.length > 0) {
return models;
}
lastError = '未获取到可用模型';
}
throw new Error(lastError || '未获取到可用模型');
}
function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim();
if (!normalized) return false;
if (normalized.toLowerCase().startsWith('re:')) return false;
return !/[\*\?\[]/.test(normalized);
return !/[\*\?]/.test(normalized);
}
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
@@ -65,27 +408,93 @@ async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMe
}
}
export async function refreshModelsForAccount(accountId: number) {
function buildAccountNotFoundRefreshResult(accountId: number): ModelRefreshAccountNotFoundResult {
return {
accountId,
refreshed: false,
status: 'failed',
errorCode: 'account_not_found',
errorMessage: '账号不存在',
modelCount: 0,
modelsPreview: [],
reason: 'account_not_found',
};
}
function buildSkippedRefreshResult(
accountId: number,
code: ModelRefreshSkipCode,
errorMessage: string,
): ModelRefreshSkippedResult {
return {
accountId,
refreshed: false,
status: 'skipped',
errorCode: code,
errorMessage,
modelCount: 0,
modelsPreview: [],
reason: code,
};
}
function buildFailedRefreshResult(input: {
accountId: number;
errorCode: ModelRefreshErrorCode;
errorMessage: string;
tokenScanned: number;
discoveredByCredential: boolean;
discoveredApiToken: boolean;
}): ModelRefreshFailureResult {
return {
accountId: input.accountId,
refreshed: true,
status: 'failed',
errorCode: input.errorCode,
errorMessage: input.errorMessage,
modelCount: 0,
modelsPreview: [],
tokenScanned: input.tokenScanned,
discoveredByCredential: input.discoveredByCredential,
discoveredApiToken: input.discoveredApiToken,
};
}
function buildSuccessfulRefreshResult(input: {
accountId: number;
modelCount: number;
modelsPreview: string[];
tokenScanned: number;
discoveredByCredential: boolean;
discoveredApiToken: boolean;
}): ModelRefreshSuccessResult {
return {
accountId: input.accountId,
refreshed: true,
status: 'success',
errorCode: null,
errorMessage: '',
modelCount: input.modelCount,
modelsPreview: input.modelsPreview,
tokenScanned: input.tokenScanned,
discoveredByCredential: input.discoveredByCredential,
discoveredApiToken: input.discoveredApiToken,
};
}
export async function refreshModelsForAccount(accountId: number): Promise<ModelRefreshResult> {
const row = await db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.accounts.id, accountId))
.get();
if (!row) {
return {
accountId,
refreshed: false,
status: 'failed',
errorCode: 'account_not_found',
errorMessage: '账号不存在',
modelCount: 0,
modelsPreview: [],
reason: 'account_not_found',
};
return buildAccountNotFoundRefreshResult(accountId);
}
const account = row.accounts;
const site = row.sites;
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
const adapter = getAdapter(site.platform);
const accountTokens = await db.select()
@@ -104,29 +513,287 @@ export async function refreshModelsForAccount(accountId: number) {
}
if (isSiteDisabled(site.status)) {
return {
accountId,
refreshed: false,
status: 'skipped',
errorCode: 'site_disabled',
errorMessage: '站点已禁用',
modelCount: 0,
modelsPreview: [],
reason: 'site_disabled',
};
return buildSkippedRefreshResult(accountId, 'site_disabled', '站点已禁用');
}
if (!adapter || account.status !== 'active') {
return {
accountId,
refreshed: false,
status: 'skipped',
errorCode: 'adapter_or_status',
errorMessage: '平台不可用或账号未激活',
modelCount: 0,
modelsPreview: [],
reason: 'adapter_or_status',
};
if (account.status !== 'active') {
return buildSkippedRefreshResult(accountId, 'adapter_or_status', '平台不可用或账号未激活');
}
if (oauth?.provider === 'codex') {
const checkedAt = new Date().toISOString();
const startedAt = Date.now();
try {
const codexModels = await withTimeout(
() => discoverCodexModelsFromCloud({ site, account }),
MODEL_DISCOVERY_TIMEOUT_MS,
`codex model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
);
if (codexModels.length === 0) {
throw new Error('未获取到可用模型');
}
await db.insert(schema.modelAvailability).values(
codexModels.map((modelName) => ({
accountId,
modelName,
available: true,
latencyMs: Date.now() - startedAt,
checkedAt,
})),
).run();
await updateOauthModelDiscoveryState({
account,
checkedAt,
status: 'healthy',
lastDiscoveredModels: codexModels,
});
await setAccountRuntimeHealth(accountId, {
state: 'healthy',
reason: 'Codex 云端模型探测成功',
source: 'model-discovery',
checkedAt,
});
return buildSuccessfulRefreshResult({
accountId,
modelCount: codexModels.length,
modelsPreview: codexModels.slice(0, 10),
tokenScanned: 0,
discoveredByCredential: true,
discoveredApiToken: false,
});
} catch (err) {
const rawMessage = (err as { message?: string })?.message || 'codex model discovery failed';
const errorCode = classifyModelDiscoveryError(rawMessage);
const errorMessage = `Codex 模型获取失败(${rawMessage})`;
await updateOauthModelDiscoveryState({
account,
checkedAt,
status: 'abnormal',
lastModelSyncError: errorMessage,
lastDiscoveredModels: [],
});
await setAccountRuntimeHealth(account.id, {
state: 'unhealthy',
reason: errorMessage,
source: 'model-discovery',
checkedAt,
});
return buildFailedRefreshResult({
accountId,
errorCode,
errorMessage,
tokenScanned: 0,
discoveredByCredential: false,
discoveredApiToken: false,
});
}
}
if (oauth?.provider === 'claude') {
const checkedAt = new Date().toISOString();
const startedAt = Date.now();
try {
await withTimeout(
() => validateClaudeOauthConnection({ site, account }),
MODEL_DISCOVERY_TIMEOUT_MS,
`claude oauth validation timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
);
await db.insert(schema.modelAvailability).values(
CLAUDE_STATIC_MODELS.map((modelName) => ({
accountId,
modelName,
available: true,
latencyMs: Date.now() - startedAt,
checkedAt,
})),
).run();
await updateOauthModelDiscoveryState({
account,
checkedAt,
status: 'healthy',
lastDiscoveredModels: CLAUDE_STATIC_MODELS,
});
await setAccountRuntimeHealth(accountId, {
state: 'healthy',
reason: 'Claude OAuth 健康探测成功',
source: 'model-discovery',
checkedAt,
});
return buildSuccessfulRefreshResult({
accountId,
modelCount: CLAUDE_STATIC_MODELS.length,
modelsPreview: CLAUDE_STATIC_MODELS.slice(0, 10),
tokenScanned: 0,
discoveredByCredential: true,
discoveredApiToken: false,
});
} catch (err) {
const rawMessage = (err as { message?: string })?.message || 'claude oauth validation failed';
const errorCode = classifyModelDiscoveryError(rawMessage);
const errorMessage = `Claude 模型获取失败(${rawMessage})`;
await updateOauthModelDiscoveryState({
account,
checkedAt,
status: 'abnormal',
lastModelSyncError: errorMessage,
lastDiscoveredModels: [],
});
await setAccountRuntimeHealth(account.id, {
state: 'unhealthy',
reason: errorMessage,
source: 'model-discovery',
checkedAt,
});
return buildFailedRefreshResult({
accountId,
errorCode,
errorMessage,
tokenScanned: 0,
discoveredByCredential: false,
discoveredApiToken: false,
});
}
}
if (oauth?.provider === 'gemini-cli') {
const checkedAt = new Date().toISOString();
const startedAt = Date.now();
try {
await withTimeout(
() => validateGeminiCliOauthConnection({ account }),
MODEL_DISCOVERY_TIMEOUT_MS,
`gemini cli oauth validation timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
);
await db.insert(schema.modelAvailability).values(
GEMINI_CLI_STATIC_MODELS.map((modelName) => ({
accountId,
modelName,
available: true,
latencyMs: Date.now() - startedAt,
checkedAt,
})),
).run();
await updateOauthModelDiscoveryState({
account,
checkedAt,
status: 'healthy',
lastDiscoveredModels: GEMINI_CLI_STATIC_MODELS,
});
await setAccountRuntimeHealth(accountId, {
state: 'healthy',
reason: 'Gemini CLI OAuth 健康探测成功',
source: 'model-discovery',
checkedAt,
});
return buildSuccessfulRefreshResult({
accountId,
modelCount: GEMINI_CLI_STATIC_MODELS.length,
modelsPreview: GEMINI_CLI_STATIC_MODELS.slice(0, 10),
tokenScanned: 0,
discoveredByCredential: true,
discoveredApiToken: false,
});
} catch (err) {
const rawMessage = (err as { message?: string })?.message || 'gemini cli oauth validation failed';
const errorCode = classifyModelDiscoveryError(rawMessage);
const errorMessage = `Gemini CLI 模型获取失败(${rawMessage})`;
await updateOauthModelDiscoveryState({
account,
checkedAt,
status: 'abnormal',
lastModelSyncError: errorMessage,
lastDiscoveredModels: [],
});
await setAccountRuntimeHealth(account.id, {
state: 'unhealthy',
reason: errorMessage,
source: 'model-discovery',
checkedAt,
});
return buildFailedRefreshResult({
accountId,
errorCode,
errorMessage,
tokenScanned: 0,
discoveredByCredential: false,
discoveredApiToken: false,
});
}
}
if (oauth?.provider === 'antigravity') {
const checkedAt = new Date().toISOString();
const startedAt = Date.now();
try {
const antigravityModels = await withTimeout(
() => discoverAntigravityModelsFromCloud({ site, account }),
MODEL_DISCOVERY_TIMEOUT_MS,
`antigravity model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
);
if (antigravityModels.length === 0) {
throw new Error('未获取到可用模型');
}
await db.insert(schema.modelAvailability).values(
antigravityModels.map((modelName) => ({
accountId,
modelName,
available: true,
latencyMs: Date.now() - startedAt,
checkedAt,
})),
).run();
await updateOauthModelDiscoveryState({
account,
checkedAt,
status: 'healthy',
lastDiscoveredModels: antigravityModels,
});
await setAccountRuntimeHealth(accountId, {
state: 'healthy',
reason: 'Antigravity OAuth 健康探测成功',
source: 'model-discovery',
checkedAt,
});
return buildSuccessfulRefreshResult({
accountId,
modelCount: antigravityModels.length,
modelsPreview: antigravityModels.slice(0, 10),
tokenScanned: 0,
discoveredByCredential: true,
discoveredApiToken: false,
});
} catch (err) {
const rawMessage = (err as { message?: string })?.message || 'antigravity model discovery failed';
const errorCode = classifyModelDiscoveryError(rawMessage);
const errorMessage = `Antigravity 模型获取失败(${rawMessage})`;
await updateOauthModelDiscoveryState({
account,
checkedAt,
status: 'abnormal',
lastModelSyncError: errorMessage,
lastDiscoveredModels: [],
});
await setAccountRuntimeHealth(account.id, {
state: 'unhealthy',
reason: errorMessage,
source: 'model-discovery',
checkedAt,
});
return buildFailedRefreshResult({
accountId,
errorCode,
errorMessage,
tokenScanned: 0,
discoveredByCredential: false,
discoveredApiToken: false,
});
}
}
if (!adapter) {
return buildSkippedRefreshResult(accountId, 'adapter_or_status', '平台不可用或账号未激活');
}
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
@@ -141,20 +808,27 @@ export async function refreshModelsForAccount(accountId: number) {
API_TOKEN_DISCOVERY_TIMEOUT_MS,
`api token discovery timeout (${Math.round(API_TOKEN_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
);
if (discoveredApiToken) {
if (discoveredApiToken && !isMaskedTokenValue(discoveredApiToken)) {
ensureDefaultTokenForAccount(account.id, discoveredApiToken, { name: 'default', source: 'sync' });
await db.update(schema.accounts).set({
apiToken: discoveredApiToken,
updatedAt: new Date().toISOString(),
}).where(eq(schema.accounts.id, account.id)).run();
} else {
discoveredApiToken = null;
}
} catch { }
}
let enabledTokens = await db.select()
.from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true)))
.where(and(
eq(schema.accountTokens.accountId, account.id),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
))
.all();
enabledTokens = enabledTokens.filter(isUsableAccountToken);
// Last fallback: if still no managed token but account has a legacy apiToken, mirror it into token table.
if (!isApiKeyConnection(account) && enabledTokens.length === 0) {
@@ -163,8 +837,13 @@ export async function refreshModelsForAccount(accountId: number) {
ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' });
enabledTokens = await db.select()
.from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true)))
.where(and(
eq(schema.accountTokens.accountId, account.id),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
))
.all();
enabledTokens = enabledTokens.filter(isUsableAccountToken);
}
}
@@ -195,6 +874,7 @@ export async function refreshModelsForAccount(accountId: number) {
const discoverModelsWithCredential = async (credentialRaw: string | null | undefined) => {
const credential = (credentialRaw || '').trim();
if (!credential) return;
if (isMaskedTokenValue(credential)) return;
if (attemptedCredentials.has(credential)) return;
attemptedCredentials.add(credential);
@@ -271,18 +951,14 @@ export async function refreshModelsForAccount(accountId: number) {
source: 'model-discovery',
checkedAt: new Date().toISOString(),
});
return {
return buildFailedRefreshResult({
accountId,
refreshed: true,
status: 'failed',
errorCode,
errorMessage,
modelCount: 0,
modelsPreview: [],
tokenScanned: scannedTokenCount,
discoveredByCredential,
discoveredApiToken: !!discoveredApiToken,
};
});
}
const checkedAt = new Date().toISOString();
@@ -304,26 +980,22 @@ export async function refreshModelsForAccount(accountId: number) {
});
const modelsPreview = Array.from(accountModels).slice(0, 10);
return {
return buildSuccessfulRefreshResult({
accountId,
refreshed: true,
status: 'success',
errorCode: null,
errorMessage: '',
modelCount: accountModels.size,
modelsPreview,
tokenScanned: scannedTokenCount,
discoveredByCredential,
discoveredApiToken: !!discoveredApiToken,
};
});
}
async function refreshModelsForAllActiveAccounts() {
async function refreshModelsForAllActiveAccounts(): Promise<ModelRefreshResult[]> {
const accounts = await db.select({ id: schema.accounts.id }).from(schema.accounts)
.where(eq(schema.accounts.status, 'active'))
.all();
const results: any[] = [];
const results: ModelRefreshResult[] = [];
for (let offset = 0; offset < accounts.length; offset += MODEL_REFRESH_BATCH_SIZE) {
const batch = accounts.slice(offset, offset + MODEL_REFRESH_BATCH_SIZE);
const batchResults = await Promise.all(batch.map(async (account) => refreshModelsForAccount(account.id)));
@@ -341,11 +1013,13 @@ export async function rebuildTokenRoutesFromAvailability() {
and(
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.all();
const usableTokenRows = tokenRows.filter((row) => isUsableAccountToken(row.account_tokens));
const accountRows = await db.select().from(schema.modelAvailability)
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
@@ -382,13 +1056,12 @@ export async function rebuildTokenRoutesFromAvailability() {
modelCandidates.get(modelName)!.set(candidateKey, { accountId, tokenId });
};
for (const row of tokenRows) {
for (const row of usableTokenRows) {
addModelCandidate(row.token_model_availability.modelName, row.accounts.id, row.account_tokens.id, row.accounts.siteId);
}
for (const row of accountRows) {
if (!isApiKeyConnection(row.accounts)) continue;
if (!(row.accounts.apiToken || '').trim()) continue;
if (!supportsDirectAccountRoutingConnection(row.accounts)) continue;
addModelCandidate(row.model_availability.modelName, row.accounts.id, null, row.accounts.siteId);
}
@@ -401,7 +1074,7 @@ export async function rebuildTokenRoutesFromAvailability() {
let removedRoutes = 0;
for (const [modelName, candidateMap] of modelCandidates.entries()) {
let route = routes.find((r) => r.modelPattern === modelName);
let route = routes.find((r) => (r.routeMode || 'pattern') !== 'explicit_group' && r.modelPattern === modelName);
if (!route) {
const inserted = await db.insert(schema.tokenRoutes).values({
modelPattern: modelName,
@@ -470,6 +1143,9 @@ export async function rebuildTokenRoutesFromAvailability() {
const latestModelNames = new Set<string>(Array.from(modelCandidates.keys()));
for (const route of routes) {
if ((route.routeMode || 'pattern') === 'explicit_group') {
continue;
}
const modelPattern = (route.modelPattern || '').trim();
if (!modelPattern || !isExactModelPattern(modelPattern) || latestModelNames.has(modelPattern)) {
continue;
@@ -0,0 +1,291 @@
import { fetch } from 'undici';
import type { OAuthProviderDefinition } from './providers.js';
export const ANTIGRAVITY_OAUTH_PROVIDER = 'antigravity';
export const ANTIGRAVITY_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
export const ANTIGRAVITY_TOKEN_URL = 'https://oauth2.googleapis.com/token';
export const ANTIGRAVITY_USERINFO_URL = 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json';
export const ANTIGRAVITY_CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
export const ANTIGRAVITY_CLIENT_SECRET = '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';
export const ANTIGRAVITY_LOOPBACK_CALLBACK_PORT = 51121;
export const ANTIGRAVITY_LOOPBACK_CALLBACK_PATH = '/oauth-callback';
export const ANTIGRAVITY_LOOPBACK_REDIRECT_URI = `http://localhost:${ANTIGRAVITY_LOOPBACK_CALLBACK_PORT}${ANTIGRAVITY_LOOPBACK_CALLBACK_PATH}`;
export const ANTIGRAVITY_UPSTREAM_BASE_URL = 'https://cloudcode-pa.googleapis.com';
export const ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL = 'https://daily-cloudcode-pa.googleapis.com';
export const ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL = 'https://daily-cloudcode-pa.sandbox.googleapis.com';
export const ANTIGRAVITY_GOOGLE_API_CLIENT = 'google-cloud-sdk vscode_cloudshelleditor/0.1';
export const ANTIGRAVITY_USER_AGENT = 'google-api-nodejs-client/9.15.1';
export const ANTIGRAVITY_MODELS_USER_AGENT = 'antigravity/1.19.6 darwin/arm64';
export const ANTIGRAVITY_CLIENT_METADATA = '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}';
export const ANTIGRAVITY_INTERNAL_API_VERSION = 'v1internal';
export const ANTIGRAVITY_ONBOARD_POLL_INTERVAL_MS = 2_000;
export const ANTIGRAVITY_ONBOARD_MAX_ATTEMPTS = 5;
const ANTIGRAVITY_SCOPES = [
'https://www.googleapis.com/auth/cloud-platform',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/cclog',
'https://www.googleapis.com/auth/experimentsandconfigs',
];
type AntigravityOAuthTokenPayload = {
access_token?: unknown;
refresh_token?: unknown;
token_type?: unknown;
expires_in?: unknown;
scope?: unknown;
expiry?: unknown;
};
type AntigravityLoadCodeAssistPayload = {
cloudaicompanionProject?: unknown;
allowedTiers?: unknown;
};
type AntigravityOnboardUserPayload = {
done?: unknown;
response?: unknown;
};
function asTrimmedString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed || undefined;
}
function parseExpiresAt(payload: AntigravityOAuthTokenPayload): number | undefined {
if (typeof payload.expires_in === 'number' && Number.isFinite(payload.expires_in) && payload.expires_in > 0) {
return Date.now() + Math.trunc(payload.expires_in) * 1000;
}
if (typeof payload.expires_in === 'string') {
const parsed = Number.parseInt(payload.expires_in.trim(), 10);
if (Number.isFinite(parsed) && parsed > 0) {
return Date.now() + parsed * 1000;
}
}
if (typeof payload.expiry === 'string') {
const parsed = Date.parse(payload.expiry);
if (!Number.isNaN(parsed)) return parsed;
}
return undefined;
}
function buildAntigravityMetadata() {
return {
ideType: 'ANTIGRAVITY',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
};
}
function extractAntigravityProjectId(value: unknown): string | undefined {
if (typeof value === 'string') {
return asTrimmedString(value);
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
return asTrimmedString((value as { id?: unknown }).id);
}
return undefined;
}
function extractAntigravityDefaultTierId(payload: AntigravityLoadCodeAssistPayload): string {
const allowedTiers = Array.isArray(payload.allowedTiers) ? payload.allowedTiers : [];
for (const rawTier of allowedTiers) {
if (!rawTier || typeof rawTier !== 'object' || Array.isArray(rawTier)) continue;
const tier = rawTier as { id?: unknown; isDefault?: unknown };
if (tier.isDefault === true) {
const tierId = asTrimmedString(tier.id);
if (tierId) return tierId;
}
}
return 'legacy-tier';
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function callAntigravityInternalApi<T>(
accessToken: string,
method: 'loadCodeAssist' | 'onboardUser',
body: Record<string, unknown>,
): Promise<T | undefined> {
const response = await fetch(
`${ANTIGRAVITY_UPSTREAM_BASE_URL}/${ANTIGRAVITY_INTERNAL_API_VERSION}:${method}`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': ANTIGRAVITY_USER_AGENT,
'X-Goog-Api-Client': ANTIGRAVITY_GOOGLE_API_CLIENT,
'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA,
},
body: JSON.stringify(body),
},
);
if (!response.ok) return undefined;
return response.json() as Promise<T>;
}
async function postAntigravityToken(body: URLSearchParams) {
const response = await fetch(ANTIGRAVITY_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: body.toString(),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(text || `antigravity token exchange failed with status ${response.status}`);
}
const payload = await response.json() as AntigravityOAuthTokenPayload;
const accessToken = asTrimmedString(payload.access_token);
if (!accessToken) {
throw new Error('antigravity token exchange response missing access token');
}
return {
accessToken,
refreshToken: asTrimmedString(payload.refresh_token),
tokenExpiresAt: parseExpiresAt(payload),
providerData: {
tokenType: asTrimmedString(payload.token_type),
scope: asTrimmedString(payload.scope),
},
};
}
async function fetchAntigravityUserEmail(accessToken: string): Promise<string | undefined> {
const response = await fetch(ANTIGRAVITY_USERINFO_URL, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
});
if (!response.ok) return undefined;
const payload = await response.json() as { email?: unknown };
return asTrimmedString(payload.email);
}
async function fetchAntigravityProjectId(accessToken: string): Promise<string | undefined> {
const metadata = buildAntigravityMetadata();
const payload = await callAntigravityInternalApi<AntigravityLoadCodeAssistPayload>(
accessToken,
'loadCodeAssist',
{ metadata },
);
if (!payload) return undefined;
const discoveredFromLoad = extractAntigravityProjectId(payload.cloudaicompanionProject);
if (discoveredFromLoad) {
return discoveredFromLoad;
}
const tierId = extractAntigravityDefaultTierId(payload);
for (let attempt = 0; attempt < ANTIGRAVITY_ONBOARD_MAX_ATTEMPTS; attempt += 1) {
const onboardPayload = await callAntigravityInternalApi<AntigravityOnboardUserPayload>(
accessToken,
'onboardUser',
{
tierId,
metadata,
},
);
if (!onboardPayload) return undefined;
if (onboardPayload.done === true) {
const response = (
onboardPayload.response
&& typeof onboardPayload.response === 'object'
&& !Array.isArray(onboardPayload.response)
)
? onboardPayload.response as { cloudaicompanionProject?: unknown }
: undefined;
return extractAntigravityProjectId(response?.cloudaicompanionProject);
}
if ((attempt + 1) < ANTIGRAVITY_ONBOARD_MAX_ATTEMPTS) {
await sleep(ANTIGRAVITY_ONBOARD_POLL_INTERVAL_MS);
}
}
return undefined;
}
export const antigravityOauthProvider: OAuthProviderDefinition = {
metadata: {
provider: ANTIGRAVITY_OAUTH_PROVIDER,
label: 'Antigravity',
platform: 'antigravity',
enabled: true,
loginType: 'oauth',
requiresProjectId: false,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: false,
},
site: {
name: 'Google Antigravity OAuth',
url: ANTIGRAVITY_UPSTREAM_BASE_URL,
platform: 'antigravity',
},
loopback: {
host: '127.0.0.1',
port: ANTIGRAVITY_LOOPBACK_CALLBACK_PORT,
path: ANTIGRAVITY_LOOPBACK_CALLBACK_PATH,
redirectUri: ANTIGRAVITY_LOOPBACK_REDIRECT_URI,
},
buildAuthorizationUrl: async ({ state, redirectUri }) => {
const params = new URLSearchParams({
client_id: ANTIGRAVITY_CLIENT_ID,
redirect_uri: redirectUri,
response_type: 'code',
access_type: 'offline',
prompt: 'consent',
scope: ANTIGRAVITY_SCOPES.join(' '),
state,
});
return `${ANTIGRAVITY_AUTH_URL}?${params.toString()}`;
},
exchangeAuthorizationCode: async ({ code, redirectUri }) => {
const token = await postAntigravityToken(new URLSearchParams({
code,
client_id: ANTIGRAVITY_CLIENT_ID,
client_secret: ANTIGRAVITY_CLIENT_SECRET,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
}));
const email = await fetchAntigravityUserEmail(token.accessToken);
const projectId = await fetchAntigravityProjectId(token.accessToken);
return {
...token,
email,
accountKey: email,
accountId: email,
projectId,
};
},
refreshAccessToken: async ({ refreshToken, oauth }) => {
const token = await postAntigravityToken(new URLSearchParams({
client_id: ANTIGRAVITY_CLIENT_ID,
client_secret: ANTIGRAVITY_CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token: refreshToken,
}));
const email = await fetchAntigravityUserEmail(token.accessToken);
return {
...token,
email,
accountKey: email,
accountId: email,
projectId: oauth?.projectId ?? await fetchAntigravityProjectId(token.accessToken),
};
},
buildProxyHeaders: () => ({
'User-Agent': ANTIGRAVITY_USER_AGENT,
'X-Goog-Api-Client': ANTIGRAVITY_GOOGLE_API_CLIENT,
'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA,
}),
};
+152
View File
@@ -0,0 +1,152 @@
import { fetch } from 'undici';
import { config } from '../../config.js';
import { createPkceChallenge } from './sessionStore.js';
import type { OAuthProviderDefinition } from './providers.js';
export const CLAUDE_OAUTH_PROVIDER = 'claude';
export const CLAUDE_AUTH_URL = 'https://claude.ai/oauth/authorize';
export const CLAUDE_TOKEN_URL = 'https://api.anthropic.com/v1/oauth/token';
export const CLAUDE_CLIENT_ID = config.claudeClientId;
export const CLAUDE_LOOPBACK_CALLBACK_PORT = 54545;
export const CLAUDE_LOOPBACK_CALLBACK_PATH = '/callback';
export const CLAUDE_LOOPBACK_REDIRECT_URI = `http://localhost:${CLAUDE_LOOPBACK_CALLBACK_PORT}${CLAUDE_LOOPBACK_CALLBACK_PATH}`;
export const CLAUDE_UPSTREAM_BASE_URL = 'https://api.anthropic.com';
export const CLAUDE_DEFAULT_ANTHROPIC_VERSION = '2023-06-01';
function requireClaudeClientId(): string {
if (!CLAUDE_CLIENT_ID) {
throw new Error('CLAUDE_CLIENT_ID is not configured');
}
return CLAUDE_CLIENT_ID;
}
type ClaudeTokenResponse = {
access_token?: unknown;
refresh_token?: unknown;
token_type?: unknown;
expires_in?: unknown;
organization?: {
uuid?: unknown;
name?: unknown;
};
account?: {
uuid?: unknown;
email_address?: unknown;
};
};
function asTrimmedString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed || undefined;
}
function parseExpiresAt(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return Date.now() + Math.trunc(value) * 1000;
}
if (typeof value === 'string') {
const parsed = Number.parseInt(value.trim(), 10);
if (Number.isFinite(parsed) && parsed > 0) {
return Date.now() + parsed * 1000;
}
}
return undefined;
}
function parseClaudeTokenPayload(payload: unknown) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('claude token exchange returned invalid payload');
}
const body = payload as ClaudeTokenResponse;
const accessToken = asTrimmedString(body.access_token);
if (!accessToken) {
throw new Error('claude token exchange response missing access token');
}
return {
accessToken,
refreshToken: asTrimmedString(body.refresh_token),
tokenExpiresAt: parseExpiresAt(body.expires_in),
email: asTrimmedString(body.account?.email_address),
accountId: asTrimmedString(body.account?.uuid),
accountKey: asTrimmedString(body.account?.uuid) || asTrimmedString(body.account?.email_address),
providerData: {
organizationId: asTrimmedString(body.organization?.uuid),
organizationName: asTrimmedString(body.organization?.name),
},
};
}
async function postClaudeToken(body: Record<string, unknown>) {
const response = await fetch(CLAUDE_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(body),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(text || `claude token exchange failed with status ${response.status}`);
}
return parseClaudeTokenPayload(await response.json());
}
export const claudeOauthProvider: OAuthProviderDefinition = {
metadata: {
provider: CLAUDE_OAUTH_PROVIDER,
label: 'Claude',
platform: 'claude',
enabled: true,
loginType: 'oauth',
requiresProjectId: false,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
site: {
name: 'Anthropic Claude OAuth',
url: CLAUDE_UPSTREAM_BASE_URL,
platform: 'claude',
},
loopback: {
host: '127.0.0.1',
port: CLAUDE_LOOPBACK_CALLBACK_PORT,
path: CLAUDE_LOOPBACK_CALLBACK_PATH,
redirectUri: CLAUDE_LOOPBACK_REDIRECT_URI,
},
buildAuthorizationUrl: async ({ state, redirectUri, codeVerifier }) => {
const params = new URLSearchParams({
code: 'true',
client_id: requireClaudeClientId(),
response_type: 'code',
redirect_uri: redirectUri,
scope: 'org:create_api_key user:profile user:inference',
code_challenge: await createPkceChallenge(codeVerifier),
code_challenge_method: 'S256',
state,
});
return `${CLAUDE_AUTH_URL}?${params.toString()}`;
},
exchangeAuthorizationCode: async ({ code, state, redirectUri, codeVerifier }) => {
return postClaudeToken({
code,
state,
grant_type: 'authorization_code',
client_id: requireClaudeClientId(),
redirect_uri: redirectUri,
code_verifier: codeVerifier,
});
},
refreshAccessToken: async ({ refreshToken }) => {
return postClaudeToken({
client_id: requireClaudeClientId(),
grant_type: 'refresh_token',
refresh_token: refreshToken,
});
},
buildProxyHeaders: () => ({
'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION,
}),
};
+30
View File
@@ -0,0 +1,30 @@
import { schema } from '../../db/index.js';
import {
buildOauthInfo,
getOauthInfoFromExtraConfig,
isOauthProvider,
type OauthInfo,
} from './oauthAccount.js';
export type CodexOauthInfo = OauthInfo & {
provider: 'codex';
};
export function getCodexOauthInfoFromExtraConfig(extraConfig?: string | null): CodexOauthInfo | null {
const oauth = getOauthInfoFromExtraConfig(extraConfig);
if (!oauth || oauth.provider !== 'codex') return null;
return oauth as CodexOauthInfo;
}
export function isCodexPlatform(
account: Pick<typeof schema.accounts.$inferSelect, 'extraConfig'> | string | null | undefined,
): boolean {
return isOauthProvider(account, 'codex');
}
export function buildCodexOauthInfo(
extraConfig?: string | null,
patch: Partial<CodexOauthInfo> = {},
): CodexOauthInfo {
return buildOauthInfo(extraConfig, { provider: 'codex', ...patch }) as CodexOauthInfo;
}
+233
View File
@@ -0,0 +1,233 @@
import { fetch } from 'undici';
import { config } from '../../config.js';
import { createPkceChallenge } from './sessionStore.js';
import type { OAuthProviderDefinition } from './providers.js';
export const CODEX_OAUTH_PROVIDER = 'codex';
export const CODEX_AUTH_URL = 'https://auth.openai.com/oauth/authorize';
export const CODEX_TOKEN_URL = 'https://auth.openai.com/oauth/token';
export const CODEX_CLIENT_ID = config.codexClientId;
export const CODEX_CALLBACK_PATH = '/api/oauth/callback/codex';
export const CODEX_LOOPBACK_CALLBACK_PATH = '/auth/callback';
export const CODEX_LOOPBACK_CALLBACK_PORT = 1455;
export const CODEX_LOOPBACK_REDIRECT_URI = `http://localhost:${CODEX_LOOPBACK_CALLBACK_PORT}${CODEX_LOOPBACK_CALLBACK_PATH}`;
export const CODEX_UPSTREAM_BASE_URL = 'https://chatgpt.com/backend-api/codex';
function requireCodexClientId(): string {
if (!CODEX_CLIENT_ID) {
throw new Error('CODEX_CLIENT_ID is not configured');
}
return CODEX_CLIENT_ID;
}
type CodexJwtClaims = {
email?: unknown;
'https://api.openai.com/auth'?: {
chatgpt_account_id?: unknown;
chatgpt_plan_type?: unknown;
};
};
export type CodexTokenExchangeResult = {
accessToken: string;
refreshToken: string;
idToken: string;
accountId?: string;
email?: string;
planType?: string;
tokenExpiresAt: number;
};
function asTrimmedString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed || undefined;
}
function parseJwtClaims(token: string): CodexJwtClaims | null {
const parts = token.split('.');
if (parts.length !== 3) return null;
try {
const payload = JSON.parse(Buffer.from(parts[1] || '', 'base64url').toString('utf8')) as unknown;
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
return payload as CodexJwtClaims;
} catch {
return null;
}
}
export async function buildCodexAuthorizationUrl(input: {
state: string;
redirectUri: string;
codeVerifier: string;
}): Promise<string> {
const params = new URLSearchParams({
client_id: requireCodexClientId(),
response_type: 'code',
redirect_uri: input.redirectUri,
scope: 'openid email profile offline_access',
state: input.state,
code_challenge: await createPkceChallenge(input.codeVerifier),
code_challenge_method: 'S256',
prompt: 'login',
id_token_add_organizations: 'true',
codex_cli_simplified_flow: 'true',
});
return `${CODEX_AUTH_URL}?${params.toString()}`;
}
function parseTokenResponsePayload(payload: unknown): CodexTokenExchangeResult {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('codex token exchange returned invalid payload');
}
const body = payload as Record<string, unknown>;
const accessToken = asTrimmedString(body.access_token);
const refreshToken = asTrimmedString(body.refresh_token);
const idToken = asTrimmedString(body.id_token);
const expiresIn = typeof body.expires_in === 'number' && Number.isFinite(body.expires_in)
? Math.trunc(body.expires_in)
: (typeof body.expires_in === 'string' ? Number.parseInt(body.expires_in.trim(), 10) : NaN);
if (!accessToken || !refreshToken || !idToken || !Number.isFinite(expiresIn) || expiresIn <= 0) {
throw new Error('codex token exchange response missing required fields');
}
const claims = parseJwtClaims(idToken);
return {
accessToken,
refreshToken,
idToken,
accountId: asTrimmedString(claims?.['https://api.openai.com/auth']?.chatgpt_account_id),
email: asTrimmedString(claims?.email),
planType: asTrimmedString(claims?.['https://api.openai.com/auth']?.chatgpt_plan_type),
tokenExpiresAt: Date.now() + expiresIn * 1000,
};
}
async function exchangeCodexToken(form: URLSearchParams): Promise<CodexTokenExchangeResult> {
const response = await fetch(CODEX_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: form.toString(),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(text || `codex token exchange failed with status ${response.status}`);
}
return parseTokenResponsePayload(await response.json());
}
export async function exchangeCodexAuthorizationCode(input: {
code: string;
codeVerifier: string;
redirectUri: string;
}): Promise<CodexTokenExchangeResult> {
return exchangeCodexToken(new URLSearchParams({
grant_type: 'authorization_code',
client_id: requireCodexClientId(),
code: input.code,
redirect_uri: input.redirectUri,
code_verifier: input.codeVerifier,
}));
}
export async function refreshCodexTokens(refreshToken: string): Promise<CodexTokenExchangeResult> {
return exchangeCodexToken(new URLSearchParams({
client_id: requireCodexClientId(),
grant_type: 'refresh_token',
refresh_token: refreshToken,
scope: 'openid profile email',
}));
}
function getHeaderValue(headers: Record<string, unknown> | undefined, key: string): string | undefined {
if (!headers) return undefined;
const loweredKey = key.toLowerCase();
for (const [rawKey, rawValue] of Object.entries(headers)) {
if (rawKey.toLowerCase() !== loweredKey) continue;
if (typeof rawValue === 'string') {
const trimmed = rawValue.trim();
if (trimmed) return trimmed;
}
if (Array.isArray(rawValue)) {
for (const item of rawValue) {
if (typeof item !== 'string') continue;
const trimmed = item.trim();
if (trimmed) return trimmed;
}
}
}
return undefined;
}
export const codexOauthProvider: OAuthProviderDefinition = {
metadata: {
provider: CODEX_OAUTH_PROVIDER,
label: 'Codex',
platform: 'codex',
enabled: true,
loginType: 'oauth',
requiresProjectId: false,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
site: {
name: 'ChatGPT Codex OAuth',
url: CODEX_UPSTREAM_BASE_URL,
platform: 'codex',
},
loopback: {
host: '127.0.0.1',
port: CODEX_LOOPBACK_CALLBACK_PORT,
path: CODEX_LOOPBACK_CALLBACK_PATH,
redirectUri: CODEX_LOOPBACK_REDIRECT_URI,
},
buildAuthorizationUrl: ({ state, redirectUri, codeVerifier }) => buildCodexAuthorizationUrl({
state,
redirectUri,
codeVerifier,
}),
exchangeAuthorizationCode: async ({ code, redirectUri, codeVerifier }) => {
const exchange = await exchangeCodexAuthorizationCode({
code,
redirectUri,
codeVerifier,
});
return {
accessToken: exchange.accessToken,
refreshToken: exchange.refreshToken,
tokenExpiresAt: exchange.tokenExpiresAt,
email: exchange.email,
accountId: exchange.accountId,
accountKey: exchange.accountId,
planType: exchange.planType,
idToken: exchange.idToken,
};
},
refreshAccessToken: async ({ refreshToken }) => {
const refreshed = await refreshCodexTokens(refreshToken);
return {
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken,
tokenExpiresAt: refreshed.tokenExpiresAt,
email: refreshed.email,
accountId: refreshed.accountId,
accountKey: refreshed.accountId,
planType: refreshed.planType,
idToken: refreshed.idToken,
};
},
buildProxyHeaders: ({ oauth, downstreamHeaders }) => {
const accountId = getHeaderValue(downstreamHeaders, 'chatgpt-account-id') || oauth.accountId || oauth.accountKey;
const originator = getHeaderValue(downstreamHeaders, 'originator') || 'codex_cli_rs';
const headers: Record<string, string> = {
Originator: originator,
};
if (accountId) {
headers['Chatgpt-Account-Id'] = accountId;
}
return headers;
},
};
@@ -0,0 +1,470 @@
import { fetch } from 'undici';
import { config } from '../../config.js';
import type { OAuthProviderDefinition } from './providers.js';
export const GEMINI_CLI_OAUTH_PROVIDER = 'gemini-cli';
export const GEMINI_CLI_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
export const GEMINI_CLI_TOKEN_URL = 'https://oauth2.googleapis.com/token';
export const GEMINI_CLI_USERINFO_URL = 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json';
export const GEMINI_CLI_PROJECTS_URL = 'https://cloudresourcemanager.googleapis.com/v1/projects';
export const GEMINI_CLI_SERVICE_USAGE_URL = 'https://serviceusage.googleapis.com/v1';
export const GEMINI_CLI_CLIENT_ID = config.geminiCliClientId;
export const GEMINI_CLI_CLIENT_SECRET = config.geminiCliClientSecret;
export const GEMINI_CLI_LOOPBACK_CALLBACK_PORT = 8085;
export const GEMINI_CLI_LOOPBACK_CALLBACK_PATH = '/oauth2callback';
export const GEMINI_CLI_LOOPBACK_REDIRECT_URI = `http://localhost:${GEMINI_CLI_LOOPBACK_CALLBACK_PORT}${GEMINI_CLI_LOOPBACK_CALLBACK_PATH}`;
export const GEMINI_CLI_UPSTREAM_BASE_URL = 'https://cloudcode-pa.googleapis.com';
export const GEMINI_CLI_GOOGLE_API_CLIENT = 'google-genai-sdk/1.41.0 gl-node/v22.19.0';
export const GEMINI_CLI_USER_AGENT = 'GeminiCLI/0.31.0/unknown (win32; x64)';
export const GEMINI_CLI_REQUIRED_SERVICE = 'cloudaicompanion.googleapis.com';
export const GEMINI_CLI_INTERNAL_API_VERSION = 'v1internal';
export const GEMINI_CLI_AUTO_ONBOARD_POLL_INTERVAL_MS = 2_000;
export const GEMINI_CLI_AUTO_ONBOARD_MAX_ATTEMPTS = 15;
export const GEMINI_CLI_ONBOARD_POLL_INTERVAL_MS = 5_000;
export const GEMINI_CLI_ONBOARD_MAX_ATTEMPTS = 6;
function requireGeminiCliOAuthConfig() {
if (!GEMINI_CLI_CLIENT_ID) {
throw new Error('GEMINI_CLI_CLIENT_ID is not configured');
}
if (!GEMINI_CLI_CLIENT_SECRET) {
throw new Error('GEMINI_CLI_CLIENT_SECRET is not configured');
}
return {
clientId: GEMINI_CLI_CLIENT_ID,
clientSecret: GEMINI_CLI_CLIENT_SECRET,
};
}
const GEMINI_CLI_SCOPES = [
'https://www.googleapis.com/auth/cloud-platform',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
];
type GeminiOAuthTokenPayload = {
access_token?: unknown;
refresh_token?: unknown;
token_type?: unknown;
expires_in?: unknown;
scope?: unknown;
expiry?: unknown;
};
type GeminiLoadCodeAssistPayload = {
cloudaicompanionProject?: unknown;
allowedTiers?: unknown;
};
type GeminiOnboardUserPayload = {
done?: unknown;
response?: unknown;
};
function asTrimmedString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed || undefined;
}
function parseExpiresAt(payload: GeminiOAuthTokenPayload): number | undefined {
if (typeof payload.expires_in === 'number' && Number.isFinite(payload.expires_in) && payload.expires_in > 0) {
return Date.now() + Math.trunc(payload.expires_in) * 1000;
}
if (typeof payload.expires_in === 'string') {
const parsed = Number.parseInt(payload.expires_in.trim(), 10);
if (Number.isFinite(parsed) && parsed > 0) {
return Date.now() + parsed * 1000;
}
}
if (typeof payload.expiry === 'string') {
const parsed = Date.parse(payload.expiry);
if (!Number.isNaN(parsed)) return parsed;
}
return undefined;
}
function buildGeminiCliMetadata() {
return {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
};
}
function extractGeminiProjectId(value: unknown): string | undefined {
if (typeof value === 'string') {
return asTrimmedString(value);
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
return asTrimmedString((value as { id?: unknown }).id);
}
return undefined;
}
function extractGeminiDefaultTierId(payload: GeminiLoadCodeAssistPayload): string {
const allowedTiers = Array.isArray(payload.allowedTiers) ? payload.allowedTiers : [];
for (const rawTier of allowedTiers) {
if (!rawTier || typeof rawTier !== 'object' || Array.isArray(rawTier)) continue;
const tier = rawTier as { id?: unknown; isDefault?: unknown };
if (tier.isDefault === true) {
const tierId = asTrimmedString(tier.id);
if (tierId) return tierId;
}
}
return 'legacy-tier';
}
function isGeminiFreeUserProject(input: {
requestedProjectId?: string;
tierId: string;
}) {
const projectId = (input.requestedProjectId || '').trim();
const tierId = input.tierId.trim();
return projectId.startsWith('gen-lang-client-')
|| tierId.toUpperCase() === 'FREE'
|| tierId.toUpperCase() === 'LEGACY';
}
function isSameGeminiProjectId(a: string, b: string): boolean {
return a.trim().toLowerCase() === b.trim().toLowerCase();
}
function extractGeminiServiceErrorMessage(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return '';
try {
const parsed = JSON.parse(trimmed) as {
error?: { message?: unknown };
};
return asTrimmedString(parsed.error?.message) || trimmed;
} catch {
return trimmed;
}
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function postGeminiToken(body: URLSearchParams) {
const response = await fetch(GEMINI_CLI_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: body.toString(),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(text || `gemini token exchange failed with status ${response.status}`);
}
const payload = await response.json() as GeminiOAuthTokenPayload;
const accessToken = asTrimmedString(payload.access_token);
if (!accessToken) {
throw new Error('gemini token exchange response missing access token');
}
return {
accessToken,
refreshToken: asTrimmedString(payload.refresh_token),
tokenExpiresAt: parseExpiresAt(payload),
providerData: {
tokenType: asTrimmedString(payload.token_type),
scope: asTrimmedString(payload.scope),
},
};
}
async function fetchGeminiUserEmail(accessToken: string): Promise<string | undefined> {
const response = await fetch(GEMINI_CLI_USERINFO_URL, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
});
if (!response.ok) return undefined;
const payload = await response.json() as { email?: unknown };
return asTrimmedString(payload.email);
}
async function callGeminiCliInternalApi<T>(
accessToken: string,
method: 'loadCodeAssist' | 'onboardUser',
body: Record<string, unknown>,
): Promise<T> {
const response = await fetch(
`${GEMINI_CLI_UPSTREAM_BASE_URL}/${GEMINI_CLI_INTERNAL_API_VERSION}:${method}`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': GEMINI_CLI_USER_AGENT,
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
},
body: JSON.stringify(body),
},
);
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(text || `api request failed with status ${response.status}`);
}
return response.json() as Promise<T>;
}
async function fetchGcpProjects(accessToken: string): Promise<string[]> {
const response = await fetch(GEMINI_CLI_PROJECTS_URL, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(text || `project list request failed with status ${response.status}`);
}
const payload = await response.json() as { projects?: Array<{ projectId?: unknown }> };
return (payload.projects || [])
.map((project) => asTrimmedString(project.projectId))
.filter((projectId): projectId is string => !!projectId);
}
async function checkCloudAIAPIEnabled(accessToken: string, projectId: string): Promise<void> {
const checkResponse = await fetch(
`${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'User-Agent': GEMINI_CLI_USER_AGENT,
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
},
},
);
if (checkResponse.ok) {
const payload = await checkResponse.json() as { state?: unknown };
const state = asTrimmedString(payload.state);
if ((state || '').toUpperCase() === 'ENABLED') {
return;
}
}
const response = await fetch(
`${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}:enable`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': GEMINI_CLI_USER_AGENT,
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
},
body: '{}',
},
);
const text = await response.text().catch(() => '');
if (response.ok) {
return;
}
if (response.status === 400 && extractGeminiServiceErrorMessage(text).toLowerCase().includes('already enabled')) {
return;
}
throw new Error(`project activation required: ${extractGeminiServiceErrorMessage(text) || `HTTP ${response.status}`}`);
}
async function performGeminiCliSetup(
accessToken: string,
requestedProjectId: string,
): Promise<string> {
const trimmedRequest = requestedProjectId.trim();
const explicitProject = !!trimmedRequest;
const metadata = buildGeminiCliMetadata();
const loadResponse = await callGeminiCliInternalApi<GeminiLoadCodeAssistPayload>(
accessToken,
'loadCodeAssist',
explicitProject
? {
metadata,
cloudaicompanionProject: trimmedRequest,
}
: { metadata },
);
const tierId = extractGeminiDefaultTierId(loadResponse);
let projectId = trimmedRequest || extractGeminiProjectId(loadResponse.cloudaicompanionProject) || '';
if (!projectId) {
for (let attempt = 0; attempt < GEMINI_CLI_AUTO_ONBOARD_MAX_ATTEMPTS; attempt += 1) {
const onboardResponse = await callGeminiCliInternalApi<GeminiOnboardUserPayload>(
accessToken,
'onboardUser',
{
tierId,
metadata,
},
);
if (onboardResponse.done === true) {
const response = (
onboardResponse.response
&& typeof onboardResponse.response === 'object'
&& !Array.isArray(onboardResponse.response)
)
? onboardResponse.response as { cloudaicompanionProject?: unknown }
: undefined;
projectId = extractGeminiProjectId(response?.cloudaicompanionProject) || '';
break;
}
if ((attempt + 1) < GEMINI_CLI_AUTO_ONBOARD_MAX_ATTEMPTS) {
await sleep(GEMINI_CLI_AUTO_ONBOARD_POLL_INTERVAL_MS);
}
}
}
if (!projectId) {
throw new Error('gemini cli: project selection required');
}
let finalProjectId = projectId;
for (let attempt = 0; attempt < GEMINI_CLI_ONBOARD_MAX_ATTEMPTS; attempt += 1) {
const onboardResponse = await callGeminiCliInternalApi<GeminiOnboardUserPayload>(
accessToken,
'onboardUser',
{
tierId,
metadata,
cloudaicompanionProject: projectId,
},
);
if (onboardResponse.done === true) {
const response = (
onboardResponse.response
&& typeof onboardResponse.response === 'object'
&& !Array.isArray(onboardResponse.response)
)
? onboardResponse.response as { cloudaicompanionProject?: unknown }
: undefined;
const responseProjectId = extractGeminiProjectId(response?.cloudaicompanionProject) || '';
if (responseProjectId) {
if (explicitProject && !isSameGeminiProjectId(responseProjectId, projectId)) {
finalProjectId = isGeminiFreeUserProject({ requestedProjectId: projectId, tierId })
? responseProjectId
: projectId;
} else {
finalProjectId = responseProjectId;
}
}
return finalProjectId || projectId;
}
if ((attempt + 1) < GEMINI_CLI_ONBOARD_MAX_ATTEMPTS) {
await sleep(GEMINI_CLI_ONBOARD_POLL_INTERVAL_MS);
}
}
if (finalProjectId) {
return finalProjectId;
}
throw new Error('gemini cli: onboarding timed out');
}
async function ensureGeminiProjectAndOnboard(
accessToken: string,
requestedProjectId?: string,
): Promise<string> {
const explicitProject = asTrimmedString(requestedProjectId);
if (explicitProject) {
return performGeminiCliSetup(accessToken, explicitProject);
}
const projects = await fetchGcpProjects(accessToken);
const firstProject = projects[0];
if (!firstProject) {
throw new Error('no Google Cloud projects available for this account');
}
return performGeminiCliSetup(accessToken, firstProject);
}
export const geminiCliOauthProvider: OAuthProviderDefinition = {
metadata: {
provider: GEMINI_CLI_OAUTH_PROVIDER,
label: 'Gemini CLI',
platform: 'gemini-cli',
enabled: true,
loginType: 'oauth',
requiresProjectId: true,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
site: {
name: 'Google Gemini CLI OAuth',
url: GEMINI_CLI_UPSTREAM_BASE_URL,
platform: 'gemini-cli',
},
loopback: {
host: '127.0.0.1',
port: GEMINI_CLI_LOOPBACK_CALLBACK_PORT,
path: GEMINI_CLI_LOOPBACK_CALLBACK_PATH,
redirectUri: GEMINI_CLI_LOOPBACK_REDIRECT_URI,
},
buildAuthorizationUrl: async ({ state, redirectUri }) => {
const oauthConfig = requireGeminiCliOAuthConfig();
const params = new URLSearchParams({
client_id: oauthConfig.clientId,
redirect_uri: redirectUri,
response_type: 'code',
access_type: 'offline',
prompt: 'consent',
scope: GEMINI_CLI_SCOPES.join(' '),
state,
});
return `${GEMINI_CLI_AUTH_URL}?${params.toString()}`;
},
exchangeAuthorizationCode: async ({ code, redirectUri, projectId }) => {
const oauthConfig = requireGeminiCliOAuthConfig();
const token = await postGeminiToken(new URLSearchParams({
code,
client_id: oauthConfig.clientId,
client_secret: oauthConfig.clientSecret,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
}));
const resolvedProjectId = await ensureGeminiProjectAndOnboard(token.accessToken, projectId);
await checkCloudAIAPIEnabled(token.accessToken, resolvedProjectId);
const email = await fetchGeminiUserEmail(token.accessToken);
return {
...token,
email,
accountKey: email,
accountId: email,
projectId: resolvedProjectId,
};
},
refreshAccessToken: async ({ refreshToken, oauth }) => {
const oauthConfig = requireGeminiCliOAuthConfig();
const token = await postGeminiToken(new URLSearchParams({
client_id: oauthConfig.clientId,
client_secret: oauthConfig.clientSecret,
grant_type: 'refresh_token',
refresh_token: refreshToken,
}));
const nextProjectId = oauth?.projectId
? oauth.projectId
: await ensureGeminiProjectAndOnboard(token.accessToken);
await checkCloudAIAPIEnabled(token.accessToken, nextProjectId);
const email = await fetchGeminiUserEmail(token.accessToken);
return {
...token,
email,
accountKey: email,
accountId: email,
projectId: nextProjectId,
};
},
buildProxyHeaders: () => ({
'User-Agent': GEMINI_CLI_USER_AGENT,
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
}),
};
@@ -0,0 +1,46 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
startOAuthLoopbackCallbackServer,
stopOAuthLoopbackCallbackServers,
} from './localCallbackServer.js';
describe('oauth loopback callback server', () => {
afterEach(async () => {
await stopOAuthLoopbackCallbackServers();
});
it('accepts codex oauth callback requests and closes the popup on success', async () => {
const callbackHandler = vi.fn(async () => ({ accountId: 12, siteId: 34 }));
const started = await startOAuthLoopbackCallbackServer('codex', {
callbackHandler,
});
const response = await fetch(`${started.origin}/auth/callback?state=test-state&code=test-code`);
const body = await response.text();
expect(response.status).toBe(200);
expect(callbackHandler).toHaveBeenCalledWith({
provider: 'codex',
state: 'test-state',
code: 'test-code',
error: undefined,
});
expect(body).toContain('window.close()');
});
it('renders a stable error page when oauth completion fails', async () => {
const callbackHandler = vi.fn(async () => {
throw new Error('oauth failed');
});
const started = await startOAuthLoopbackCallbackServer('claude', {
callbackHandler,
});
const response = await fetch(`${started.origin}/callback?state=test-state&code=test-code`);
const body = await response.text();
expect(response.status).toBe(500);
expect(body).toContain('OAuth authorization failed');
expect(body).not.toContain('oauth failed');
});
});
@@ -0,0 +1,245 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { type AddressInfo } from 'node:net';
import { getOAuthProviderDefinition, listOAuthProviderDefinitions } from './providers.js';
import { handleOauthCallback } from './service.js';
type CallbackHandler = typeof handleOauthCallback;
type StartOAuthLoopbackCallbackServerOptions = {
host?: string;
port?: number;
callbackHandler?: CallbackHandler;
};
export type OAuthLoopbackCallbackServerState = {
provider: string;
attempted: boolean;
ready: boolean;
host?: string;
port: number;
path: string;
origin: string;
redirectUri: string;
error?: string;
};
const servers = new Map<string, Server>();
const states = new Map<string, OAuthLoopbackCallbackServerState>();
const startPromises = new Map<string, Promise<OAuthLoopbackCallbackServerState>>();
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function renderCompletionPage(message: string): string {
const safeMessage = escapeHtml(message);
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>OAuth Callback</title>
</head>
<body>
<script>window.close();</script>
${safeMessage}
</body>
</html>`;
}
function respondHtml(response: ServerResponse, statusCode: number, message: string) {
response.writeHead(statusCode, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
});
response.end(renderCompletionPage(message));
}
function normalizeOrigin(host: string | undefined, port: number): string {
if (!host || host === '::' || host === '0.0.0.0') {
return `http://localhost:${port}`;
}
if (host.includes(':') && !host.startsWith('[')) {
return `http://[${host}]:${port}`;
}
return `http://${host}:${port}`;
}
function createDefaultState(provider: string): OAuthLoopbackCallbackServerState {
const definition = getOAuthProviderDefinition(provider);
if (!definition) {
throw new Error(`unsupported oauth provider: ${provider}`);
}
return {
provider,
attempted: false,
ready: false,
host: definition.loopback.host,
port: definition.loopback.port,
path: definition.loopback.path,
origin: normalizeOrigin(definition.loopback.host, definition.loopback.port),
redirectUri: definition.loopback.redirectUri,
};
}
async function handleCallbackRequest(
provider: string,
request: IncomingMessage,
response: ServerResponse,
callbackHandler: CallbackHandler,
) {
const definition = getOAuthProviderDefinition(provider);
if (!definition) {
response.writeHead(404);
response.end('Not found');
return;
}
if (request.method !== 'GET') {
response.writeHead(405, { Allow: 'GET' });
response.end('Method not allowed');
return;
}
const requestUrl = new URL(request.url || '/', 'http://localhost');
if (requestUrl.pathname !== definition.loopback.path) {
response.writeHead(404);
response.end('Not found');
return;
}
try {
await callbackHandler({
provider,
state: requestUrl.searchParams.get('state') || '',
code: requestUrl.searchParams.get('code') || undefined,
error: requestUrl.searchParams.get('error') || undefined,
});
respondHtml(response, 200, 'OAuth authorization succeeded. You can close this window.');
} catch {
respondHtml(response, 500, 'OAuth authorization failed. Return to metapi and review the server logs.');
}
}
export function getOAuthLoopbackCallbackServerState(provider: string): OAuthLoopbackCallbackServerState {
return { ...(states.get(provider) || createDefaultState(provider)) };
}
export function getOAuthLoopbackCallbackServerStates(): OAuthLoopbackCallbackServerState[] {
return listOAuthProviderDefinitions().map((provider) => getOAuthLoopbackCallbackServerState(provider.metadata.provider));
}
export async function startOAuthLoopbackCallbackServer(
provider: string,
options: StartOAuthLoopbackCallbackServerOptions = {},
): Promise<OAuthLoopbackCallbackServerState> {
const definition = getOAuthProviderDefinition(provider);
if (!definition) {
throw new Error(`unsupported oauth provider: ${provider}`);
}
if (servers.has(provider)) {
return getOAuthLoopbackCallbackServerState(provider);
}
const existingStart = startPromises.get(provider);
if (existingStart) {
return existingStart;
}
const callbackHandler = options.callbackHandler || handleOauthCallback;
const host = options.host || definition.loopback.host;
const port = options.port ?? definition.loopback.port;
const { path, redirectUri } = definition.loopback;
const startPromise = new Promise<OAuthLoopbackCallbackServerState>((resolve, reject) => {
const server = createServer((request, response) => {
void handleCallbackRequest(provider, request, response, callbackHandler);
});
const finalizeFailure = (error: Error) => {
const failedState: OAuthLoopbackCallbackServerState = {
provider,
attempted: true,
ready: false,
host,
port,
path,
origin: normalizeOrigin(host, port),
redirectUri,
error: error.message || `failed to start ${provider} oauth callback server`,
};
states.set(provider, failedState);
servers.delete(provider);
reject(error);
};
const onStartupError = (error: Error) => {
finalizeFailure(error);
};
server.once('error', onStartupError);
server.listen(port, host, () => {
server.off('error', onStartupError);
servers.set(provider, server);
const address = server.address() as AddressInfo | null;
const listeningPort = address?.port || port;
const listeningHost = address?.address || host;
const nextState: OAuthLoopbackCallbackServerState = {
provider,
attempted: true,
ready: true,
host: listeningHost,
port: listeningPort,
path,
origin: normalizeOrigin(listeningHost, listeningPort),
redirectUri,
};
states.set(provider, nextState);
resolve(getOAuthLoopbackCallbackServerState(provider));
});
}).finally(() => {
startPromises.delete(provider);
});
startPromises.set(provider, startPromise);
return startPromise;
}
export async function startOAuthLoopbackCallbackServers(
options: StartOAuthLoopbackCallbackServerOptions = {},
): Promise<OAuthLoopbackCallbackServerState[]> {
const results = await Promise.allSettled(
listOAuthProviderDefinitions().map((provider) =>
startOAuthLoopbackCallbackServer(provider.metadata.provider, options)),
);
return results.map((result, index) => {
if (result.status === 'fulfilled') return result.value;
return getOAuthLoopbackCallbackServerState(listOAuthProviderDefinitions()[index]!.metadata.provider);
});
}
export async function stopOAuthLoopbackCallbackServers(): Promise<void> {
const activeServers = Array.from(servers.entries());
servers.clear();
states.clear();
startPromises.clear();
await Promise.all(activeServers.map(async ([provider, server]) => {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}).catch(() => undefined);
states.set(provider, createDefaultState(provider));
}));
}
export async function startCodexLoopbackCallbackServer(
options: StartOAuthLoopbackCallbackServerOptions = {},
): Promise<OAuthLoopbackCallbackServerState> {
return startOAuthLoopbackCallbackServer('codex', options);
}
export async function stopCodexLoopbackCallbackServer(): Promise<void> {
await stopOAuthLoopbackCallbackServers();
}
+166
View File
@@ -0,0 +1,166 @@
import { schema } from '../../db/index.js';
import type { OauthQuotaSnapshot } from './quotaTypes.js';
type ParsedOauthInfo = {
provider?: unknown;
accountId?: unknown;
accountKey?: unknown;
email?: unknown;
planType?: unknown;
projectId?: unknown;
tokenExpiresAt?: unknown;
refreshToken?: unknown;
idToken?: unknown;
providerData?: unknown;
quota?: unknown;
modelDiscoveryStatus?: unknown;
lastModelSyncAt?: unknown;
lastModelSyncError?: unknown;
lastDiscoveredModels?: unknown;
};
type ParsedExtraConfig = {
oauth?: ParsedOauthInfo;
};
export type OauthModelDiscoveryStatus = 'healthy' | 'abnormal';
export type OauthInfo = {
provider: string;
accountId?: string;
accountKey?: string;
email?: string;
planType?: string;
projectId?: string;
tokenExpiresAt?: number;
refreshToken?: string;
idToken?: string;
providerData?: Record<string, unknown>;
quota?: OauthQuotaSnapshot;
modelDiscoveryStatus?: OauthModelDiscoveryStatus;
lastModelSyncAt?: string;
lastModelSyncError?: string;
lastDiscoveredModels?: string[];
};
function parseExtraConfig(extraConfig?: string | null): ParsedExtraConfig {
if (!extraConfig) return {};
try {
const parsed = JSON.parse(extraConfig) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
return parsed as ParsedExtraConfig;
} catch {
return {};
}
}
function asTrimmedString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed || undefined;
}
function asPositiveInteger(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return Math.trunc(value);
}
if (typeof value === 'string') {
const parsed = Number.parseInt(value.trim(), 10);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
return undefined;
}
function asIsoDateTime(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
const parsed = Date.parse(trimmed);
return Number.isNaN(parsed) ? undefined : new Date(parsed).toISOString();
}
function asStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
return value
.map((item) => asTrimmedString(item))
.filter((item): item is string => !!item);
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
return value as Record<string, unknown>;
}
function asQuotaSnapshot(value: unknown): OauthQuotaSnapshot | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
return value as OauthQuotaSnapshot;
}
function asModelDiscoveryStatus(value: unknown): OauthModelDiscoveryStatus | undefined {
if (typeof value !== 'string') return undefined;
const normalized = value.trim().toLowerCase();
if (normalized === 'healthy') return 'healthy';
if (normalized === 'abnormal') return 'abnormal';
return undefined;
}
export function getOauthInfoFromExtraConfig(extraConfig?: string | null): OauthInfo | null {
const parsed = parseExtraConfig(extraConfig).oauth;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
const provider = asTrimmedString(parsed.provider);
if (!provider) return null;
const accountKey = asTrimmedString(parsed.accountKey) || asTrimmedString(parsed.accountId);
return {
provider,
accountId: asTrimmedString(parsed.accountId) || accountKey,
accountKey,
email: asTrimmedString(parsed.email),
planType: asTrimmedString(parsed.planType),
projectId: asTrimmedString(parsed.projectId),
tokenExpiresAt: asPositiveInteger(parsed.tokenExpiresAt),
refreshToken: asTrimmedString(parsed.refreshToken),
idToken: asTrimmedString(parsed.idToken),
providerData: asRecord(parsed.providerData),
quota: asQuotaSnapshot(parsed.quota),
modelDiscoveryStatus: asModelDiscoveryStatus(parsed.modelDiscoveryStatus),
lastModelSyncAt: asIsoDateTime(parsed.lastModelSyncAt),
lastModelSyncError: asTrimmedString(parsed.lastModelSyncError),
lastDiscoveredModels: asStringArray(parsed.lastDiscoveredModels),
};
}
export function buildOauthInfo(
extraConfig?: string | null,
patch: Partial<OauthInfo> = {},
): OauthInfo {
const provider = patch.provider || getOauthInfoFromExtraConfig(extraConfig)?.provider;
if (!provider) {
throw new Error('oauth provider is required');
}
const current = getOauthInfoFromExtraConfig(extraConfig);
const next: OauthInfo = {
provider,
...(current || {}),
...patch,
};
if (!next.accountKey && next.accountId) {
next.accountKey = next.accountId;
}
if (!next.accountId && next.accountKey) {
next.accountId = next.accountKey;
}
return next;
}
export function isOauthProvider(
account: Pick<typeof schema.accounts.$inferSelect, 'extraConfig'> | string | null | undefined,
provider?: string,
): boolean {
const extraConfig = typeof account === 'string' || account == null
? account
: account.extraConfig;
const oauth = getOauthInfoFromExtraConfig(extraConfig);
if (!oauth) return false;
if (!provider) return true;
return oauth.provider === provider;
}
+100
View File
@@ -0,0 +1,100 @@
import { codexOauthProvider } from './codexProvider.js';
import { claudeOauthProvider } from './claudeProvider.js';
import { geminiCliOauthProvider } from './geminiCliProvider.js';
import { antigravityOauthProvider } from './antigravityProvider.js';
export type OAuthProviderId = 'codex' | 'claude' | 'gemini-cli' | 'antigravity';
export type OAuthProviderMetadata = {
provider: OAuthProviderId;
label: string;
platform: string;
enabled: boolean;
loginType: 'oauth';
requiresProjectId: boolean;
supportsDirectAccountRouting: boolean;
supportsCloudValidation: boolean;
supportsNativeProxy: boolean;
};
export type OAuthProviderExchangeResult = {
accessToken: string;
refreshToken?: string;
tokenExpiresAt?: number;
email?: string;
accountKey?: string;
accountId?: string;
planType?: string;
projectId?: string;
idToken?: string;
providerData?: Record<string, unknown>;
};
export type OAuthProviderRefreshResult = OAuthProviderExchangeResult;
export type OAuthProviderProxyHeaderInput = {
oauth: {
provider: string;
accountKey?: string;
accountId?: string;
projectId?: string;
providerData?: Record<string, unknown>;
};
downstreamHeaders?: Record<string, unknown>;
};
export interface OAuthProviderDefinition {
metadata: OAuthProviderMetadata;
site: {
name: string;
url: string;
platform: string;
};
loopback: {
host: string;
port: number;
path: string;
redirectUri: string;
};
buildAuthorizationUrl(input: {
state: string;
redirectUri: string;
codeVerifier: string;
projectId?: string;
}): Promise<string>;
resolveRedirectUri?(input: {
requestOrigin?: string;
}): string;
exchangeAuthorizationCode(input: {
code: string;
state: string;
redirectUri: string;
codeVerifier: string;
projectId?: string;
}): Promise<OAuthProviderExchangeResult>;
refreshAccessToken(input: {
refreshToken: string;
oauth?: {
projectId?: string;
providerData?: Record<string, unknown>;
};
}): Promise<OAuthProviderRefreshResult>;
buildProxyHeaders?(input: OAuthProviderProxyHeaderInput): Record<string, string>;
}
const PROVIDERS: OAuthProviderDefinition[] = [
codexOauthProvider,
claudeOauthProvider,
geminiCliOauthProvider,
antigravityOauthProvider,
];
const PROVIDER_BY_ID = new Map(PROVIDERS.map((provider) => [provider.metadata.provider, provider] as const));
export function listOAuthProviderDefinitions(): OAuthProviderDefinition[] {
return PROVIDERS.slice();
}
export function getOAuthProviderDefinition(provider: string): OAuthProviderDefinition | undefined {
return PROVIDER_BY_ID.get(provider as OAuthProviderId);
}
+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, it } from 'vitest';
import {
buildQuotaSnapshotFromOauthInfo,
parseCodexQuotaResetHint,
} from './quota.js';
function buildJwt(payload: Record<string, unknown>) {
const encode = (value: unknown) => Buffer.from(JSON.stringify(value))
.toString('base64url');
return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.signature`;
}
describe('oauth quota snapshot helpers', () => {
it('builds a codex quota snapshot from stored id_token claims and reset hint', () => {
const snapshot = buildQuotaSnapshotFromOauthInfo({
provider: 'codex',
planType: 'plus',
idToken: buildJwt({
email: 'codex-user@example.com',
'https://api.openai.com/auth': {
chatgpt_account_id: 'chatgpt-account-123',
chatgpt_plan_type: 'plus',
chatgpt_subscription_active_start: '2026-03-01T00:00:00.000Z',
chatgpt_subscription_active_until: '2026-04-01T00:00:00.000Z',
},
}),
quota: {
status: 'supported',
source: 'reverse_engineered',
lastSyncAt: '2026-03-18T01:00:00.000Z',
lastLimitResetAt: '2026-03-18T05:00:00.000Z',
providerMessage: 'current codex oauth signals do not expose stable 5h/7d remaining values',
windows: {
fiveHour: {
supported: false,
message: 'official 5h quota window is not exposed by current codex oauth artifacts',
},
sevenDay: {
supported: false,
message: 'official 7d quota window is not exposed by current codex oauth artifacts',
},
},
},
});
expect(snapshot).toEqual({
status: 'supported',
source: 'reverse_engineered',
lastSyncAt: '2026-03-18T01:00:00.000Z',
lastLimitResetAt: '2026-03-18T05:00:00.000Z',
providerMessage: 'current codex oauth signals do not expose stable 5h/7d remaining values',
subscription: {
planType: 'plus',
activeStart: '2026-03-01T00:00:00.000Z',
activeUntil: '2026-04-01T00:00:00.000Z',
},
windows: {
fiveHour: {
supported: false,
message: 'official 5h quota window is not exposed by current codex oauth artifacts',
},
sevenDay: {
supported: false,
message: 'official 7d quota window is not exposed by current codex oauth artifacts',
},
},
});
});
it('returns unsupported snapshots for non-codex providers', () => {
const snapshot = buildQuotaSnapshotFromOauthInfo({
provider: 'antigravity',
planType: 'pro',
});
expect(snapshot).toEqual({
status: 'unsupported',
source: 'official',
providerMessage: 'official quota windows are not exposed for antigravity oauth',
windows: {
fiveHour: {
supported: false,
message: 'official 5h quota window is unavailable for this provider',
},
sevenDay: {
supported: false,
message: 'official 7d quota window is unavailable for this provider',
},
},
});
});
it('parses codex usage_limit_reached reset hints', () => {
const reset = parseCodexQuotaResetHint(429, JSON.stringify({
error: {
type: 'usage_limit_reached',
resets_at: 1773800400,
},
}));
expect(reset).toEqual({
resetAt: '2026-03-18T02:20:00.000Z',
message: 'codex usage_limit_reached reset hint observed from upstream',
});
});
});
+259
View File
@@ -0,0 +1,259 @@
import { eq } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { mergeAccountExtraConfig } from '../accountExtraConfig.js';
import { buildOauthInfo, getOauthInfoFromExtraConfig, type OauthInfo } from './oauthAccount.js';
import type { OauthQuotaSnapshot, OauthQuotaWindowSnapshot } from './quotaTypes.js';
type CodexJwtClaims = {
'https://api.openai.com/auth'?: {
chatgpt_plan_type?: unknown;
chatgpt_subscription_active_start?: unknown;
chatgpt_subscription_active_until?: unknown;
};
};
function asTrimmedString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed || undefined;
}
function asIsoDateTime(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
const parsed = Date.parse(trimmed);
return Number.isNaN(parsed) ? undefined : new Date(parsed).toISOString();
}
function parseCodexJwtClaims(idToken?: string): CodexJwtClaims | null {
if (!idToken) return null;
const parts = idToken.split('.');
if (parts.length !== 3) return null;
try {
const payload = JSON.parse(Buffer.from(parts[1] || '', 'base64url').toString('utf8')) as unknown;
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
return payload as CodexJwtClaims;
} catch {
return null;
}
}
function buildUnsupportedWindow(message: string): OauthQuotaWindowSnapshot {
return { supported: false, message };
}
function buildCodexUnsupportedWindows(): OauthQuotaSnapshot['windows'] {
return {
fiveHour: buildUnsupportedWindow('official 5h quota window is not exposed by current codex oauth artifacts'),
sevenDay: buildUnsupportedWindow('official 7d quota window is not exposed by current codex oauth artifacts'),
};
}
function buildProviderUnsupportedSnapshot(provider: string): OauthQuotaSnapshot {
return {
status: 'unsupported',
source: 'official',
providerMessage: `official quota windows are not exposed for ${provider} oauth`,
windows: {
fiveHour: buildUnsupportedWindow('official 5h quota window is unavailable for this provider'),
sevenDay: buildUnsupportedWindow('official 7d quota window is unavailable for this provider'),
},
};
}
function normalizeStoredWindow(value: unknown): OauthQuotaWindowSnapshot | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const raw = value as Record<string, unknown>;
const supported = typeof raw.supported === 'boolean' ? raw.supported : undefined;
if (supported === undefined) return undefined;
const pickNumber = (field: string) => {
const item = raw[field];
return typeof item === 'number' && Number.isFinite(item) ? item : undefined;
};
const normalized: OauthQuotaWindowSnapshot = {
supported,
};
const limit = pickNumber('limit');
const used = pickNumber('used');
const remaining = pickNumber('remaining');
const resetAt = asIsoDateTime(raw.resetAt);
const message = asTrimmedString(raw.message);
if (limit !== undefined) normalized.limit = limit;
if (used !== undefined) normalized.used = used;
if (remaining !== undefined) normalized.remaining = remaining;
if (resetAt) normalized.resetAt = resetAt;
if (message) normalized.message = message;
return normalized;
}
function normalizeStoredQuotaSnapshot(value: unknown): OauthQuotaSnapshot | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const raw = value as Record<string, unknown>;
const status = raw.status === 'supported' || raw.status === 'unsupported' || raw.status === 'error'
? raw.status
: undefined;
const source = raw.source === 'official' || raw.source === 'reverse_engineered'
? raw.source
: undefined;
const windowsRaw = raw.windows;
if (!status || !source || !windowsRaw || typeof windowsRaw !== 'object' || Array.isArray(windowsRaw)) {
return undefined;
}
const windowsObject = windowsRaw as Record<string, unknown>;
const fiveHour = normalizeStoredWindow(windowsObject.fiveHour);
const sevenDay = normalizeStoredWindow(windowsObject.sevenDay);
if (!fiveHour || !sevenDay) return undefined;
const subscriptionRaw = raw.subscription;
const subscription = subscriptionRaw && typeof subscriptionRaw === 'object' && !Array.isArray(subscriptionRaw)
? {
planType: asTrimmedString((subscriptionRaw as Record<string, unknown>).planType),
activeStart: asIsoDateTime((subscriptionRaw as Record<string, unknown>).activeStart),
activeUntil: asIsoDateTime((subscriptionRaw as Record<string, unknown>).activeUntil),
}
: undefined;
return {
status,
source,
...(asIsoDateTime(raw.lastSyncAt) ? { lastSyncAt: asIsoDateTime(raw.lastSyncAt)! } : {}),
...(asTrimmedString(raw.lastError) ? { lastError: asTrimmedString(raw.lastError)! } : {}),
...(asTrimmedString(raw.providerMessage) ? { providerMessage: asTrimmedString(raw.providerMessage)! } : {}),
...(subscription && (subscription.planType || subscription.activeStart || subscription.activeUntil)
? { subscription }
: {}),
windows: { fiveHour, sevenDay },
...(asIsoDateTime(raw.lastLimitResetAt) ? { lastLimitResetAt: asIsoDateTime(raw.lastLimitResetAt)! } : {}),
};
}
function buildStoredCodexSnapshot(oauth: Pick<OauthInfo, 'planType' | 'idToken' | 'quota'>): OauthQuotaSnapshot {
const claims = parseCodexJwtClaims(oauth.idToken);
const authClaims = claims?.['https://api.openai.com/auth'];
const storedQuota = normalizeStoredQuotaSnapshot(oauth.quota);
const subscription = {
planType: asTrimmedString(authClaims?.chatgpt_plan_type) || oauth.planType,
activeStart: asIsoDateTime(authClaims?.chatgpt_subscription_active_start),
activeUntil: asIsoDateTime(authClaims?.chatgpt_subscription_active_until),
};
return {
status: storedQuota?.status || 'supported',
source: storedQuota?.source || 'reverse_engineered',
...(storedQuota?.lastSyncAt ? { lastSyncAt: storedQuota.lastSyncAt } : {}),
...(storedQuota?.lastError ? { lastError: storedQuota.lastError } : {}),
providerMessage: storedQuota?.providerMessage || 'current codex oauth signals do not expose stable 5h/7d remaining values',
...((subscription.planType || subscription.activeStart || subscription.activeUntil) ? { subscription } : {}),
windows: storedQuota?.windows || buildCodexUnsupportedWindows(),
...(storedQuota?.lastLimitResetAt ? { lastLimitResetAt: storedQuota.lastLimitResetAt } : {}),
};
}
export function buildQuotaSnapshotFromOauthInfo(oauth: Pick<OauthInfo, 'provider' | 'planType' | 'idToken' | 'quota'>): OauthQuotaSnapshot {
if (oauth.provider === 'codex') {
return buildStoredCodexSnapshot(oauth);
}
return buildProviderUnsupportedSnapshot(oauth.provider);
}
export function parseCodexQuotaResetHint(
statusCode: number,
errorBody: string | null | undefined,
nowMs = Date.now(),
): { resetAt: string; message: string } | null {
if (statusCode !== 429 || !errorBody) return null;
try {
const parsed = JSON.parse(errorBody) as Record<string, any>;
const error = parsed?.error;
if (!error || typeof error !== 'object' || error.type !== 'usage_limit_reached') {
return null;
}
if (typeof error.resets_at === 'number' && Number.isFinite(error.resets_at) && error.resets_at > 0) {
return {
resetAt: new Date(error.resets_at * 1000).toISOString(),
message: 'codex usage_limit_reached reset hint observed from upstream',
};
}
if (typeof error.resets_in_seconds === 'number' && Number.isFinite(error.resets_in_seconds) && error.resets_in_seconds > 0) {
return {
resetAt: new Date(nowMs + error.resets_in_seconds * 1000).toISOString(),
message: 'codex usage_limit_reached reset hint observed from upstream',
};
}
} catch {
return null;
}
return null;
}
async function persistQuotaSnapshot(accountId: number, snapshot: OauthQuotaSnapshot) {
const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId)).get();
if (!account) {
throw new Error('oauth account not found');
}
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
if (!oauth) {
throw new Error('account is not managed by oauth');
}
const nextExtraConfig = mergeAccountExtraConfig(account.extraConfig, {
oauth: buildOauthInfo(account.extraConfig, {
quota: snapshot,
}),
});
await db.update(schema.accounts).set({
extraConfig: nextExtraConfig,
updatedAt: new Date().toISOString(),
}).where(eq(schema.accounts.id, accountId)).run();
return snapshot;
}
export async function refreshOauthQuotaSnapshot(accountId: number): Promise<OauthQuotaSnapshot> {
const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId)).get();
if (!account) {
throw new Error('oauth account not found');
}
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
if (!oauth) {
throw new Error('account is not managed by oauth');
}
const baseSnapshot = buildQuotaSnapshotFromOauthInfo(oauth);
const snapshot: OauthQuotaSnapshot = {
...baseSnapshot,
lastSyncAt: new Date().toISOString(),
...(baseSnapshot.status === 'error' ? {} : { lastError: undefined }),
};
return persistQuotaSnapshot(accountId, snapshot);
}
export async function recordOauthQuotaResetHint(input: {
accountId: number;
statusCode: number;
errorText?: string | null;
}): Promise<OauthQuotaSnapshot | null> {
const resetHint = parseCodexQuotaResetHint(input.statusCode, input.errorText);
if (!resetHint) return null;
const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, input.accountId)).get();
if (!account) return null;
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
if (!oauth || oauth.provider !== 'codex') return null;
const baseSnapshot = buildQuotaSnapshotFromOauthInfo({
...oauth,
quota: {
...normalizeStoredQuotaSnapshot(oauth.quota),
status: 'supported',
source: 'reverse_engineered',
lastLimitResetAt: resetHint.resetAt,
providerMessage: 'current codex oauth signals do not expose stable 5h/7d remaining values',
windows: normalizeStoredQuotaSnapshot(oauth.quota)?.windows || buildCodexUnsupportedWindows(),
},
});
return persistQuotaSnapshot(input.accountId, {
...baseSnapshot,
lastSyncAt: new Date().toISOString(),
lastLimitResetAt: resetHint.resetAt,
});
}
+26
View File
@@ -0,0 +1,26 @@
export type OauthQuotaWindowSnapshot = {
supported: boolean;
limit?: number | null;
used?: number | null;
remaining?: number | null;
resetAt?: string | null;
message?: string;
};
export type OauthQuotaSnapshot = {
status: 'supported' | 'unsupported' | 'error';
source: 'official' | 'reverse_engineered';
lastSyncAt?: string;
lastError?: string;
providerMessage?: string;
subscription?: {
planType?: string;
activeStart?: string;
activeUntil?: string;
};
windows: {
fiveHour: OauthQuotaWindowSnapshot;
sevenDay: OauthQuotaWindowSnapshot;
};
lastLimitResetAt?: string;
};

Some files were not shown because too many files have changed in this diff Show More