forked from boos/BoosAPI
feat: add multi-provider OAuth support for Claude and Gemini CLI
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
ALTER TABLE `accounts` ADD `oauth_provider` text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `accounts` ADD `oauth_account_key` text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `accounts` ADD `oauth_project_id` text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE `accounts`
|
||||||
|
SET
|
||||||
|
`oauth_provider` = NULLIF(TRIM(json_extract(`extra_config`, '$.oauth.provider')), ''),
|
||||||
|
`oauth_account_key` = NULLIF(
|
||||||
|
TRIM(COALESCE(
|
||||||
|
json_extract(`extra_config`, '$.oauth.accountKey'),
|
||||||
|
json_extract(`extra_config`, '$.oauth.accountId')
|
||||||
|
)),
|
||||||
|
''
|
||||||
|
),
|
||||||
|
`oauth_project_id` = NULLIF(TRIM(json_extract(`extra_config`, '$.oauth.projectId')), '')
|
||||||
|
WHERE `extra_config` IS NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `accounts_oauth_provider_idx` ON `accounts` (`oauth_provider`);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `accounts_oauth_identity_idx` ON `accounts` (`oauth_provider`,`oauth_account_key`,`oauth_project_id`);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`,`url`);
|
||||||
@@ -85,6 +85,13 @@
|
|||||||
"when": 1773665311013,
|
"when": 1773665311013,
|
||||||
"tag": "0012_account_token_value_status",
|
"tag": "0012_account_token_value_status",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1773682200000,
|
||||||
|
"tag": "0013_oauth_multi_provider",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ describe('buildConfig', () => {
|
|||||||
expect(config.telegramApiBaseUrl).toBe('https://api.telegram.org');
|
expect(config.telegramApiBaseUrl).toBe('https://api.telegram.org');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not embed OAuth client credentials by default', () => {
|
||||||
|
const config = buildConfig({});
|
||||||
|
|
||||||
|
expect(config.codexClientId).toBe('');
|
||||||
|
expect(config.claudeClientId).toBe('');
|
||||||
|
expect(config.claudeClientSecret).toBe('');
|
||||||
|
expect(config.geminiCliClientId).toBe('');
|
||||||
|
expect(config.geminiCliClientSecret).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts JSON request bodies larger than Fastify default 1 MiB', async () => {
|
it('accepts JSON request bodies larger than Fastify default 1 MiB', async () => {
|
||||||
const app = Fastify(buildFastifyOptions(buildConfig({})));
|
const app = Fastify(buildFastifyOptions(buildConfig({})));
|
||||||
const largeText = 'a'.repeat(2 * 1024 * 1024);
|
const largeText = 'a'.repeat(2 * 1024 * 1024);
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ function parseCsvList(value: string | undefined): string[] {
|
|||||||
.filter((item) => item.length > 0);
|
.filter((item) => item.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseOptionalSecret(value: string | undefined): string {
|
||||||
|
return (value || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
function parseDbType(value: string | undefined): 'sqlite' | 'mysql' | 'postgres' {
|
function parseDbType(value: string | undefined): 'sqlite' | 'mysql' | 'postgres' {
|
||||||
const normalized = (value || 'sqlite').trim().toLowerCase();
|
const normalized = (value || 'sqlite').trim().toLowerCase();
|
||||||
if (normalized === 'mysql') return 'mysql';
|
if (normalized === 'mysql') return 'mysql';
|
||||||
@@ -41,6 +45,11 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
|
|||||||
return {
|
return {
|
||||||
authToken: env.AUTH_TOKEN || 'change-me-admin-token',
|
authToken: env.AUTH_TOKEN || 'change-me-admin-token',
|
||||||
proxyToken: env.PROXY_TOKEN || 'change-me-proxy-sk-token',
|
proxyToken: env.PROXY_TOKEN || 'change-me-proxy-sk-token',
|
||||||
|
codexClientId: parseOptionalSecret(env.CODEX_CLIENT_ID),
|
||||||
|
claudeClientId: parseOptionalSecret(env.CLAUDE_CLIENT_ID),
|
||||||
|
claudeClientSecret: parseOptionalSecret(env.CLAUDE_CLIENT_SECRET),
|
||||||
|
geminiCliClientId: parseOptionalSecret(env.GEMINI_CLI_CLIENT_ID),
|
||||||
|
geminiCliClientSecret: parseOptionalSecret(env.GEMINI_CLI_CLIENT_SECRET),
|
||||||
systemProxyUrl: env.SYSTEM_PROXY_URL || '',
|
systemProxyUrl: env.SYSTEM_PROXY_URL || '',
|
||||||
accountCredentialSecret: env.ACCOUNT_CREDENTIAL_SECRET || env.AUTH_TOKEN || 'change-me-admin-token',
|
accountCredentialSecret: env.ACCOUNT_CREDENTIAL_SECRET || env.AUTH_TOKEN || 'change-me-admin-token',
|
||||||
checkinCron: env.CHECKIN_CRON || '0 8 * * *',
|
checkinCron: env.CHECKIN_CRON || '0 8 * * *',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
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 `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 `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 `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 `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, `group_name` TEXT, `tags` TEXT);
|
CREATE TABLE IF NOT EXISTS `downstream_api_keys` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `key` TEXT NOT NULL, `description` TEXT, `enabled` BOOLEAN DEFAULT true, `expires_at` 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);
|
||||||
@@ -18,10 +18,13 @@ CREATE UNIQUE INDEX `model_availability_account_model_unique` ON `model_availabi
|
|||||||
CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(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 `proxy_video_tasks_public_id_unique` ON `proxy_video_tasks` (`public_id`(191));
|
||||||
CREATE UNIQUE INDEX `site_disabled_models_site_model_unique` ON `site_disabled_models` (`site_id`, `model_name`(191));
|
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 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_enabled_idx` ON `account_tokens` (`account_id`, `enabled`);
|
||||||
CREATE INDEX `account_tokens_account_id_idx` ON `account_tokens` (`account_id`);
|
CREATE INDEX `account_tokens_account_id_idx` ON `account_tokens` (`account_id`);
|
||||||
CREATE INDEX `account_tokens_enabled_idx` ON `account_tokens` (`enabled`);
|
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_id_idx` ON `accounts` (`site_id`);
|
||||||
CREATE INDEX `accounts_site_status_idx` ON `accounts` (`site_id`, `status`(191));
|
CREATE INDEX `accounts_site_status_idx` ON `accounts` (`site_id`, `status`(191));
|
||||||
CREATE INDEX `accounts_status_idx` ON `accounts` (`status`(191));
|
CREATE INDEX `accounts_status_idx` ON `accounts` (`status`(191));
|
||||||
|
|||||||
@@ -1 +1,6 @@
|
|||||||
ALTER TABLE `account_tokens` ADD COLUMN `value_status` VARCHAR(191) NOT NULL DEFAULT 'ready';
|
ALTER TABLE `accounts` ADD COLUMN `oauth_provider` TEXT;
|
||||||
|
ALTER TABLE `accounts` ADD COLUMN `oauth_account_key` TEXT;
|
||||||
|
ALTER TABLE `accounts` ADD COLUMN `oauth_project_id` TEXT;
|
||||||
|
CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`(191), `url`(191));
|
||||||
|
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));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
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 "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 "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 "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 "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, "group_name" TEXT, "tags" TEXT);
|
CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL, "description" TEXT, "enabled" BOOLEAN DEFAULT true, "expires_at" 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);
|
||||||
@@ -18,10 +18,13 @@ CREATE UNIQUE INDEX "model_availability_account_model_unique" ON "model_availabi
|
|||||||
CREATE UNIQUE INDEX "proxy_files_public_id_unique" ON "proxy_files" ("public_id");
|
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 "proxy_video_tasks_public_id_unique" ON "proxy_video_tasks" ("public_id");
|
||||||
CREATE UNIQUE INDEX "site_disabled_models_site_model_unique" ON "site_disabled_models" ("site_id", "model_name");
|
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 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_enabled_idx" ON "account_tokens" ("account_id", "enabled");
|
||||||
CREATE INDEX "account_tokens_account_id_idx" ON "account_tokens" ("account_id");
|
CREATE INDEX "account_tokens_account_id_idx" ON "account_tokens" ("account_id");
|
||||||
CREATE INDEX "account_tokens_enabled_idx" ON "account_tokens" ("enabled");
|
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_id_idx" ON "accounts" ("site_id");
|
||||||
CREATE INDEX "accounts_site_status_idx" ON "accounts" ("site_id", "status");
|
CREATE INDEX "accounts_site_status_idx" ON "accounts" ("site_id", "status");
|
||||||
CREATE INDEX "accounts_status_idx" ON "accounts" ("status");
|
CREATE INDEX "accounts_status_idx" ON "accounts" ("status");
|
||||||
|
|||||||
@@ -1 +1,6 @@
|
|||||||
ALTER TABLE "account_tokens" ADD COLUMN "value_status" TEXT NOT NULL DEFAULT 'ready';
|
ALTER TABLE "accounts" ADD COLUMN "oauth_provider" TEXT;
|
||||||
|
ALTER TABLE "accounts" ADD COLUMN "oauth_account_key" TEXT;
|
||||||
|
ALTER TABLE "accounts" ADD COLUMN "oauth_project_id" TEXT;
|
||||||
|
CREATE UNIQUE INDEX "sites_platform_url_unique" ON "sites" ("platform", "url");
|
||||||
|
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");
|
||||||
|
|||||||
@@ -185,6 +185,24 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"defaultValue": "0",
|
"defaultValue": "0",
|
||||||
"primaryKey": false
|
"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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1125,6 +1143,24 @@
|
|||||||
],
|
],
|
||||||
"unique": false
|
"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",
|
"name": "accounts_site_id_idx",
|
||||||
"table": "accounts",
|
"table": "accounts",
|
||||||
@@ -1404,6 +1440,15 @@
|
|||||||
],
|
],
|
||||||
"unique": true
|
"unique": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "sites_platform_url_unique",
|
||||||
|
"table": "sites",
|
||||||
|
"columns": [
|
||||||
|
"platform",
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"unique": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "sites_status_idx",
|
"name": "sites_status_idx",
|
||||||
"table": "sites",
|
"table": "sites",
|
||||||
@@ -1501,6 +1546,14 @@
|
|||||||
"model_name"
|
"model_name"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "sites_platform_url_unique",
|
||||||
|
"table": "sites",
|
||||||
|
"columns": [
|
||||||
|
"platform",
|
||||||
|
"url"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "token_model_availability_token_model_unique",
|
"name": "token_model_availability_token_model_unique",
|
||||||
"table": "token_model_availability",
|
"table": "token_model_availability",
|
||||||
|
|||||||
@@ -344,6 +344,7 @@ describe('sqlite migrate bootstrap', () => {
|
|||||||
'0010_proxy_logs_downstream_api_key',
|
'0010_proxy_logs_downstream_api_key',
|
||||||
'0011_downstream_api_key_metadata',
|
'0011_downstream_api_key_metadata',
|
||||||
'0012_account_token_value_status',
|
'0012_account_token_value_status',
|
||||||
|
'0013_oauth_multi_provider',
|
||||||
]);
|
]);
|
||||||
const appliedEntries = journalEntries.filter((entry) => !missingTags.has(entry.tag));
|
const appliedEntries = journalEntries.filter((entry) => !missingTags.has(entry.tag));
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export const sites = sqliteTable('sites', {
|
|||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
url: text('url').notNull(),
|
url: text('url').notNull(),
|
||||||
externalCheckinUrl: text('external_checkin_url'),
|
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'
|
||||||
proxyUrl: text('proxy_url'),
|
proxyUrl: text('proxy_url'),
|
||||||
useSystemProxy: integer('use_system_proxy', { mode: 'boolean' }).default(false),
|
useSystemProxy: integer('use_system_proxy', { mode: 'boolean' }).default(false),
|
||||||
customHeaders: text('custom_headers'),
|
customHeaders: text('custom_headers'),
|
||||||
@@ -19,6 +19,7 @@ export const sites = sqliteTable('sites', {
|
|||||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
statusIdx: index('sites_status_idx').on(table.status),
|
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', {
|
export const siteDisabledModels = sqliteTable('site_disabled_models', {
|
||||||
@@ -48,6 +49,9 @@ export const accounts = sqliteTable('accounts', {
|
|||||||
checkinEnabled: integer('checkin_enabled', { mode: 'boolean' }).default(true),
|
checkinEnabled: integer('checkin_enabled', { mode: 'boolean' }).default(true),
|
||||||
lastCheckinAt: text('last_checkin_at'),
|
lastCheckinAt: text('last_checkin_at'),
|
||||||
lastBalanceRefresh: text('last_balance_refresh'),
|
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
|
extraConfig: text('extra_config'), // JSON string
|
||||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||||
@@ -55,6 +59,8 @@ export const accounts = sqliteTable('accounts', {
|
|||||||
siteIdIdx: index('accounts_site_id_idx').on(table.siteId),
|
siteIdIdx: index('accounts_site_id_idx').on(table.siteId),
|
||||||
statusIdx: index('accounts_status_idx').on(table.status),
|
statusIdx: index('accounts_status_idx').on(table.status),
|
||||||
siteStatusIdx: index('accounts_site_status_idx').on(table.siteId, 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', {
|
export const accountTokens = sqliteTable('account_tokens', {
|
||||||
|
|||||||
+4
-4
@@ -25,7 +25,7 @@ import { buildStartupSummaryLines } from './services/startupInfo.js';
|
|||||||
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js';
|
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js';
|
||||||
import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js';
|
import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js';
|
||||||
import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js';
|
import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js';
|
||||||
import { startCodexLoopbackCallbackServer, stopCodexLoopbackCallbackServer } from './services/oauth/localCallbackServer.js';
|
import { startOAuthLoopbackCallbackServers, stopOAuthLoopbackCallbackServers } from './services/oauth/localCallbackServer.js';
|
||||||
import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js';
|
import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js';
|
||||||
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
|
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
@@ -349,14 +349,14 @@ if (existsSync(webDir)) {
|
|||||||
// Start scheduler
|
// Start scheduler
|
||||||
await startScheduler();
|
await startScheduler();
|
||||||
try {
|
try {
|
||||||
await startCodexLoopbackCallbackServer();
|
await startOAuthLoopbackCallbackServers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Failed to start Codex OAuth callback listener: ${(error as Error)?.message || 'unknown error'}`);
|
console.warn(`Failed to start OAuth callback listeners: ${(error as Error)?.message || 'unknown error'}`);
|
||||||
}
|
}
|
||||||
setLegacyProxyLogRetentionFallbackEnabled(!config.logCleanupConfigured);
|
setLegacyProxyLogRetentionFallbackEnabled(!config.logCleanupConfigured);
|
||||||
app.addHook('onClose', async () => {
|
app.addHook('onClose', async () => {
|
||||||
stopProxyLogRetentionService();
|
stopProxyLogRetentionService();
|
||||||
await stopCodexLoopbackCallbackServer();
|
await stopOAuthLoopbackCallbackServers();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ vi.mock('../../services/platforms/index.js', () => ({
|
|||||||
|
|
||||||
type DbModule = typeof import('../../db/index.js');
|
type DbModule = typeof import('../../db/index.js');
|
||||||
|
|
||||||
describe('account token coverage refresh', () => {
|
describe('account token coverage refresh', { timeout: 15_000 }, () => {
|
||||||
let app: FastifyInstance;
|
let app: FastifyInstance;
|
||||||
let db: DbModule['db'];
|
let db: DbModule['db'];
|
||||||
let schema: DbModule['schema'];
|
let schema: DbModule['schema'];
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ describe('accounts credential mode', { timeout: 15_000 }, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks codex oauth connection as proxy-capable but not checkin/balance-capable', async () => {
|
it('marks codex oauth connection as direct-routed proxy-only connection without checkin/balance capabilities', async () => {
|
||||||
const site = await db.insert(schema.sites).values({
|
const site = await db.insert(schema.sites).values({
|
||||||
name: 'Codex Site',
|
name: 'Codex Site',
|
||||||
url: 'https://chatgpt.com/backend-api/codex',
|
url: 'https://chatgpt.com/backend-api/codex',
|
||||||
@@ -224,7 +224,7 @@ describe('accounts credential mode', { timeout: 15_000 }, () => {
|
|||||||
expect(item?.capabilities).toMatchObject({
|
expect(item?.capabilities).toMatchObject({
|
||||||
canCheckin: false,
|
canCheckin: false,
|
||||||
canRefreshBalance: false,
|
canRefreshBalance: false,
|
||||||
proxyOnly: false,
|
proxyOnly: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ensureDefaultTokenForAccount, syncTokensFromUpstream } from '../../serv
|
|||||||
import {
|
import {
|
||||||
getCredentialModeFromExtraConfig,
|
getCredentialModeFromExtraConfig,
|
||||||
guessPlatformUserIdFromUsername,
|
guessPlatformUserIdFromUsername,
|
||||||
|
hasOauthProvider,
|
||||||
getSub2ApiAuthFromExtraConfig,
|
getSub2ApiAuthFromExtraConfig,
|
||||||
mergeAccountExtraConfig,
|
mergeAccountExtraConfig,
|
||||||
normalizeCredentialMode as normalizeCredentialModeInput,
|
normalizeCredentialMode as normalizeCredentialModeInput,
|
||||||
@@ -19,7 +20,6 @@ import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
|||||||
import { parseCheckinRewardAmount } from '../../services/checkinRewardParser.js';
|
import { parseCheckinRewardAmount } from '../../services/checkinRewardParser.js';
|
||||||
import { estimateRewardWithTodayIncomeFallback } from '../../services/todayIncomeRewardService.js';
|
import { estimateRewardWithTodayIncomeFallback } from '../../services/todayIncomeRewardService.js';
|
||||||
import { getLocalDayRangeUtc } from '../../services/localTimeService.js';
|
import { getLocalDayRangeUtc } from '../../services/localTimeService.js';
|
||||||
import { isCodexPlatform } from '../../services/oauth/codexAccount.js';
|
|
||||||
import {
|
import {
|
||||||
buildRuntimeHealthForAccount,
|
buildRuntimeHealthForAccount,
|
||||||
setAccountRuntimeHealth,
|
setAccountRuntimeHealth,
|
||||||
@@ -82,11 +82,11 @@ function buildCapabilitiesFromCredentialMode(
|
|||||||
hasSessionToken: boolean,
|
hasSessionToken: boolean,
|
||||||
extraConfig?: string | null,
|
extraConfig?: string | null,
|
||||||
): AccountCapabilities {
|
): AccountCapabilities {
|
||||||
if (isCodexPlatform(extraConfig)) {
|
if (hasOauthProvider(extraConfig)) {
|
||||||
return {
|
return {
|
||||||
canCheckin: false,
|
canCheckin: false,
|
||||||
canRefreshBalance: false,
|
canRefreshBalance: false,
|
||||||
proxyOnly: false,
|
proxyOnly: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const sessionCapable = credentialMode === 'session'
|
const sessionCapable = credentialMode === 'session'
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import Fastify from 'fastify';
|
||||||
import { readFileSync } from 'node:fs';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
import { resolve } from 'node:path';
|
import { oauthRoutes } from './oauth.js';
|
||||||
|
import { isPublicApiRoute } from '../../desktop.js';
|
||||||
|
|
||||||
describe('oauth route registration', () => {
|
describe('oauth route registration', () => {
|
||||||
it('registers oauth routes in the main server entry', () => {
|
const apps: Array<Awaited<ReturnType<typeof Fastify>>> = [];
|
||||||
const source = readFileSync(resolve(process.cwd(), 'src/server/index.ts'), 'utf8');
|
|
||||||
|
|
||||||
expect(source).toContain("import { oauthRoutes } from './routes/api/oauth.js';");
|
afterEach(async () => {
|
||||||
expect(source).toContain('await app.register(oauthRoutes);');
|
await Promise.all(apps.splice(0).map((app) => app.close()));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats oauth callback as a public API route', () => {
|
it('registers oauth routes on a Fastify instance at runtime', async () => {
|
||||||
const source = readFileSync(resolve(process.cwd(), 'src/server/desktop.ts'), 'utf8');
|
const app = Fastify();
|
||||||
|
apps.push(app);
|
||||||
|
await app.register(oauthRoutes);
|
||||||
|
|
||||||
expect(source).toContain("url.startsWith('/api/oauth/callback/')");
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-oauth-routes-'));
|
dataDir = mkdtempSync(join(tmpdir(), 'metapi-oauth-routes-'));
|
||||||
process.env.DATA_DIR = dataDir;
|
process.env.DATA_DIR = dataDir;
|
||||||
|
process.env.CODEX_CLIENT_ID = 'test-codex-client-id';
|
||||||
|
process.env.CLAUDE_CLIENT_ID = 'test-claude-client-id';
|
||||||
|
process.env.GEMINI_CLI_CLIENT_ID = 'test-gemini-client-id';
|
||||||
|
process.env.GEMINI_CLI_CLIENT_SECRET = 'test-gemini-client-secret';
|
||||||
|
|
||||||
await import('../../db/migrate.js');
|
await import('../../db/migrate.js');
|
||||||
const dbModule = await import('../../db/index.js');
|
const dbModule = await import('../../db/index.js');
|
||||||
@@ -52,9 +56,13 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
|||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await app.close();
|
await app.close();
|
||||||
delete process.env.DATA_DIR;
|
delete process.env.DATA_DIR;
|
||||||
|
delete process.env.CODEX_CLIENT_ID;
|
||||||
|
delete process.env.CLAUDE_CLIENT_ID;
|
||||||
|
delete process.env.GEMINI_CLI_CLIENT_ID;
|
||||||
|
delete process.env.GEMINI_CLI_CLIENT_SECRET;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lists codex oauth provider metadata', async () => {
|
it('lists multi-provider oauth metadata', async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/api/oauth/providers',
|
url: '/api/oauth/providers',
|
||||||
@@ -62,14 +70,27 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
|||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(response.json()).toMatchObject({
|
expect(response.json()).toMatchObject({
|
||||||
providers: [
|
providers: expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
provider: 'codex',
|
provider: 'codex',
|
||||||
platform: 'codex',
|
platform: 'codex',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
loginType: 'oauth',
|
loginType: 'oauth',
|
||||||
}),
|
}),
|
||||||
],
|
expect.objectContaining({
|
||||||
|
provider: 'claude',
|
||||||
|
platform: 'claude',
|
||||||
|
enabled: true,
|
||||||
|
loginType: 'oauth',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
provider: 'gemini-cli',
|
||||||
|
platform: 'gemini-cli',
|
||||||
|
enabled: true,
|
||||||
|
loginType: 'oauth',
|
||||||
|
requiresProjectId: true,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,7 +114,7 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
|||||||
expect(startBody.state).toMatch(/^[a-zA-Z0-9_-]{20,}$/);
|
expect(startBody.state).toMatch(/^[a-zA-Z0-9_-]{20,}$/);
|
||||||
expect(startBody.authorizationUrl).toContain('https://auth.openai.com/oauth/authorize?');
|
expect(startBody.authorizationUrl).toContain('https://auth.openai.com/oauth/authorize?');
|
||||||
expect(startBody.authorizationUrl).toContain('client_id=');
|
expect(startBody.authorizationUrl).toContain('client_id=');
|
||||||
expect(startBody.authorizationUrl).toContain(encodeURIComponent('http://localhost:1455/auth/callback'));
|
expect(startBody.authorizationUrl).toContain(encodeURIComponent('https://metapi.example/api/oauth/callback/codex'));
|
||||||
expect(startBody.authorizationUrl).toContain(`state=${encodeURIComponent(startBody.state)}`);
|
expect(startBody.authorizationUrl).toContain(`state=${encodeURIComponent(startBody.state)}`);
|
||||||
expect(startBody.authorizationUrl).toContain('code_challenge=');
|
expect(startBody.authorizationUrl).toContain('code_challenge=');
|
||||||
|
|
||||||
@@ -109,6 +130,26 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the codex loopback callback for local origins', async () => {
|
||||||
|
const startResponse = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/oauth/providers/codex/start',
|
||||||
|
headers: {
|
||||||
|
host: 'localhost:4000',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(startResponse.statusCode).toBe(200);
|
||||||
|
const startBody = startResponse.json() as {
|
||||||
|
provider: string;
|
||||||
|
state: string;
|
||||||
|
authorizationUrl: string;
|
||||||
|
};
|
||||||
|
expect(startBody.provider).toBe('codex');
|
||||||
|
expect(startBody.authorizationUrl).toContain(encodeURIComponent('http://localhost:1455/auth/callback'));
|
||||||
|
expect(startBody.authorizationUrl).not.toContain(encodeURIComponent('http://localhost:4000/api/oauth/callback/codex'));
|
||||||
|
});
|
||||||
|
|
||||||
it('handles codex callback, creates oauth-backed account, and discovers plan models', async () => {
|
it('handles codex callback, creates oauth-backed account, and discovers plan models', async () => {
|
||||||
const jwt = buildJwt({
|
const jwt = buildJwt({
|
||||||
email: 'codex-user@example.com',
|
email: 'codex-user@example.com',
|
||||||
@@ -163,6 +204,9 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
|||||||
});
|
});
|
||||||
expect(callbackResponse.statusCode).toBe(200);
|
expect(callbackResponse.statusCode).toBe(200);
|
||||||
expect(callbackResponse.body).toContain('window.close()');
|
expect(callbackResponse.body).toContain('window.close()');
|
||||||
|
expect(String(fetchMock.mock.calls[0]?.[1]?.body || '')).toContain(
|
||||||
|
'redirect_uri=https%3A%2F%2Fmetapi.example%2Fapi%2Foauth%2Fcallback%2Fcodex',
|
||||||
|
);
|
||||||
|
|
||||||
const sessionResponse = await app.inject({
|
const sessionResponse = await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -284,7 +328,12 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
|||||||
url: '/api/oauth/connections',
|
url: '/api/oauth/connections',
|
||||||
});
|
});
|
||||||
expect(connectionsResponse.statusCode).toBe(200);
|
expect(connectionsResponse.statusCode).toBe(200);
|
||||||
expect(connectionsResponse.json()).toEqual({ items: [] });
|
expect(connectionsResponse.json()).toMatchObject({
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
limit: 50,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lists oauth connection health metadata and supports deleting the connection', async () => {
|
it('lists oauth connection health metadata and supports deleting the connection', async () => {
|
||||||
@@ -301,6 +350,8 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
|||||||
accessToken: 'oauth-access-token',
|
accessToken: 'oauth-access-token',
|
||||||
apiToken: null,
|
apiToken: null,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
oauthProvider: 'codex',
|
||||||
|
oauthAccountKey: 'chatgpt-account-123',
|
||||||
extraConfig: JSON.stringify({
|
extraConfig: JSON.stringify({
|
||||||
credentialMode: 'session',
|
credentialMode: 'session',
|
||||||
oauth: {
|
oauth: {
|
||||||
@@ -342,8 +393,8 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(listResponse.statusCode).toBe(200);
|
expect(listResponse.statusCode).toBe(200);
|
||||||
expect(listResponse.json()).toEqual({
|
expect(listResponse.json()).toMatchObject({
|
||||||
items: [
|
items: expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
provider: 'codex',
|
provider: 'codex',
|
||||||
@@ -353,7 +404,10 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
|||||||
lastModelSyncAt: '2026-03-17T08:00:00.000Z',
|
lastModelSyncAt: '2026-03-17T08:00:00.000Z',
|
||||||
lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)',
|
lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)',
|
||||||
}),
|
}),
|
||||||
],
|
]),
|
||||||
|
total: 1,
|
||||||
|
limit: 50,
|
||||||
|
offset: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteResponse = await app.inject({
|
const deleteResponse = await app.inject({
|
||||||
|
|||||||
+141
-29
@@ -1,4 +1,5 @@
|
|||||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||||
|
import { createRateLimitGuard } from '../../middleware/requestRateLimit.js';
|
||||||
import {
|
import {
|
||||||
deleteOauthConnection,
|
deleteOauthConnection,
|
||||||
getOauthSessionStatus,
|
getOauthSessionStatus,
|
||||||
@@ -9,7 +10,80 @@ import {
|
|||||||
startOauthRebindFlow,
|
startOauthRebindFlow,
|
||||||
} from '../../services/oauth/service.js';
|
} from '../../services/oauth/service.js';
|
||||||
|
|
||||||
function resolveRequestOrigin(request: FastifyRequest): string {
|
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 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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
const forwardedProto = typeof request.headers['x-forwarded-proto'] === 'string'
|
||||||
? request.headers['x-forwarded-proto'].split(',')[0]?.trim()
|
? request.headers['x-forwarded-proto'].split(',')[0]?.trim()
|
||||||
: '';
|
: '';
|
||||||
@@ -18,24 +92,37 @@ function resolveRequestOrigin(request: FastifyRequest): string {
|
|||||||
? request.headers['x-forwarded-host'].split(',')[0]?.trim()
|
? request.headers['x-forwarded-host'].split(',')[0]?.trim()
|
||||||
: '';
|
: '';
|
||||||
const host = forwardedHost
|
const host = forwardedHost
|
||||||
|| (typeof request.headers.host === 'string' ? request.headers.host.trim() : '')
|
|| (typeof request.headers.host === 'string' ? request.headers.host.trim() : '');
|
||||||
|| 'localhost';
|
if (!host) return undefined;
|
||||||
return `${protocol}://${host}`;
|
return `${protocol}://${host}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function oauthRoutes(app: FastifyInstance) {
|
export async function oauthRoutes(app: FastifyInstance) {
|
||||||
app.get('/api/oauth/providers', async () => ({
|
app.get('/api/oauth/providers', { preHandler: [limitOauthProviderRead] }, async () => ({
|
||||||
providers: listOauthProviders(),
|
providers: listOauthProviders(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.post<{ Params: { provider: string }; Body: { accountId?: number } }>(
|
app.post<{ Params: { provider: string }; Body: { accountId?: number; projectId?: string } }>(
|
||||||
'/api/oauth/providers/:provider/start',
|
'/api/oauth/providers/:provider/start',
|
||||||
|
{ preHandler: [limitOauthStart] },
|
||||||
async (request, reply) => {
|
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 {
|
try {
|
||||||
return startOauthProviderFlow({
|
return await startOauthProviderFlow({
|
||||||
provider: request.params.provider,
|
provider: request.params.provider,
|
||||||
redirectOrigin: resolveRequestOrigin(request),
|
rebindAccountId: rebindAccountId ?? undefined,
|
||||||
rebindAccountId: request.body?.accountId,
|
projectId: projectId ?? undefined,
|
||||||
|
requestOrigin: resolveRequestOrigin(request),
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return reply.code(404).send({ message: error?.message || 'oauth provider not found' });
|
return reply.code(404).send({ message: error?.message || 'oauth provider not found' });
|
||||||
@@ -43,23 +130,52 @@ export async function oauthRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
app.get<{ Params: { state: string } }>('/api/oauth/sessions/:state', async (request, reply) => {
|
app.get<{ Params: { state: string } }>(
|
||||||
|
'/api/oauth/sessions/:state',
|
||||||
|
{ preHandler: [limitOauthSessionRead] },
|
||||||
|
async (request, reply) => {
|
||||||
const session = getOauthSessionStatus(request.params.state);
|
const session = getOauthSessionStatus(request.params.state);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return reply.code(404).send({ message: 'oauth session not found' });
|
return reply.code(404).send({ message: 'oauth session not found' });
|
||||||
}
|
}
|
||||||
return session;
|
return session;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.get('/api/oauth/connections', async () => ({
|
app.get<{ Querystring: { limit?: string; offset?: string } }>(
|
||||||
items: await listOauthConnections(),
|
'/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 } }>(
|
app.post<{ Params: { accountId: string } }>(
|
||||||
'/api/oauth/connections/:accountId/rebind',
|
'/api/oauth/connections/:accountId/rebind',
|
||||||
|
{ preHandler: [limitOauthConnectionMutate] },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
|
const accountId = parsePositiveInteger(request.params.accountId);
|
||||||
|
if (accountId === null) {
|
||||||
|
return reply.code(400).send({ message: 'invalid account id' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const accountId = Number.parseInt(request.params.accountId, 10);
|
|
||||||
return await startOauthRebindFlow(accountId, resolveRequestOrigin(request));
|
return await startOauthRebindFlow(accountId, resolveRequestOrigin(request));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return reply.code(404).send({ message: error?.message || 'oauth account not found' });
|
return reply.code(404).send({ message: error?.message || 'oauth account not found' });
|
||||||
@@ -69,9 +185,13 @@ export async function oauthRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
app.delete<{ Params: { accountId: string } }>(
|
app.delete<{ Params: { accountId: string } }>(
|
||||||
'/api/oauth/connections/:accountId',
|
'/api/oauth/connections/:accountId',
|
||||||
|
{ preHandler: [limitOauthConnectionMutate] },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
|
const accountId = parsePositiveInteger(request.params.accountId);
|
||||||
|
if (accountId === null) {
|
||||||
|
return reply.code(400).send({ message: 'invalid account id' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const accountId = Number.parseInt(request.params.accountId, 10);
|
|
||||||
return await deleteOauthConnection(accountId);
|
return await deleteOauthConnection(accountId);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return reply.code(404).send({ message: error?.message || 'oauth account not found' });
|
return reply.code(404).send({ message: error?.message || 'oauth account not found' });
|
||||||
@@ -81,6 +201,7 @@ export async function oauthRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
app.get<{ Params: { provider: string }; Querystring: { state?: string; code?: string; error?: string } }>(
|
app.get<{ Params: { provider: string }; Querystring: { state?: string; code?: string; error?: string } }>(
|
||||||
'/api/oauth/callback/:provider',
|
'/api/oauth/callback/:provider',
|
||||||
|
{ preHandler: [limitOauthCallback] },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
let message = 'OAuth callback received.';
|
let message = 'OAuth callback received.';
|
||||||
try {
|
try {
|
||||||
@@ -91,21 +212,12 @@ export async function oauthRoutes(app: FastifyInstance) {
|
|||||||
error: request.query.error,
|
error: request.query.error,
|
||||||
});
|
});
|
||||||
message = 'OAuth authorization succeeded. You can close this window.';
|
message = 'OAuth authorization succeeded. You can close this window.';
|
||||||
} catch (error: any) {
|
} catch {
|
||||||
message = `OAuth authorization failed: ${error?.message || 'unknown error'}`;
|
message = 'OAuth authorization failed. Return to metapi and review the server logs.';
|
||||||
}
|
}
|
||||||
|
|
||||||
reply.type('text/html; charset=utf-8');
|
reply.type('text/html; charset=utf-8');
|
||||||
return `<!doctype html>
|
return renderCallbackPage(message);
|
||||||
<html lang="zh-CN">
|
},
|
||||||
<head>
|
);
|
||||||
<meta charset="utf-8" />
|
|
||||||
<title>OAuth Callback</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<script>window.close();</script>
|
|
||||||
${message}
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
parseStoredUtcDateTime,
|
parseStoredUtcDateTime,
|
||||||
toLocalDayKeyFromStoredUtc,
|
toLocalDayKeyFromStoredUtc,
|
||||||
} from '../../services/localTimeService.js';
|
} from '../../services/localTimeService.js';
|
||||||
|
import { createRateLimitGuard } from '../../middleware/requestRateLimit.js';
|
||||||
|
|
||||||
function parseBooleanFlag(raw?: string): boolean {
|
function parseBooleanFlag(raw?: string): boolean {
|
||||||
if (!raw) return false;
|
if (!raw) return false;
|
||||||
@@ -36,6 +37,11 @@ function parseBooleanFlag(raw?: string): boolean {
|
|||||||
|
|
||||||
const MODELS_MARKETPLACE_BASE_TTL_MS = 15_000;
|
const MODELS_MARKETPLACE_BASE_TTL_MS = 15_000;
|
||||||
const MODELS_MARKETPLACE_PRICING_TTL_MS = 90_000;
|
const MODELS_MARKETPLACE_PRICING_TTL_MS = 90_000;
|
||||||
|
const limitModelTokenCandidatesRead = createRateLimitGuard({
|
||||||
|
bucket: 'models-token-candidates-read',
|
||||||
|
max: 30,
|
||||||
|
windowMs: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
type ModelsMarketplaceCacheEntry = {
|
type ModelsMarketplaceCacheEntry = {
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
@@ -970,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 resolveTokenGroupLabel = (tokenGroup: string | null, tokenName: string | null): string | null => {
|
||||||
const explicit = (tokenGroup || '').trim();
|
const explicit = (tokenGroup || '').trim();
|
||||||
if (explicit) return explicit;
|
if (explicit) return explicit;
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
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 { 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: '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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,9 +33,15 @@ import {
|
|||||||
resolveOpenAiBodyInputFiles,
|
resolveOpenAiBodyInputFiles,
|
||||||
} from '../../services/proxyInputFileResolver.js';
|
} from '../../services/proxyInputFileResolver.js';
|
||||||
import {
|
import {
|
||||||
buildCodexOauthProviderHeaders,
|
buildOauthProviderHeaders,
|
||||||
refreshCodexOauthAccessToken,
|
|
||||||
} from '../../services/oauth/service.js';
|
} from '../../services/oauth/service.js';
|
||||||
|
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
|
||||||
|
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js';
|
||||||
|
import { collectResponsesFinalPayloadFromSse } from './responsesSseFinal.js';
|
||||||
|
import {
|
||||||
|
createGeminiCliStreamReader,
|
||||||
|
unwrapGeminiCliPayload,
|
||||||
|
} from './geminiCliCompat.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
@@ -120,6 +126,8 @@ async function handleChatProxyRequest(
|
|||||||
excludeChannelIds.push(selected.channel.id);
|
excludeChannelIds.push(selected.channel.id);
|
||||||
|
|
||||||
const modelName = selected.actualModel || requestedModel;
|
const modelName = selected.actualModel || requestedModel;
|
||||||
|
const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
|
||||||
|
const isCodexSite = String(selected.site.platform || '').trim().toLowerCase() === 'codex';
|
||||||
const endpointCandidates = [
|
const endpointCandidates = [
|
||||||
...await resolveUpstreamEndpointCandidates(
|
...await resolveUpstreamEndpointCandidates(
|
||||||
{
|
{
|
||||||
@@ -135,22 +143,23 @@ async function handleChatProxyRequest(
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
const buildProviderHeaders = () => (
|
const buildProviderHeaders = () => (
|
||||||
String(selected.site.platform || '').trim().toLowerCase() === 'codex'
|
buildOauthProviderHeaders({
|
||||||
? buildCodexOauthProviderHeaders({
|
|
||||||
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
||||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||||
})
|
})
|
||||||
: {}
|
|
||||||
);
|
);
|
||||||
const buildEndpointRequest = (
|
const buildEndpointRequest = (
|
||||||
endpoint: 'chat' | 'messages' | 'responses',
|
endpoint: 'chat' | 'messages' | 'responses',
|
||||||
options: { forceNormalizeClaudeBody?: boolean } = {},
|
options: { forceNormalizeClaudeBody?: boolean } = {},
|
||||||
) => {
|
) => {
|
||||||
|
const upstreamStream = isStream || (isCodexSite && endpoint === 'responses');
|
||||||
const endpointRequest = buildUpstreamEndpointRequest({
|
const endpointRequest = buildUpstreamEndpointRequest({
|
||||||
endpoint,
|
endpoint,
|
||||||
modelName,
|
modelName,
|
||||||
stream: isStream,
|
stream: upstreamStream,
|
||||||
tokenValue: selected.tokenValue,
|
tokenValue: selected.tokenValue,
|
||||||
|
oauthProvider: oauth?.provider,
|
||||||
|
oauthProjectId: oauth?.projectId,
|
||||||
sitePlatform: selected.site.platform,
|
sitePlatform: selected.site.platform,
|
||||||
siteUrl: selected.site.url,
|
siteUrl: selected.site.url,
|
||||||
openaiBody: resolvedOpenAiBody,
|
openaiBody: resolvedOpenAiBody,
|
||||||
@@ -173,7 +182,7 @@ async function handleChatProxyRequest(
|
|||||||
modelName,
|
modelName,
|
||||||
requestedModelHint: requestedModel,
|
requestedModelHint: requestedModel,
|
||||||
sitePlatform: selected.site.platform,
|
sitePlatform: selected.site.platform,
|
||||||
isStream,
|
isStream: isStream || isCodexSite,
|
||||||
buildRequest: ({ endpoint, forceNormalizeClaudeBody }) => buildEndpointRequest(
|
buildRequest: ({ endpoint, forceNormalizeClaudeBody }) => buildEndpointRequest(
|
||||||
endpoint,
|
endpoint,
|
||||||
{ forceNormalizeClaudeBody },
|
{ forceNormalizeClaudeBody },
|
||||||
@@ -188,8 +197,9 @@ async function handleChatProxyRequest(
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
const tryRecover = async (ctx: Parameters<NonNullable<typeof endpointStrategy.tryRecover>>[0]) => {
|
const tryRecover = async (ctx: Parameters<NonNullable<typeof endpointStrategy.tryRecover>>[0]) => {
|
||||||
if (ctx.response.status === 401 && String(selected.site.platform || '').trim().toLowerCase() === 'codex') {
|
if (ctx.response.status === 401 && oauth) {
|
||||||
const refreshed = await refreshCodexOauthAccessToken(selected.account.id);
|
try {
|
||||||
|
const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id);
|
||||||
selected.tokenValue = refreshed.accessToken;
|
selected.tokenValue = refreshed.accessToken;
|
||||||
selected.account = {
|
selected.account = {
|
||||||
...selected.account,
|
...selected.account,
|
||||||
@@ -215,6 +225,9 @@ async function handleChatProxyRequest(
|
|||||||
ctx.request = refreshedRequest;
|
ctx.request = refreshedRequest;
|
||||||
ctx.response = refreshedResponse;
|
ctx.response = refreshedResponse;
|
||||||
ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error');
|
ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error');
|
||||||
|
} catch {
|
||||||
|
return endpointStrategy.tryRecover(ctx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return endpointStrategy.tryRecover(ctx);
|
return endpointStrategy.tryRecover(ctx);
|
||||||
};
|
};
|
||||||
@@ -345,6 +358,9 @@ async function handleChatProxyRequest(
|
|||||||
} catch {
|
} catch {
|
||||||
fallbackData = fallbackText;
|
fallbackData = fallbackText;
|
||||||
}
|
}
|
||||||
|
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
|
||||||
|
fallbackData = unwrapGeminiCliPayload(fallbackData);
|
||||||
|
}
|
||||||
streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw);
|
streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw);
|
||||||
|
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
@@ -395,7 +411,10 @@ async function handleChatProxyRequest(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = upstream.body?.getReader();
|
const upstreamReader = upstream.body?.getReader();
|
||||||
|
const reader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader
|
||||||
|
? createGeminiCliStreamReader(upstreamReader)
|
||||||
|
: upstreamReader;
|
||||||
await streamSession.run(reader, reply.raw);
|
await streamSession.run(reader, reply.raw);
|
||||||
|
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
@@ -446,13 +465,25 @@ async function handleChatProxyRequest(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawText = await upstream.text();
|
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
|
||||||
let upstreamData: unknown = rawText;
|
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 {
|
try {
|
||||||
upstreamData = JSON.parse(rawText);
|
upstreamData = JSON.parse(rawText);
|
||||||
} catch {
|
} catch {
|
||||||
upstreamData = rawText;
|
upstreamData = rawText;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
|
||||||
|
upstreamData = unwrapGeminiCliPayload(upstreamData);
|
||||||
|
}
|
||||||
|
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
const parsedUsage = parseProxyUsage(upstreamData);
|
const parsedUsage = parseProxyUsage(upstreamData);
|
||||||
|
|||||||
@@ -210,6 +210,52 @@ describe('gemini native proxy routes', () => {
|
|||||||
expect(secondUrl).toContain('key=gemini-key-2');
|
expect(secondUrl).toContain('key=gemini-key-2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('serves gemini-cli model list from local static catalog without upstream fetch', async () => {
|
||||||
|
selectChannelMock.mockReturnValue({
|
||||||
|
channel: { id: 21, routeId: 22 },
|
||||||
|
site: { id: 55, name: 'gemini-cli-site', url: 'https://cloudcode-pa.googleapis.com', platform: 'gemini-cli' },
|
||||||
|
account: {
|
||||||
|
id: 35,
|
||||||
|
username: 'gemini-cli-user@example.com',
|
||||||
|
extraConfig: JSON.stringify({
|
||||||
|
credentialMode: 'session',
|
||||||
|
oauth: {
|
||||||
|
provider: 'gemini-cli',
|
||||||
|
email: 'gemini-cli-user@example.com',
|
||||||
|
projectId: 'project-demo',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
tokenName: 'default',
|
||||||
|
tokenValue: 'oauth-access-token',
|
||||||
|
actualModel: 'gemini-2.5-pro',
|
||||||
|
});
|
||||||
|
explainSelectionMock.mockImplementation(async (modelName: string) => (
|
||||||
|
modelName === 'gemini-2.5-pro'
|
||||||
|
? { selectedChannelId: 21 }
|
||||||
|
: { selectedChannelId: undefined }
|
||||||
|
));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/v1beta/models',
|
||||||
|
headers: {
|
||||||
|
authorization: 'Bearer sk-managed-gemini',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
models: expect.arrayContaining([
|
||||||
|
{
|
||||||
|
name: 'models/gemini-2.5-pro',
|
||||||
|
displayName: 'Gemini 2.5 Pro',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('forwards native generateContent requests through the gemini route group', async () => {
|
it('forwards native generateContent requests through the gemini route group', async () => {
|
||||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||||
candidates: [
|
candidates: [
|
||||||
@@ -270,6 +316,95 @@ describe('gemini native proxy routes', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('wraps gemini-cli native generateContent requests and unwraps the response payload', async () => {
|
||||||
|
selectChannelMock.mockReturnValue({
|
||||||
|
channel: { id: 31, routeId: 22 },
|
||||||
|
site: { id: 66, name: 'gemini-cli-site', url: 'https://cloudcode-pa.googleapis.com', platform: 'gemini-cli' },
|
||||||
|
account: {
|
||||||
|
id: 36,
|
||||||
|
username: 'gemini-cli-user@example.com',
|
||||||
|
extraConfig: JSON.stringify({
|
||||||
|
credentialMode: 'session',
|
||||||
|
oauth: {
|
||||||
|
provider: 'gemini-cli',
|
||||||
|
email: 'gemini-cli-user@example.com',
|
||||||
|
projectId: 'project-demo',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
tokenName: 'default',
|
||||||
|
tokenValue: 'oauth-access-token',
|
||||||
|
actualModel: 'gemini-2.5-pro',
|
||||||
|
});
|
||||||
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
response: {
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
parts: [{ text: 'hello from gemini cli' }],
|
||||||
|
role: 'model',
|
||||||
|
},
|
||||||
|
finishReason: 'STOP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1beta/models/gemini-2.5-pro:generateContent',
|
||||||
|
headers: {
|
||||||
|
authorization: 'Bearer sk-managed-gemini',
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ text: 'hello' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(targetUrl).toBe('https://cloudcode-pa.googleapis.com/v1internal:generateContent');
|
||||||
|
expect(requestInit.headers).toMatchObject({
|
||||||
|
Authorization: 'Bearer oauth-access-token',
|
||||||
|
});
|
||||||
|
expect((requestInit.headers as Record<string, string>)['User-Agent']).toContain('GeminiCLI/');
|
||||||
|
expect((requestInit.headers as Record<string, string>)['X-Goog-Api-Client']).toContain('google-genai-sdk/');
|
||||||
|
expect(JSON.parse(String(requestInit.body))).toEqual({
|
||||||
|
project: 'project-demo',
|
||||||
|
model: 'gemini-2.5-pro',
|
||||||
|
request: {
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ text: 'hello' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response.json()).toEqual({
|
||||||
|
responseId: '',
|
||||||
|
modelVersion: '',
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
content: {
|
||||||
|
parts: [{ text: 'hello from gemini cli' }],
|
||||||
|
role: 'model',
|
||||||
|
},
|
||||||
|
finishReason: 'STOP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('filters Gemini native model list by downstream managed-key policy', async () => {
|
it('filters Gemini native model list by downstream managed-key policy', async () => {
|
||||||
authorizeDownstreamTokenMock.mockResolvedValue({
|
authorizeDownstreamTokenMock.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -6,11 +6,18 @@ import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
|||||||
import { parseProxyUsage } from '../../services/proxyUsageParser.js';
|
import { parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||||
import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js';
|
import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
|
import { buildOauthProviderHeaders } from '../../services/oauth/service.js';
|
||||||
|
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
|
||||||
import { getDownstreamRoutingPolicy } from './downstreamPolicy.js';
|
import { getDownstreamRoutingPolicy } from './downstreamPolicy.js';
|
||||||
import { composeProxyLogMessage } from './logPathMeta.js';
|
import { composeProxyLogMessage } from './logPathMeta.js';
|
||||||
import {
|
import {
|
||||||
geminiGenerateContentTransformer,
|
geminiGenerateContentTransformer,
|
||||||
} from '../../transformers/gemini/generate-content/index.js';
|
} from '../../transformers/gemini/generate-content/index.js';
|
||||||
|
import {
|
||||||
|
createGeminiCliStreamReader,
|
||||||
|
unwrapGeminiCliPayload,
|
||||||
|
wrapGeminiCliRequest,
|
||||||
|
} from './geminiCliCompat.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
const GEMINI_MODEL_PROBES = [
|
const GEMINI_MODEL_PROBES = [
|
||||||
@@ -19,12 +26,34 @@ const GEMINI_MODEL_PROBES = [
|
|||||||
'gemini-1.5-flash',
|
'gemini-1.5-flash',
|
||||||
'gemini-pro',
|
'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 = {
|
const EMPTY_PROXY_USAGE = {
|
||||||
promptTokens: 0,
|
promptTokens: 0,
|
||||||
completionTokens: 0,
|
completionTokens: 0,
|
||||||
totalTokens: 0,
|
totalTokens: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isGeminiCliPlatform(platform: unknown): boolean {
|
||||||
|
return String(platform || '').trim().toLowerCase() === 'gemini-cli';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGeminiCliActionPath(input: {
|
||||||
|
isStreamAction: boolean;
|
||||||
|
isCountTokensAction: boolean;
|
||||||
|
}) {
|
||||||
|
if (input.isCountTokensAction) return '/v1internal:countTokens';
|
||||||
|
if (input.isStreamAction) return '/v1internal:streamGenerateContent?alt=sse';
|
||||||
|
return '/v1internal:generateContent';
|
||||||
|
}
|
||||||
|
|
||||||
async function selectGeminiChannel(request: FastifyRequest) {
|
async function selectGeminiChannel(request: FastifyRequest) {
|
||||||
const policy = getDownstreamRoutingPolicy(request);
|
const policy = getDownstreamRoutingPolicy(request);
|
||||||
for (const candidate of GEMINI_MODEL_PROBES) {
|
for (const candidate of GEMINI_MODEL_PROBES) {
|
||||||
@@ -165,6 +194,14 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
excludeChannelIds.push(selected.channel.id);
|
excludeChannelIds.push(selected.channel.id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
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(
|
const upstream = await fetch(
|
||||||
geminiGenerateContentTransformer.resolveModelsUrl(selected.site.url, apiVersion, selected.tokenValue),
|
geminiGenerateContentTransformer.resolveModelsUrl(selected.site.url, apiVersion, selected.tokenValue),
|
||||||
{ method: 'GET' },
|
{ method: 'GET' },
|
||||||
@@ -212,6 +249,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
params: request.params as { geminiApiVersion?: string } | undefined,
|
params: request.params as { geminiApiVersion?: string } | undefined,
|
||||||
});
|
});
|
||||||
const { apiVersion, modelActionPath, isStreamAction, requestedModel } = parsedPath;
|
const { apiVersion, modelActionPath, isStreamAction, requestedModel } = parsedPath;
|
||||||
|
const isCountTokensAction = modelActionPath.endsWith(':countTokens');
|
||||||
if (!requestedModel) {
|
if (!requestedModel) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: { message: 'Gemini model path is required', type: 'invalid_request_error' },
|
error: { message: 'Gemini model path is required', type: 'invalid_request_error' },
|
||||||
@@ -240,31 +278,72 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
request.body || {},
|
request.body || {},
|
||||||
selected.actualModel || requestedModel,
|
selected.actualModel || requestedModel,
|
||||||
);
|
);
|
||||||
|
const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
|
||||||
|
const isGeminiCli = isGeminiCliPlatform(selected.site.platform);
|
||||||
|
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(
|
const actualModelAction = modelActionPath.replace(
|
||||||
/^models\/[^:]+/,
|
/^models\/[^:]+/,
|
||||||
`models/${selected.actualModel || requestedModel}`,
|
`models/${selected.actualModel || requestedModel}`,
|
||||||
);
|
);
|
||||||
const upstreamPath = resolveUpstreamPath(apiVersion, actualModelAction);
|
const upstreamPath = isGeminiCli
|
||||||
|
? buildGeminiCliActionPath({ isStreamAction, isCountTokensAction })
|
||||||
|
: resolveUpstreamPath(apiVersion, actualModelAction);
|
||||||
const query = new URLSearchParams(request.query as Record<string, string>).toString();
|
const query = new URLSearchParams(request.query as Record<string, string>).toString();
|
||||||
|
const requestBody = isGeminiCli
|
||||||
|
? (
|
||||||
|
isCountTokensAction
|
||||||
|
? { request: body }
|
||||||
|
: wrapGeminiCliRequest({
|
||||||
|
modelName: selected.actualModel || requestedModel,
|
||||||
|
projectId: oauth?.projectId || '',
|
||||||
|
request: body as Record<string, unknown>,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
: body;
|
||||||
|
const requestHeaders = isGeminiCli
|
||||||
|
? {
|
||||||
|
'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 startTime = Date.now();
|
const startTime = Date.now();
|
||||||
try {
|
try {
|
||||||
const upstream = await fetch(
|
const targetUrl = isGeminiCli
|
||||||
geminiGenerateContentTransformer.resolveActionUrl(
|
? `${selected.site.url}${upstreamPath}`
|
||||||
|
: geminiGenerateContentTransformer.resolveActionUrl(
|
||||||
selected.site.url,
|
selected.site.url,
|
||||||
apiVersion,
|
apiVersion,
|
||||||
actualModelAction,
|
actualModelAction,
|
||||||
selected.tokenValue,
|
selected.tokenValue,
|
||||||
query,
|
query,
|
||||||
),
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
const upstream = await fetch(targetUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: requestHeaders,
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
const contentType = upstream.headers.get('content-type') || 'application/json';
|
const contentType = upstream.headers.get('content-type') || 'application/json';
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
lastStatus = upstream.status;
|
lastStatus = upstream.status;
|
||||||
@@ -298,7 +377,10 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
reply.hijack();
|
reply.hijack();
|
||||||
reply.raw.statusCode = upstream.status;
|
reply.raw.statusCode = upstream.status;
|
||||||
reply.raw.setHeader('Content-Type', contentType || 'text/event-stream');
|
reply.raw.setHeader('Content-Type', contentType || 'text/event-stream');
|
||||||
const reader = upstream.body?.getReader();
|
const upstreamReader = upstream.body?.getReader();
|
||||||
|
const reader = isGeminiCliPlatform(selected.site.platform) && upstreamReader
|
||||||
|
? createGeminiCliStreamReader(upstreamReader)
|
||||||
|
: upstreamReader;
|
||||||
if (!reader) {
|
if (!reader) {
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
|
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
|
||||||
@@ -373,9 +455,12 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
let parsedUsage = EMPTY_PROXY_USAGE;
|
let parsedUsage = EMPTY_PROXY_USAGE;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(text);
|
const parsed = JSON.parse(text);
|
||||||
const responsePayload = geminiGenerateContentTransformer.stream.serializeUpstreamJsonPayload(
|
const unwrappedPayload = unwrapGeminiCliPayload(parsed);
|
||||||
|
const responsePayload = isCountTokensAction
|
||||||
|
? unwrappedPayload
|
||||||
|
: geminiGenerateContentTransformer.stream.serializeUpstreamJsonPayload(
|
||||||
aggregateState,
|
aggregateState,
|
||||||
parsed,
|
unwrappedPayload,
|
||||||
isStreamAction,
|
isStreamAction,
|
||||||
);
|
);
|
||||||
parsedUsage = parseProxyUsage(aggregateState);
|
parsedUsage = parseProxyUsage(aggregateState);
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import { TextDecoder, TextEncoder } from 'node:util';
|
||||||
|
import { resolveGeminiThinkingConfigFromRequest } from '../../transformers/gemini/generate-content/convert.js';
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 parts = convertContentToGeminiParts(message.content);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
parts.push({
|
||||||
|
functionCall: {
|
||||||
|
name,
|
||||||
|
args,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (parts.length <= 0) continue;
|
||||||
|
request.contents = [
|
||||||
|
...(Array.isArray(request.contents) ? request.contents : []),
|
||||||
|
{
|
||||||
|
role: role === 'assistant' ? 'model' : 'user',
|
||||||
|
parts,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) =>
|
|||||||
estimatedCostFromQuota: 0,
|
estimatedCostFromQuota: 0,
|
||||||
recoveredFromSelfLog: false,
|
recoveredFromSelfLog: false,
|
||||||
}));
|
}));
|
||||||
const refreshCodexAccessTokenMock = vi.fn();
|
const refreshOauthAccessTokenSingleflightMock = vi.fn();
|
||||||
const dbInsertMock = vi.fn((_arg?: any) => ({
|
const dbInsertMock = vi.fn((_arg?: any) => ({
|
||||||
values: () => ({
|
values: () => ({
|
||||||
run: () => undefined,
|
run: () => undefined,
|
||||||
@@ -61,13 +61,9 @@ vi.mock('../../services/proxyUsageFallbackService.js', () => ({
|
|||||||
resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg),
|
resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../services/oauth/service.js', async () => {
|
vi.mock('../../services/oauth/refreshSingleflight.js', () => ({
|
||||||
const actual = await vi.importActual<typeof import('../../services/oauth/service.js')>('../../services/oauth/service.js');
|
refreshOauthAccessTokenSingleflight: (...args: unknown[]) => refreshOauthAccessTokenSingleflightMock(...args),
|
||||||
return {
|
}));
|
||||||
...actual,
|
|
||||||
refreshCodexOauthAccessToken: (...args: unknown[]) => refreshCodexAccessTokenMock(...args),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('../../db/index.js', () => ({
|
vi.mock('../../db/index.js', () => ({
|
||||||
db: {
|
db: {
|
||||||
@@ -82,6 +78,21 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
describe('responses proxy codex oauth refresh', () => {
|
describe('responses proxy codex oauth refresh', () => {
|
||||||
let app: FastifyInstance;
|
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 () => {
|
beforeAll(async () => {
|
||||||
const { responsesProxyRoute } = await import('./responses.js');
|
const { responsesProxyRoute } = await import('./responses.js');
|
||||||
app = Fastify();
|
app = Fastify();
|
||||||
@@ -98,7 +109,7 @@ describe('responses proxy codex oauth refresh', () => {
|
|||||||
reportProxyAllFailedMock.mockReset();
|
reportProxyAllFailedMock.mockReset();
|
||||||
reportTokenExpiredMock.mockReset();
|
reportTokenExpiredMock.mockReset();
|
||||||
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
|
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
|
||||||
refreshCodexAccessTokenMock.mockReset();
|
refreshOauthAccessTokenSingleflightMock.mockReset();
|
||||||
dbInsertMock.mockClear();
|
dbInsertMock.mockClear();
|
||||||
|
|
||||||
selectChannelMock.mockReturnValue({
|
selectChannelMock.mockReturnValue({
|
||||||
@@ -122,7 +133,7 @@ describe('responses proxy codex oauth refresh', () => {
|
|||||||
actualModel: 'gpt-5.2-codex',
|
actualModel: 'gpt-5.2-codex',
|
||||||
});
|
});
|
||||||
selectNextChannelMock.mockReturnValue(null);
|
selectNextChannelMock.mockReturnValue(null);
|
||||||
refreshCodexAccessTokenMock.mockResolvedValue({
|
refreshOauthAccessTokenSingleflightMock.mockResolvedValue({
|
||||||
accessToken: 'fresh-access-token',
|
accessToken: 'fresh-access-token',
|
||||||
accountId: 33,
|
accountId: 33,
|
||||||
accountKey: 'chatgpt-account-123',
|
accountKey: 'chatgpt-account-123',
|
||||||
@@ -163,7 +174,7 @@ describe('responses proxy codex oauth refresh', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(refreshCodexAccessTokenMock).toHaveBeenCalledWith(33);
|
expect(refreshOauthAccessTokenSingleflightMock).toHaveBeenCalledWith(33);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
const [firstUrl, firstOptions] = fetchMock.mock.calls[0] as [string, any];
|
const [firstUrl, firstOptions] = fetchMock.mock.calls[0] as [string, any];
|
||||||
@@ -205,6 +216,7 @@ describe('responses proxy codex oauth refresh', () => {
|
|||||||
const [, options] = fetchMock.mock.calls[0] as [string, any];
|
const [, options] = fetchMock.mock.calls[0] as [string, any];
|
||||||
const forwardedBody = JSON.parse(options.body);
|
const forwardedBody = JSON.parse(options.body);
|
||||||
expect(forwardedBody.instructions).toBe('');
|
expect(forwardedBody.instructions).toBe('');
|
||||||
|
expect(forwardedBody.stream).toBe(true);
|
||||||
expect(forwardedBody.input).toEqual([
|
expect(forwardedBody.input).toEqual([
|
||||||
{
|
{
|
||||||
type: 'message',
|
type: 'message',
|
||||||
@@ -213,4 +225,82 @@ describe('responses proxy codex oauth refresh', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,9 +28,15 @@ import {
|
|||||||
resolveResponsesBodyInputFiles,
|
resolveResponsesBodyInputFiles,
|
||||||
} from '../../services/proxyInputFileResolver.js';
|
} from '../../services/proxyInputFileResolver.js';
|
||||||
import {
|
import {
|
||||||
buildCodexOauthProviderHeaders,
|
buildOauthProviderHeaders,
|
||||||
refreshCodexOauthAccessToken,
|
|
||||||
} from '../../services/oauth/service.js';
|
} from '../../services/oauth/service.js';
|
||||||
|
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
|
||||||
|
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js';
|
||||||
|
import { collectResponsesFinalPayloadFromSse } from './responsesSseFinal.js';
|
||||||
|
import {
|
||||||
|
createGeminiCliStreamReader,
|
||||||
|
unwrapGeminiCliPayload,
|
||||||
|
} from './geminiCliCompat.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
@@ -180,6 +186,8 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
excludeChannelIds.push(selected.channel.id);
|
excludeChannelIds.push(selected.channel.id);
|
||||||
|
|
||||||
const modelName = selected.actualModel || requestedModel;
|
const modelName = selected.actualModel || requestedModel;
|
||||||
|
const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
|
||||||
|
const isCodexSite = String(selected.site.platform || '').trim().toLowerCase() === 'codex';
|
||||||
const owner = getProxyResourceOwner(request);
|
const owner = getProxyResourceOwner(request);
|
||||||
let normalizedResponsesBody: Record<string, unknown> = {
|
let normalizedResponsesBody: Record<string, unknown> = {
|
||||||
...requestEnvelope.parsed.normalizedBody,
|
...requestEnvelope.parsed.normalizedBody,
|
||||||
@@ -233,19 +241,20 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
endpointCandidates.push('responses', 'chat', 'messages');
|
endpointCandidates.push('responses', 'chat', 'messages');
|
||||||
}
|
}
|
||||||
const buildProviderHeaders = () => (
|
const buildProviderHeaders = () => (
|
||||||
String(selected.site.platform || '').trim().toLowerCase() === 'codex'
|
buildOauthProviderHeaders({
|
||||||
? buildCodexOauthProviderHeaders({
|
|
||||||
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
||||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||||
})
|
})
|
||||||
: {}
|
|
||||||
);
|
);
|
||||||
const buildEndpointRequest = (endpoint: 'chat' | 'messages' | 'responses') => {
|
const buildEndpointRequest = (endpoint: 'chat' | 'messages' | 'responses') => {
|
||||||
|
const upstreamStream = isStream || (isCodexSite && endpoint === 'responses');
|
||||||
const endpointRequest = buildUpstreamEndpointRequest({
|
const endpointRequest = buildUpstreamEndpointRequest({
|
||||||
endpoint,
|
endpoint,
|
||||||
modelName,
|
modelName,
|
||||||
stream: isStream,
|
stream: upstreamStream,
|
||||||
tokenValue: selected.tokenValue,
|
tokenValue: selected.tokenValue,
|
||||||
|
oauthProvider: oauth?.provider,
|
||||||
|
oauthProjectId: oauth?.projectId,
|
||||||
sitePlatform: selected.site.platform,
|
sitePlatform: selected.site.platform,
|
||||||
siteUrl: selected.site.url,
|
siteUrl: selected.site.url,
|
||||||
openaiBody: openAiBody,
|
openaiBody: openAiBody,
|
||||||
@@ -267,7 +276,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
const endpointStrategy = openAiResponsesTransformer.compatibility.createEndpointStrategy({
|
const endpointStrategy = openAiResponsesTransformer.compatibility.createEndpointStrategy({
|
||||||
isStream,
|
isStream: isStream || isCodexSite,
|
||||||
requiresNativeResponsesFileUrl,
|
requiresNativeResponsesFileUrl,
|
||||||
dispatchRequest: (compatibilityRequest, targetUrl) => fetch(
|
dispatchRequest: (compatibilityRequest, targetUrl) => fetch(
|
||||||
targetUrl ?? `${selected.site.url}${compatibilityRequest.path}`,
|
targetUrl ?? `${selected.site.url}${compatibilityRequest.path}`,
|
||||||
@@ -279,8 +288,9 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
const tryRecover = async (ctx: Parameters<NonNullable<typeof endpointStrategy.tryRecover>>[0]) => {
|
const tryRecover = async (ctx: Parameters<NonNullable<typeof endpointStrategy.tryRecover>>[0]) => {
|
||||||
if (ctx.response.status === 401 && String(selected.site.platform || '').trim().toLowerCase() === 'codex') {
|
if (ctx.response.status === 401 && oauth) {
|
||||||
const refreshed = await refreshCodexOauthAccessToken(selected.account.id);
|
try {
|
||||||
|
const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id);
|
||||||
selected.tokenValue = refreshed.accessToken;
|
selected.tokenValue = refreshed.accessToken;
|
||||||
selected.account = {
|
selected.account = {
|
||||||
...selected.account,
|
...selected.account,
|
||||||
@@ -306,6 +316,9 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
ctx.request = refreshedRequest;
|
ctx.request = refreshedRequest;
|
||||||
ctx.response = refreshedResponse;
|
ctx.response = refreshedResponse;
|
||||||
ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error');
|
ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error');
|
||||||
|
} catch {
|
||||||
|
return endpointStrategy.tryRecover(ctx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return endpointStrategy.tryRecover(ctx);
|
return endpointStrategy.tryRecover(ctx);
|
||||||
};
|
};
|
||||||
@@ -397,7 +410,10 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
reply.raw.setHeader('Connection', 'keep-alive');
|
reply.raw.setHeader('Connection', 'keep-alive');
|
||||||
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
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 = {
|
let parsedUsage: UsageSummary = {
|
||||||
promptTokens: 0,
|
promptTokens: 0,
|
||||||
completionTokens: 0,
|
completionTokens: 0,
|
||||||
@@ -460,13 +476,25 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawText = await upstream.text();
|
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
|
||||||
let upstreamData: unknown = rawText;
|
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 {
|
try {
|
||||||
upstreamData = JSON.parse(rawText);
|
upstreamData = JSON.parse(rawText);
|
||||||
} catch {
|
} catch {
|
||||||
upstreamData = rawText;
|
upstreamData = rawText;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
|
||||||
|
upstreamData = unwrapGeminiCliPayload(upstreamData);
|
||||||
|
}
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
const parsedUsage = parseProxyUsage(upstreamData);
|
const parsedUsage = parseProxyUsage(upstreamData);
|
||||||
const normalized = openAiResponsesTransformer.transformFinalResponse(
|
const normalized = openAiResponsesTransformer.transformFinalResponse(
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -340,6 +340,10 @@ describe('buildUpstreamEndpointRequest', () => {
|
|||||||
openaiBody: {
|
openaiBody: {
|
||||||
model: 'gpt-5.2-codex',
|
model: 'gpt-5.2-codex',
|
||||||
messages: [{ role: 'user', content: 'hello codex' }],
|
messages: [{ role: 'user', content: 'hello codex' }],
|
||||||
|
temperature: 0.2,
|
||||||
|
top_p: 0.9,
|
||||||
|
user: 'drop-me',
|
||||||
|
service_tier: 'auto',
|
||||||
},
|
},
|
||||||
downstreamFormat: 'openai',
|
downstreamFormat: 'openai',
|
||||||
providerHeaders: {
|
providerHeaders: {
|
||||||
@@ -352,6 +356,125 @@ describe('buildUpstreamEndpointRequest', () => {
|
|||||||
expect(request.headers.Authorization).toBe('Bearer oauth-access-token');
|
expect(request.headers.Authorization).toBe('Bearer oauth-access-token');
|
||||||
expect(request.headers.Originator).toBe('codex_cli_rs');
|
expect(request.headers.Originator).toBe('codex_cli_rs');
|
||||||
expect(request.headers['Chatgpt-Account-Id']).toBe('chatgpt-account-123');
|
expect(request.headers['Chatgpt-Account-Id']).toBe('chatgpt-account-123');
|
||||||
|
expect(request.body.instructions).toBe('');
|
||||||
|
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('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']).toContain('GeminiCLI/');
|
||||||
|
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('uses bearer auth for claude oauth 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.path).toBe('/v1/messages');
|
||||||
|
expect(request.headers.Authorization).toBe('Bearer oauth-access-token');
|
||||||
|
expect(request.headers['x-api-key']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
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', () => {
|
it('normalizes downstream responses input string before forwarding upstream', () => {
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import {
|
|||||||
convertOpenAiBodyToAnthropicMessagesBody,
|
convertOpenAiBodyToAnthropicMessagesBody,
|
||||||
sanitizeAnthropicMessagesBody,
|
sanitizeAnthropicMessagesBody,
|
||||||
} from '../../transformers/anthropic/messages/conversion.js';
|
} from '../../transformers/anthropic/messages/conversion.js';
|
||||||
|
import {
|
||||||
|
buildGeminiGenerateContentRequestFromOpenAi,
|
||||||
|
wrapGeminiCliRequest,
|
||||||
|
} from './geminiCliCompat.js';
|
||||||
export {
|
export {
|
||||||
buildMinimalJsonHeadersForCompatibility,
|
buildMinimalJsonHeadersForCompatibility,
|
||||||
isEndpointDispatchDeniedError,
|
isEndpointDispatchDeniedError,
|
||||||
@@ -198,6 +202,72 @@ function ensureCodexResponsesInstructions(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
): 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',
|
||||||
|
'previous_response_id',
|
||||||
|
'prompt_cache_retention',
|
||||||
|
'safety_identifier',
|
||||||
|
]) {
|
||||||
|
delete next[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asTrimmedString(next.service_tier).toLowerCase() !== 'priority') {
|
||||||
|
delete next.service_tier;
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function normalizeEndpointTypes(value: unknown): UpstreamEndpoint[] {
|
function normalizeEndpointTypes(value: unknown): UpstreamEndpoint[] {
|
||||||
const raw = asTrimmedString(value).toLowerCase();
|
const raw = asTrimmedString(value).toLowerCase();
|
||||||
@@ -258,6 +328,10 @@ function preferredEndpointOrder(
|
|||||||
return ['chat'];
|
return ['chat'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (platform === 'gemini-cli') {
|
||||||
|
return ['chat'];
|
||||||
|
}
|
||||||
|
|
||||||
if (platform === 'openai') {
|
if (platform === 'openai') {
|
||||||
if (preferMessagesForClaudeModel && downstreamFormat !== 'responses') {
|
if (preferMessagesForClaudeModel && downstreamFormat !== 'responses') {
|
||||||
// Some OpenAI-compatible gateways expose Claude natively via /v1/messages.
|
// Some OpenAI-compatible gateways expose Claude natively via /v1/messages.
|
||||||
@@ -438,6 +512,8 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
modelName: string;
|
modelName: string;
|
||||||
stream: boolean;
|
stream: boolean;
|
||||||
tokenValue: string;
|
tokenValue: string;
|
||||||
|
oauthProvider?: string;
|
||||||
|
oauthProjectId?: string;
|
||||||
sitePlatform?: string;
|
sitePlatform?: string;
|
||||||
siteUrl?: string;
|
siteUrl?: string;
|
||||||
openaiBody: Record<string, unknown>;
|
openaiBody: Record<string, unknown>;
|
||||||
@@ -455,6 +531,8 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
const sitePlatform = normalizePlatformName(input.sitePlatform);
|
const sitePlatform = normalizePlatformName(input.sitePlatform);
|
||||||
const isClaudeUpstream = sitePlatform === 'claude';
|
const isClaudeUpstream = sitePlatform === 'claude';
|
||||||
const isGeminiUpstream = sitePlatform === 'gemini';
|
const isGeminiUpstream = sitePlatform === 'gemini';
|
||||||
|
const isGeminiCliUpstream = sitePlatform === 'gemini-cli';
|
||||||
|
const isClaudeOauthUpstream = isClaudeUpstream && input.oauthProvider === 'claude';
|
||||||
|
|
||||||
const resolveGeminiEndpointPath = (endpoint: UpstreamEndpoint): string => {
|
const resolveGeminiEndpointPath = (endpoint: UpstreamEndpoint): string => {
|
||||||
const normalizedSiteUrl = asTrimmedString(input.siteUrl).toLowerCase();
|
const normalizedSiteUrl = asTrimmedString(input.siteUrl).toLowerCase();
|
||||||
@@ -484,6 +562,12 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
return '/responses';
|
return '/responses';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sitePlatform === 'gemini-cli') {
|
||||||
|
return input.stream
|
||||||
|
? '/v1internal:streamGenerateContent?alt=sse'
|
||||||
|
: '/v1internal:generateContent';
|
||||||
|
}
|
||||||
|
|
||||||
if (sitePlatform === 'claude') {
|
if (sitePlatform === 'claude') {
|
||||||
return '/v1/messages';
|
return '/v1/messages';
|
||||||
}
|
}
|
||||||
@@ -505,7 +589,7 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
|
|
||||||
const stripGeminiUnsupportedFields = (body: Record<string, unknown>) => {
|
const stripGeminiUnsupportedFields = (body: Record<string, unknown>) => {
|
||||||
const next = { ...body };
|
const next = { ...body };
|
||||||
if (isGeminiUpstream) {
|
if (isGeminiUpstream || isGeminiCliUpstream) {
|
||||||
for (const key of [
|
for (const key of [
|
||||||
'frequency_penalty',
|
'frequency_penalty',
|
||||||
'presence_penalty',
|
'presence_penalty',
|
||||||
@@ -522,6 +606,34 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
|
|
||||||
const openaiBody = stripGeminiUnsupportedFields(input.openaiBody);
|
const openaiBody = stripGeminiUnsupportedFields(input.openaiBody);
|
||||||
|
|
||||||
|
if (isGeminiCliUpstream) {
|
||||||
|
const projectId = asTrimmedString(input.oauthProjectId);
|
||||||
|
if (!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 = ensureStreamAcceptHeader(commonHeaders, input.stream);
|
||||||
|
return {
|
||||||
|
path: resolveEndpointPath(input.endpoint),
|
||||||
|
headers,
|
||||||
|
body: wrapGeminiCliRequest({
|
||||||
|
modelName: input.modelName,
|
||||||
|
projectId,
|
||||||
|
request: geminiRequest,
|
||||||
|
}) as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (input.endpoint === 'messages') {
|
if (input.endpoint === 'messages') {
|
||||||
const claudeHeaders = input.downstreamFormat === 'claude'
|
const claudeHeaders = input.downstreamFormat === 'claude'
|
||||||
? extractClaudePassthroughHeaders(input.downstreamHeaders)
|
? extractClaudePassthroughHeaders(input.downstreamHeaders)
|
||||||
@@ -562,8 +674,10 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
const headers = ensureStreamAcceptHeader({
|
const headers = ensureStreamAcceptHeader({
|
||||||
...commonHeaders,
|
...commonHeaders,
|
||||||
...claudeHeaders,
|
...claudeHeaders,
|
||||||
'x-api-key': input.tokenValue,
|
|
||||||
'anthropic-version': anthropicVersion,
|
'anthropic-version': anthropicVersion,
|
||||||
|
...(isClaudeOauthUpstream
|
||||||
|
? { Authorization: `Bearer ${input.tokenValue}` }
|
||||||
|
: { 'x-api-key': input.tokenValue }),
|
||||||
}, input.stream);
|
}, input.stream);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -586,9 +700,15 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
}
|
}
|
||||||
: convertOpenAiBodyToResponsesBodyViaTransformer(openaiBody, input.modelName, input.stream)
|
: convertOpenAiBodyToResponsesBodyViaTransformer(openaiBody, input.modelName, input.stream)
|
||||||
);
|
);
|
||||||
const body = ensureCodexResponsesInstructions(
|
const body = ensureCodexResponsesStoreFalse(
|
||||||
|
ensureCodexResponsesInstructions(
|
||||||
|
applyCodexResponsesCompatibility(
|
||||||
sanitizeResponsesBodyForProxyViaTransformer(rawBody, input.modelName, input.stream),
|
sanitizeResponsesBodyForProxyViaTransformer(rawBody, input.modelName, input.stream),
|
||||||
sitePlatform,
|
sitePlatform,
|
||||||
|
),
|
||||||
|
sitePlatform,
|
||||||
|
),
|
||||||
|
sitePlatform,
|
||||||
);
|
);
|
||||||
|
|
||||||
const headers = ensureStreamAcceptHeader({
|
const headers = ensureStreamAcceptHeader({
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
mergeAccountExtraConfig,
|
mergeAccountExtraConfig,
|
||||||
normalizeCredentialMode,
|
normalizeCredentialMode,
|
||||||
resolvePlatformUserId,
|
resolvePlatformUserId,
|
||||||
|
requiresManagedAccountTokens,
|
||||||
|
supportsDirectAccountRoutingConnection,
|
||||||
} from './accountExtraConfig.js';
|
} from './accountExtraConfig.js';
|
||||||
|
|
||||||
describe('accountExtraConfig', () => {
|
describe('accountExtraConfig', () => {
|
||||||
@@ -67,4 +69,40 @@ describe('accountExtraConfig', () => {
|
|||||||
sub2apiAuth: { refreshToken: ' ' },
|
sub2apiAuth: { refreshToken: ' ' },
|
||||||
}))).toBeNull();
|
}))).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -102,19 +102,25 @@ function hasCredentialValue(value: string | null | undefined): boolean {
|
|||||||
|
|
||||||
export function supportsDirectAccountRoutingConnection(account: DirectAccountRoutingInput): boolean {
|
export function supportsDirectAccountRoutingConnection(account: DirectAccountRoutingInput): boolean {
|
||||||
const credentialMode = getCredentialModeFromExtraConfig(account.extraConfig);
|
const credentialMode = getCredentialModeFromExtraConfig(account.extraConfig);
|
||||||
|
if (hasOauthProvider(account.extraConfig)) {
|
||||||
|
return hasCredentialValue(account.accessToken) || hasCredentialValue(account.apiToken);
|
||||||
|
}
|
||||||
if (credentialMode === 'apikey') {
|
if (credentialMode === 'apikey') {
|
||||||
return hasCredentialValue(account.apiToken);
|
return hasCredentialValue(account.apiToken);
|
||||||
}
|
}
|
||||||
if (hasOauthProvider(account.extraConfig)) {
|
if (credentialMode === 'session') {
|
||||||
return hasCredentialValue(account.accessToken);
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
if (hasCredentialValue(account.accessToken)) return false;
|
||||||
|
return hasCredentialValue(account.apiToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function requiresManagedAccountTokens(account: DirectAccountRoutingInput): boolean {
|
export function requiresManagedAccountTokens(account: DirectAccountRoutingInput): boolean {
|
||||||
const credentialMode = getCredentialModeFromExtraConfig(account.extraConfig);
|
const credentialMode = getCredentialModeFromExtraConfig(account.extraConfig);
|
||||||
if (credentialMode === 'apikey') return false;
|
|
||||||
if (hasOauthProvider(account.extraConfig)) return false;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -215,6 +215,9 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
|
|||||||
username,
|
username,
|
||||||
accessToken: accountAccessToken,
|
accessToken: accountAccessToken,
|
||||||
apiToken,
|
apiToken,
|
||||||
|
oauthProvider: null,
|
||||||
|
oauthAccountKey: null,
|
||||||
|
oauthProjectId: null,
|
||||||
balance: importedBalance,
|
balance: importedBalance,
|
||||||
balanceUsed: importedUsed,
|
balanceUsed: importedUsed,
|
||||||
quota: importedQuota > 0 ? importedQuota : importedBalance,
|
quota: importedQuota > 0 ? importedQuota : importedBalance,
|
||||||
|
|||||||
@@ -19,12 +19,40 @@ import { invalidateTokenRouterCache } from './tokenRouter.js';
|
|||||||
import { setAccountRuntimeHealth } from './accountHealthService.js';
|
import { setAccountRuntimeHealth } from './accountHealthService.js';
|
||||||
import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js';
|
import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js';
|
||||||
import { withSiteRecordProxyRequestInit } from './siteProxy.js';
|
import { withSiteRecordProxyRequestInit } from './siteProxy.js';
|
||||||
import { buildCodexOauthInfo, getCodexOauthInfoFromExtraConfig, isCodexPlatform } from './oauth/codexAccount.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 {
|
||||||
|
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 API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
|
||||||
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
|
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
|
||||||
const MODEL_REFRESH_BATCH_SIZE = 3;
|
const MODEL_REFRESH_BATCH_SIZE = 3;
|
||||||
const CODEX_MODELS_CLIENT_VERSION = '1.0.0';
|
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 ModelRefreshErrorCode = 'timeout' | 'unauthorized' | 'empty_models' | 'unknown';
|
||||||
type ModelRefreshSkipCode = 'site_disabled' | 'adapter_or_status';
|
type ModelRefreshSkipCode = 'site_disabled' | 'adapter_or_status';
|
||||||
@@ -156,16 +184,18 @@ function extractCodexModelIds(payload: unknown): string[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateCodexOauthModelDiscoveryState(input: {
|
async function updateOauthModelDiscoveryState(input: {
|
||||||
account: typeof schema.accounts.$inferSelect;
|
account: typeof schema.accounts.$inferSelect;
|
||||||
checkedAt: string;
|
checkedAt: string;
|
||||||
status: 'healthy' | 'abnormal';
|
status: 'healthy' | 'abnormal';
|
||||||
lastModelSyncError?: string;
|
lastModelSyncError?: string;
|
||||||
lastDiscoveredModels?: string[];
|
lastDiscoveredModels?: string[];
|
||||||
}) {
|
}) {
|
||||||
if (!isCodexPlatform(input.account)) return input.account.extraConfig || null;
|
const oauth = getOauthInfoFromExtraConfig(input.account.extraConfig);
|
||||||
|
if (!oauth) return input.account.extraConfig || null;
|
||||||
const extraConfig = mergeAccountExtraConfig(input.account.extraConfig, {
|
const extraConfig = mergeAccountExtraConfig(input.account.extraConfig, {
|
||||||
oauth: buildCodexOauthInfo(input.account.extraConfig, {
|
oauth: buildOauthInfo(input.account.extraConfig, {
|
||||||
|
provider: oauth.provider,
|
||||||
modelDiscoveryStatus: input.status,
|
modelDiscoveryStatus: input.status,
|
||||||
lastModelSyncAt: input.checkedAt,
|
lastModelSyncAt: input.checkedAt,
|
||||||
lastModelSyncError: input.lastModelSyncError,
|
lastModelSyncError: input.lastModelSyncError,
|
||||||
@@ -176,7 +206,6 @@ async function updateCodexOauthModelDiscoveryState(input: {
|
|||||||
extraConfig,
|
extraConfig,
|
||||||
updatedAt: input.checkedAt,
|
updatedAt: input.checkedAt,
|
||||||
}).where(eq(schema.accounts.id, input.account.id)).run();
|
}).where(eq(schema.accounts.id, input.account.id)).run();
|
||||||
input.account.extraConfig = extraConfig;
|
|
||||||
return extraConfig;
|
return extraConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +238,65 @@ async function discoverCodexModelsFromCloud(input: {
|
|||||||
return normalizeModels(extractCodexModelIds(await response.json()));
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isExactModelPattern(modelPattern: string): boolean {
|
function isExactModelPattern(modelPattern: string): boolean {
|
||||||
const normalized = modelPattern.trim();
|
const normalized = modelPattern.trim();
|
||||||
if (!normalized) return false;
|
if (!normalized) return false;
|
||||||
@@ -316,6 +404,7 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
|
|||||||
|
|
||||||
const account = row.accounts;
|
const account = row.accounts;
|
||||||
const site = row.sites;
|
const site = row.sites;
|
||||||
|
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
|
||||||
const adapter = getAdapter(site.platform);
|
const adapter = getAdapter(site.platform);
|
||||||
|
|
||||||
const accountTokens = await db.select()
|
const accountTokens = await db.select()
|
||||||
@@ -337,11 +426,11 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
|
|||||||
return buildSkippedRefreshResult(accountId, 'site_disabled', '站点已禁用');
|
return buildSkippedRefreshResult(accountId, 'site_disabled', '站点已禁用');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!adapter || account.status !== 'active') {
|
if (account.status !== 'active') {
|
||||||
return buildSkippedRefreshResult(accountId, 'adapter_or_status', '平台不可用或账号未激活');
|
return buildSkippedRefreshResult(accountId, 'adapter_or_status', '平台不可用或账号未激活');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCodexPlatform(account)) {
|
if (oauth?.provider === 'codex') {
|
||||||
const checkedAt = new Date().toISOString();
|
const checkedAt = new Date().toISOString();
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
try {
|
try {
|
||||||
@@ -363,7 +452,7 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
|
|||||||
checkedAt,
|
checkedAt,
|
||||||
})),
|
})),
|
||||||
).run();
|
).run();
|
||||||
await updateCodexOauthModelDiscoveryState({
|
await updateOauthModelDiscoveryState({
|
||||||
account,
|
account,
|
||||||
checkedAt,
|
checkedAt,
|
||||||
status: 'healthy',
|
status: 'healthy',
|
||||||
@@ -387,7 +476,7 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
|
|||||||
const rawMessage = (err as { message?: string })?.message || 'codex model discovery failed';
|
const rawMessage = (err as { message?: string })?.message || 'codex model discovery failed';
|
||||||
const errorCode = classifyModelDiscoveryError(rawMessage);
|
const errorCode = classifyModelDiscoveryError(rawMessage);
|
||||||
const errorMessage = `Codex 模型获取失败(${rawMessage})`;
|
const errorMessage = `Codex 模型获取失败(${rawMessage})`;
|
||||||
await updateCodexOauthModelDiscoveryState({
|
await updateOauthModelDiscoveryState({
|
||||||
account,
|
account,
|
||||||
checkedAt,
|
checkedAt,
|
||||||
status: 'abnormal',
|
status: 'abnormal',
|
||||||
@@ -411,6 +500,142 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (!adapter) {
|
||||||
|
return buildSkippedRefreshResult(accountId, 'adapter_or_status', '平台不可用或账号未激活');
|
||||||
|
}
|
||||||
|
|
||||||
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
|
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
|
||||||
let discoveredApiToken: string | null = null;
|
let discoveredApiToken: string | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -1,124 +1,30 @@
|
|||||||
import { schema } from '../../db/index.js';
|
import { schema } from '../../db/index.js';
|
||||||
|
import {
|
||||||
|
buildOauthInfo,
|
||||||
|
getOauthInfoFromExtraConfig,
|
||||||
|
isOauthProvider,
|
||||||
|
type OauthInfo,
|
||||||
|
} from './oauthAccount.js';
|
||||||
|
|
||||||
type ParsedOauthInfo = {
|
export type CodexOauthInfo = OauthInfo & {
|
||||||
provider?: unknown;
|
|
||||||
accountId?: unknown;
|
|
||||||
email?: unknown;
|
|
||||||
planType?: unknown;
|
|
||||||
tokenExpiresAt?: unknown;
|
|
||||||
refreshToken?: unknown;
|
|
||||||
idToken?: unknown;
|
|
||||||
modelDiscoveryStatus?: unknown;
|
|
||||||
lastModelSyncAt?: unknown;
|
|
||||||
lastModelSyncError?: unknown;
|
|
||||||
lastDiscoveredModels?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ParsedExtraConfig = {
|
|
||||||
oauth?: ParsedOauthInfo;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CodexOauthInfo = {
|
|
||||||
provider: 'codex';
|
provider: 'codex';
|
||||||
accountId?: string;
|
|
||||||
email?: string;
|
|
||||||
planType?: string;
|
|
||||||
tokenExpiresAt?: number;
|
|
||||||
refreshToken?: string;
|
|
||||||
idToken?: string;
|
|
||||||
modelDiscoveryStatus?: 'healthy' | 'abnormal';
|
|
||||||
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;
|
|
||||||
const normalized = value
|
|
||||||
.map((item) => asTrimmedString(item))
|
|
||||||
.filter((item): item is string => !!item);
|
|
||||||
return normalized.length > 0 ? normalized : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function asModelDiscoveryStatus(value: unknown): 'healthy' | 'abnormal' | 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 getCodexOauthInfoFromExtraConfig(extraConfig?: string | null): CodexOauthInfo | null {
|
export function getCodexOauthInfoFromExtraConfig(extraConfig?: string | null): CodexOauthInfo | null {
|
||||||
const parsed = parseExtraConfig(extraConfig).oauth;
|
const oauth = getOauthInfoFromExtraConfig(extraConfig);
|
||||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
if (!oauth || oauth.provider !== 'codex') return null;
|
||||||
const provider = asTrimmedString(parsed.provider);
|
return oauth as CodexOauthInfo;
|
||||||
if (provider !== 'codex') return null;
|
|
||||||
return {
|
|
||||||
provider: 'codex',
|
|
||||||
accountId: asTrimmedString(parsed.accountId),
|
|
||||||
email: asTrimmedString(parsed.email),
|
|
||||||
planType: asTrimmedString(parsed.planType),
|
|
||||||
tokenExpiresAt: asPositiveInteger(parsed.tokenExpiresAt),
|
|
||||||
refreshToken: asTrimmedString(parsed.refreshToken),
|
|
||||||
idToken: asTrimmedString(parsed.idToken),
|
|
||||||
modelDiscoveryStatus: asModelDiscoveryStatus(parsed.modelDiscoveryStatus),
|
|
||||||
lastModelSyncAt: asIsoDateTime(parsed.lastModelSyncAt),
|
|
||||||
lastModelSyncError: asTrimmedString(parsed.lastModelSyncError),
|
|
||||||
lastDiscoveredModels: asStringArray(parsed.lastDiscoveredModels),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCodexPlatform(account: Pick<typeof schema.accounts.$inferSelect, 'extraConfig'> | string | null | undefined): boolean {
|
export function isCodexPlatform(
|
||||||
const extraConfig = typeof account === 'string' || account == null
|
account: Pick<typeof schema.accounts.$inferSelect, 'extraConfig'> | string | null | undefined,
|
||||||
? account
|
): boolean {
|
||||||
: account.extraConfig;
|
return isOauthProvider(account, 'codex');
|
||||||
return getCodexOauthInfoFromExtraConfig(extraConfig)?.provider === 'codex';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildCodexOauthInfo(
|
export function buildCodexOauthInfo(
|
||||||
extraConfig?: string | null,
|
extraConfig?: string | null,
|
||||||
patch: Partial<CodexOauthInfo> = {},
|
patch: Partial<CodexOauthInfo> = {},
|
||||||
): CodexOauthInfo {
|
): CodexOauthInfo {
|
||||||
const current = getCodexOauthInfoFromExtraConfig(extraConfig);
|
return buildOauthInfo(extraConfig, { provider: 'codex', ...patch }) as CodexOauthInfo;
|
||||||
return {
|
|
||||||
provider: 'codex',
|
|
||||||
...(current || {}),
|
|
||||||
...patch,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,51 @@
|
|||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
|
import { config } from '../../config.js';
|
||||||
import { createPkceChallenge } from './sessionStore.js';
|
import { createPkceChallenge } from './sessionStore.js';
|
||||||
|
import type { OAuthProviderDefinition } from './providers.js';
|
||||||
|
|
||||||
export const CODEX_OAUTH_PROVIDER = 'codex';
|
export const CODEX_OAUTH_PROVIDER = 'codex';
|
||||||
export const CODEX_AUTH_URL = 'https://auth.openai.com/oauth/authorize';
|
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_TOKEN_URL = 'https://auth.openai.com/oauth/token';
|
||||||
export const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
export const CODEX_CLIENT_ID = config.codexClientId;
|
||||||
export const CODEX_CALLBACK_PATH = '/api/oauth/callback/codex';
|
export const CODEX_CALLBACK_PATH = '/api/oauth/callback/codex';
|
||||||
export const CODEX_LOOPBACK_CALLBACK_PATH = '/auth/callback';
|
export const CODEX_LOOPBACK_CALLBACK_PATH = '/auth/callback';
|
||||||
export const CODEX_LOOPBACK_CALLBACK_PORT = 1455;
|
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_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';
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOrigin(origin?: string): string | null {
|
||||||
|
if (typeof origin !== 'string') return null;
|
||||||
|
const trimmed = origin.trim().replace(/\/+$/, '');
|
||||||
|
return trimmed || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoopbackHostname(hostname: string): boolean {
|
||||||
|
const normalized = hostname.trim().toLowerCase();
|
||||||
|
return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1' || normalized === '[::1]';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCodexRedirectUri(requestOrigin?: string): string {
|
||||||
|
const origin = normalizeOrigin(requestOrigin);
|
||||||
|
if (!origin) return CODEX_LOOPBACK_REDIRECT_URI;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(origin);
|
||||||
|
if (isLoopbackHostname(parsed.hostname)) {
|
||||||
|
return CODEX_LOOPBACK_REDIRECT_URI;
|
||||||
|
}
|
||||||
|
return `${origin}${CODEX_CALLBACK_PATH}`;
|
||||||
|
} catch {
|
||||||
|
return CODEX_LOOPBACK_REDIRECT_URI;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type CodexJwtClaims = {
|
type CodexJwtClaims = {
|
||||||
email?: unknown;
|
email?: unknown;
|
||||||
'https://api.openai.com/auth'?: {
|
'https://api.openai.com/auth'?: {
|
||||||
@@ -47,18 +82,18 @@ function parseJwtClaims(token: string): CodexJwtClaims | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildCodexAuthorizationUrl(input: {
|
export async function buildCodexAuthorizationUrl(input: {
|
||||||
state: string;
|
state: string;
|
||||||
redirectUri: string;
|
redirectUri: string;
|
||||||
codeVerifier: string;
|
codeVerifier: string;
|
||||||
}): string {
|
}): Promise<string> {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
client_id: CODEX_CLIENT_ID,
|
client_id: requireCodexClientId(),
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
redirect_uri: input.redirectUri,
|
redirect_uri: input.redirectUri,
|
||||||
scope: 'openid email profile offline_access',
|
scope: 'openid email profile offline_access',
|
||||||
state: input.state,
|
state: input.state,
|
||||||
code_challenge: createPkceChallenge(input.codeVerifier),
|
code_challenge: await createPkceChallenge(input.codeVerifier),
|
||||||
code_challenge_method: 'S256',
|
code_challenge_method: 'S256',
|
||||||
prompt: 'login',
|
prompt: 'login',
|
||||||
id_token_add_organizations: 'true',
|
id_token_add_organizations: 'true',
|
||||||
@@ -116,7 +151,7 @@ export async function exchangeCodexAuthorizationCode(input: {
|
|||||||
}): Promise<CodexTokenExchangeResult> {
|
}): Promise<CodexTokenExchangeResult> {
|
||||||
return exchangeCodexToken(new URLSearchParams({
|
return exchangeCodexToken(new URLSearchParams({
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
client_id: CODEX_CLIENT_ID,
|
client_id: requireCodexClientId(),
|
||||||
code: input.code,
|
code: input.code,
|
||||||
redirect_uri: input.redirectUri,
|
redirect_uri: input.redirectUri,
|
||||||
code_verifier: input.codeVerifier,
|
code_verifier: input.codeVerifier,
|
||||||
@@ -125,9 +160,101 @@ export async function exchangeCodexAuthorizationCode(input: {
|
|||||||
|
|
||||||
export async function refreshCodexTokens(refreshToken: string): Promise<CodexTokenExchangeResult> {
|
export async function refreshCodexTokens(refreshToken: string): Promise<CodexTokenExchangeResult> {
|
||||||
return exchangeCodexToken(new URLSearchParams({
|
return exchangeCodexToken(new URLSearchParams({
|
||||||
client_id: CODEX_CLIENT_ID,
|
client_id: requireCodexClientId(),
|
||||||
grant_type: 'refresh_token',
|
grant_type: 'refresh_token',
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
scope: 'openid profile email',
|
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,
|
||||||
|
},
|
||||||
|
resolveRedirectUri: ({ requestOrigin }) => resolveCodexRedirectUri(requestOrigin),
|
||||||
|
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,250 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 ensureGeminiProjectEnabled(accessToken: string, projectId: string): Promise<void> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => '');
|
||||||
|
throw new Error(text || `service usage lookup failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
const payload = await response.json() as { state?: unknown };
|
||||||
|
const state = asTrimmedString(payload.state);
|
||||||
|
if ((state || '').toUpperCase() !== 'ENABLED') {
|
||||||
|
throw new Error(`Cloud AI API not enabled for project ${projectId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveGeminiProjectId(accessToken: string, requestedProjectId?: string): Promise<string> {
|
||||||
|
const explicitProject = asTrimmedString(requestedProjectId);
|
||||||
|
if (explicitProject) {
|
||||||
|
await ensureGeminiProjectEnabled(accessToken, explicitProject);
|
||||||
|
return explicitProject;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = await fetchGcpProjects(accessToken);
|
||||||
|
if (projects.length === 1 && projects[0]) {
|
||||||
|
await ensureGeminiProjectEnabled(accessToken, projects[0]);
|
||||||
|
return projects[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projects.length <= 0) {
|
||||||
|
throw new Error('no Google Cloud projects available for this account');
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('gemini cli: project selection required');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 resolveGeminiProjectId(token.accessToken, projectId);
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
if (oauth?.projectId) {
|
||||||
|
await ensureGeminiProjectEnabled(token.accessToken, oauth.projectId);
|
||||||
|
}
|
||||||
|
const email = await fetchGeminiUserEmail(token.accessToken);
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
email,
|
||||||
|
accountKey: email,
|
||||||
|
accountId: email,
|
||||||
|
projectId: oauth?.projectId,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
buildProxyHeaders: () => ({
|
||||||
|
'User-Agent': GEMINI_CLI_USER_AGENT,
|
||||||
|
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -1,19 +1,17 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
startCodexLoopbackCallbackServer,
|
startOAuthLoopbackCallbackServer,
|
||||||
stopCodexLoopbackCallbackServer,
|
stopOAuthLoopbackCallbackServers,
|
||||||
} from './localCallbackServer.js';
|
} from './localCallbackServer.js';
|
||||||
|
|
||||||
describe('codex loopback callback server', () => {
|
describe('oauth loopback callback server', () => {
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await stopCodexLoopbackCallbackServer();
|
await stopOAuthLoopbackCallbackServers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts oauth callback requests and closes the popup on success', async () => {
|
it('accepts codex oauth callback requests and closes the popup on success', async () => {
|
||||||
const callbackHandler = vi.fn(async () => ({ accountId: 12, siteId: 34 }));
|
const callbackHandler = vi.fn(async () => ({ accountId: 12, siteId: 34 }));
|
||||||
const started = await startCodexLoopbackCallbackServer({
|
const started = await startOAuthLoopbackCallbackServer('codex', {
|
||||||
host: '127.0.0.1',
|
|
||||||
port: 0,
|
|
||||||
callbackHandler,
|
callbackHandler,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -30,20 +28,19 @@ describe('codex loopback callback server', () => {
|
|||||||
expect(body).toContain('window.close()');
|
expect(body).toContain('window.close()');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders an error page when oauth completion fails', async () => {
|
it('renders a stable error page when oauth completion fails', async () => {
|
||||||
const callbackHandler = vi.fn(async () => {
|
const callbackHandler = vi.fn(async () => {
|
||||||
throw new Error('oauth failed');
|
throw new Error('oauth failed');
|
||||||
});
|
});
|
||||||
const started = await startCodexLoopbackCallbackServer({
|
const started = await startOAuthLoopbackCallbackServer('claude', {
|
||||||
host: '127.0.0.1',
|
|
||||||
port: 0,
|
|
||||||
callbackHandler,
|
callbackHandler,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(`${started.origin}/auth/callback?state=test-state&code=test-code`);
|
const response = await fetch(`${started.origin}/callback?state=test-state&code=test-code`);
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
expect(body).toContain('oauth failed');
|
expect(body).toContain('OAuth authorization failed');
|
||||||
|
expect(body).not.toContain('oauth failed');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,39 +1,31 @@
|
|||||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
||||||
import { type AddressInfo } from 'node:net';
|
import { type AddressInfo } from 'node:net';
|
||||||
import {
|
import { getOAuthProviderDefinition, listOAuthProviderDefinitions } from './providers.js';
|
||||||
CODEX_LOOPBACK_CALLBACK_PATH,
|
|
||||||
CODEX_LOOPBACK_CALLBACK_PORT,
|
|
||||||
CODEX_OAUTH_PROVIDER,
|
|
||||||
} from './codexProvider.js';
|
|
||||||
import { handleOauthCallback } from './service.js';
|
import { handleOauthCallback } from './service.js';
|
||||||
|
|
||||||
type CallbackHandler = typeof handleOauthCallback;
|
type CallbackHandler = typeof handleOauthCallback;
|
||||||
|
|
||||||
type StartCodexLoopbackCallbackServerOptions = {
|
type StartOAuthLoopbackCallbackServerOptions = {
|
||||||
port?: number;
|
|
||||||
host?: string;
|
host?: string;
|
||||||
|
port?: number;
|
||||||
callbackHandler?: CallbackHandler;
|
callbackHandler?: CallbackHandler;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CodexLoopbackCallbackServerState = {
|
export type OAuthLoopbackCallbackServerState = {
|
||||||
|
provider: string;
|
||||||
attempted: boolean;
|
attempted: boolean;
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
host?: string;
|
host?: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
path: string;
|
||||||
origin: string;
|
origin: string;
|
||||||
|
redirectUri: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_STATE: CodexLoopbackCallbackServerState = {
|
const servers = new Map<string, Server>();
|
||||||
attempted: false,
|
const states = new Map<string, OAuthLoopbackCallbackServerState>();
|
||||||
ready: false,
|
const startPromises = new Map<string, Promise<OAuthLoopbackCallbackServerState>>();
|
||||||
port: CODEX_LOOPBACK_CALLBACK_PORT,
|
|
||||||
origin: `http://localhost:${CODEX_LOOPBACK_CALLBACK_PORT}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
let callbackServer: Server | null = null;
|
|
||||||
let callbackServerState: CodexLoopbackCallbackServerState = { ...DEFAULT_STATE };
|
|
||||||
let startPromise: Promise<CodexLoopbackCallbackServerState> | null = null;
|
|
||||||
|
|
||||||
function escapeHtml(value: string): string {
|
function escapeHtml(value: string): string {
|
||||||
return value
|
return value
|
||||||
@@ -50,7 +42,7 @@ function renderCompletionPage(message: string): string {
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Codex OAuth Callback</title>
|
<title>OAuth Callback</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script>window.close();</script>
|
<script>window.close();</script>
|
||||||
@@ -67,37 +59,6 @@ function respondHtml(response: ServerResponse, statusCode: number, message: stri
|
|||||||
response.end(renderCompletionPage(message));
|
response.end(renderCompletionPage(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCallbackRequest(
|
|
||||||
request: IncomingMessage,
|
|
||||||
response: ServerResponse,
|
|
||||||
callbackHandler: CallbackHandler,
|
|
||||||
) {
|
|
||||||
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 !== CODEX_LOOPBACK_CALLBACK_PATH) {
|
|
||||||
response.writeHead(404);
|
|
||||||
response.end('Not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await callbackHandler({
|
|
||||||
provider: CODEX_OAUTH_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 (error: any) {
|
|
||||||
respondHtml(response, 500, `OAuth authorization failed: ${error?.message || 'unknown error'}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeOrigin(host: string | undefined, port: number): string {
|
function normalizeOrigin(host: string | undefined, port: number): string {
|
||||||
if (!host || host === '::' || host === '0.0.0.0') {
|
if (!host || host === '::' || host === '0.0.0.0') {
|
||||||
return `http://localhost:${port}`;
|
return `http://localhost:${port}`;
|
||||||
@@ -108,76 +69,177 @@ function normalizeOrigin(host: string | undefined, port: number): string {
|
|||||||
return `http://${host}:${port}`;
|
return `http://${host}:${port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCodexLoopbackCallbackServerState(): CodexLoopbackCallbackServerState {
|
function createDefaultState(provider: string): OAuthLoopbackCallbackServerState {
|
||||||
return { ...callbackServerState };
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startCodexLoopbackCallbackServer(
|
async function handleCallbackRequest(
|
||||||
options: StartCodexLoopbackCallbackServerOptions = {},
|
provider: string,
|
||||||
): Promise<CodexLoopbackCallbackServerState> {
|
request: IncomingMessage,
|
||||||
if (callbackServer) {
|
response: ServerResponse,
|
||||||
return getCodexLoopbackCallbackServerState();
|
callbackHandler: CallbackHandler,
|
||||||
|
) {
|
||||||
|
const definition = getOAuthProviderDefinition(provider);
|
||||||
|
if (!definition) {
|
||||||
|
response.writeHead(404);
|
||||||
|
response.end('Not found');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (startPromise) {
|
|
||||||
return startPromise;
|
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 callbackHandler = options.callbackHandler || handleOauthCallback;
|
||||||
const requestedPort = options.port ?? CODEX_LOOPBACK_CALLBACK_PORT;
|
const host = options.host || definition.loopback.host;
|
||||||
const requestedHost = options.host;
|
const port = options.port ?? definition.loopback.port;
|
||||||
|
const { path, redirectUri } = definition.loopback;
|
||||||
startPromise = new Promise<CodexLoopbackCallbackServerState>((resolve, reject) => {
|
const startPromise = new Promise<OAuthLoopbackCallbackServerState>((resolve, reject) => {
|
||||||
const server = createServer((request, response) => {
|
const server = createServer((request, response) => {
|
||||||
void handleCallbackRequest(request, response, callbackHandler);
|
void handleCallbackRequest(provider, request, response, callbackHandler);
|
||||||
});
|
});
|
||||||
|
|
||||||
const finalizeFailure = (error: Error) => {
|
const finalizeFailure = (error: Error) => {
|
||||||
callbackServerState = {
|
const failedState: OAuthLoopbackCallbackServerState = {
|
||||||
|
provider,
|
||||||
attempted: true,
|
attempted: true,
|
||||||
ready: false,
|
ready: false,
|
||||||
host: requestedHost,
|
host,
|
||||||
port: requestedPort,
|
port,
|
||||||
origin: normalizeOrigin(requestedHost, requestedPort),
|
path,
|
||||||
error: error.message || 'failed to start codex oauth callback server',
|
origin: normalizeOrigin(host, port),
|
||||||
|
redirectUri,
|
||||||
|
error: error.message || `failed to start ${provider} oauth callback server`,
|
||||||
};
|
};
|
||||||
callbackServer = null;
|
states.set(provider, failedState);
|
||||||
|
servers.delete(provider);
|
||||||
reject(error);
|
reject(error);
|
||||||
};
|
};
|
||||||
|
|
||||||
server.once('error', (error) => {
|
const onStartupError = (error: Error) => {
|
||||||
finalizeFailure(error as Error);
|
finalizeFailure(error);
|
||||||
});
|
};
|
||||||
|
|
||||||
server.listen(requestedPort, requestedHost, () => {
|
server.once('error', onStartupError);
|
||||||
server.removeAllListeners('error');
|
server.listen(port, host, () => {
|
||||||
callbackServer = server;
|
server.off('error', onStartupError);
|
||||||
|
servers.set(provider, server);
|
||||||
const address = server.address() as AddressInfo | null;
|
const address = server.address() as AddressInfo | null;
|
||||||
const port = address?.port || requestedPort;
|
const listeningPort = address?.port || port;
|
||||||
const host = address?.address || requestedHost;
|
const listeningHost = address?.address || host;
|
||||||
callbackServerState = {
|
const nextState: OAuthLoopbackCallbackServerState = {
|
||||||
|
provider,
|
||||||
attempted: true,
|
attempted: true,
|
||||||
ready: true,
|
ready: true,
|
||||||
host,
|
host: listeningHost,
|
||||||
port,
|
port: listeningPort,
|
||||||
origin: normalizeOrigin(host, port),
|
path,
|
||||||
|
origin: normalizeOrigin(listeningHost, listeningPort),
|
||||||
|
redirectUri,
|
||||||
};
|
};
|
||||||
resolve(getCodexLoopbackCallbackServerState());
|
states.set(provider, nextState);
|
||||||
|
resolve(getOAuthLoopbackCallbackServerState(provider));
|
||||||
});
|
});
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
startPromise = null;
|
startPromises.delete(provider);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
startPromises.set(provider, startPromise);
|
||||||
return startPromise;
|
return startPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function stopCodexLoopbackCallbackServer(): Promise<void> {
|
export async function startOAuthLoopbackCallbackServers(
|
||||||
const server = callbackServer;
|
options: StartOAuthLoopbackCallbackServerOptions = {},
|
||||||
callbackServer = null;
|
): Promise<OAuthLoopbackCallbackServerState[]> {
|
||||||
callbackServerState = { ...DEFAULT_STATE };
|
const results = await Promise.allSettled(
|
||||||
|
listOAuthProviderDefinitions().map((provider) =>
|
||||||
if (!server) return;
|
startOAuthLoopbackCallbackServer(provider.metadata.provider, options)),
|
||||||
|
);
|
||||||
await new Promise<void>((resolve, reject) => {
|
return results.map((result, index) => {
|
||||||
server.close((error) => (error ? reject(error) : resolve()));
|
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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { schema } from '../../db/index.js';
|
||||||
|
|
||||||
|
type ParsedOauthInfo = {
|
||||||
|
provider?: unknown;
|
||||||
|
accountId?: unknown;
|
||||||
|
accountKey?: unknown;
|
||||||
|
email?: unknown;
|
||||||
|
planType?: unknown;
|
||||||
|
projectId?: unknown;
|
||||||
|
tokenExpiresAt?: unknown;
|
||||||
|
refreshToken?: unknown;
|
||||||
|
idToken?: unknown;
|
||||||
|
providerData?: 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>;
|
||||||
|
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 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),
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { codexOauthProvider } from './codexProvider.js';
|
||||||
|
import { claudeOauthProvider } from './claudeProvider.js';
|
||||||
|
import { geminiCliOauthProvider } from './geminiCliProvider.js';
|
||||||
|
|
||||||
|
export type OAuthProviderId = 'codex' | 'claude' | 'gemini-cli';
|
||||||
|
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { refreshOauthAccessToken } from './service.js';
|
||||||
|
|
||||||
|
const refreshInFlight = new Map<number, Promise<Awaited<ReturnType<typeof refreshOauthAccessToken>>>>();
|
||||||
|
|
||||||
|
export async function refreshOauthAccessTokenSingleflight(accountId: number) {
|
||||||
|
const existing = refreshInFlight.get(accountId);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = refreshOauthAccessToken(accountId).finally(() => {
|
||||||
|
refreshInFlight.delete(accountId);
|
||||||
|
});
|
||||||
|
refreshInFlight.set(accountId, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
@@ -1,44 +1,23 @@
|
|||||||
import { eq } from 'drizzle-orm';
|
import { and, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm';
|
||||||
import { db, schema } from '../../db/index.js';
|
import { db, schema } from '../../db/index.js';
|
||||||
import { mergeAccountExtraConfig } from '../accountExtraConfig.js';
|
import { mergeAccountExtraConfig } from '../accountExtraConfig.js';
|
||||||
import { refreshModelsForAccount, rebuildTokenRoutesFromAvailability } from '../modelService.js';
|
import { refreshModelsForAccount, rebuildTokenRoutesFromAvailability } from '../modelService.js';
|
||||||
import {
|
|
||||||
buildCodexAuthorizationUrl,
|
|
||||||
CODEX_LOOPBACK_REDIRECT_URI,
|
|
||||||
CODEX_OAUTH_PROVIDER,
|
|
||||||
CODEX_UPSTREAM_BASE_URL,
|
|
||||||
exchangeCodexAuthorizationCode,
|
|
||||||
refreshCodexTokens,
|
|
||||||
type CodexTokenExchangeResult,
|
|
||||||
} from './codexProvider.js';
|
|
||||||
import {
|
import {
|
||||||
createOauthSession,
|
createOauthSession,
|
||||||
getOauthSession,
|
getOauthSession,
|
||||||
markOauthSessionError,
|
markOauthSessionError,
|
||||||
markOauthSessionSuccess,
|
markOauthSessionSuccess,
|
||||||
} from './sessionStore.js';
|
} from './sessionStore.js';
|
||||||
import { getCodexOauthInfoFromExtraConfig } from './codexAccount.js';
|
import { getOAuthLoopbackCallbackServerState } from './localCallbackServer.js';
|
||||||
import { getCodexLoopbackCallbackServerState } from './localCallbackServer.js';
|
import {
|
||||||
|
getOAuthProviderDefinition,
|
||||||
|
listOAuthProviderDefinitions,
|
||||||
|
type OAuthProviderDefinition,
|
||||||
|
} from './providers.js';
|
||||||
|
import { buildOauthInfo, getOauthInfoFromExtraConfig } from './oauthAccount.js';
|
||||||
|
import { buildCodexOauthInfo } from './codexAccount.js';
|
||||||
|
|
||||||
type OAuthProviderMetadata = {
|
type OAuthProviderMetadata = ReturnType<typeof listOauthProviders>[number];
|
||||||
provider: string;
|
|
||||||
label: string;
|
|
||||||
platform: string;
|
|
||||||
enabled: boolean;
|
|
||||||
loginType: 'oauth';
|
|
||||||
};
|
|
||||||
|
|
||||||
const OAUTH_PROVIDER_LIST: OAuthProviderMetadata[] = [
|
|
||||||
{
|
|
||||||
provider: CODEX_OAUTH_PROVIDER,
|
|
||||||
label: 'Codex',
|
|
||||||
platform: 'codex',
|
|
||||||
enabled: true,
|
|
||||||
loginType: 'oauth',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const CODEX_SITE_NAME = 'ChatGPT Codex OAuth';
|
|
||||||
|
|
||||||
function asNonEmptyString(value: unknown): string | undefined {
|
function asNonEmptyString(value: unknown): string | undefined {
|
||||||
if (typeof value !== 'string') return undefined;
|
if (typeof value !== 'string') return undefined;
|
||||||
@@ -46,54 +25,39 @@ function asNonEmptyString(value: unknown): string | undefined {
|
|||||||
return trimmed || undefined;
|
return trimmed || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHeaderValue(headers: Record<string, unknown> | undefined, key: string): string | undefined {
|
function buildUsername(input: {
|
||||||
if (!headers) return undefined;
|
email?: string;
|
||||||
const loweredKey = key.toLowerCase();
|
accountKey?: string;
|
||||||
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
provider: string;
|
||||||
if (rawKey.toLowerCase() !== loweredKey) continue;
|
}) {
|
||||||
if (typeof rawValue === 'string') {
|
return input.email || input.accountKey || `${input.provider}-user`;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildUsername(exchange: CodexTokenExchangeResult): string {
|
|
||||||
return exchange.email || exchange.accountId || 'codex-user';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getNextAccountSortOrder(): Promise<number> {
|
async function getNextAccountSortOrder(): Promise<number> {
|
||||||
const rows = await db.select({ sortOrder: schema.accounts.sortOrder }).from(schema.accounts).all();
|
const row = await db.select({
|
||||||
const max = rows.reduce((currentMax, row) => Math.max(currentMax, row.sortOrder || 0), -1);
|
maxSortOrder: sql<number>`COALESCE(MAX(${schema.accounts.sortOrder}), -1)`,
|
||||||
return max + 1;
|
}).from(schema.accounts).get();
|
||||||
|
return (row?.maxSortOrder ?? -1) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getNextSiteSortOrder(): Promise<number> {
|
async function getNextSiteSortOrder(): Promise<number> {
|
||||||
const rows = await db.select({ sortOrder: schema.sites.sortOrder }).from(schema.sites).all();
|
const row = await db.select({
|
||||||
const max = rows.reduce((currentMax, row) => Math.max(currentMax, row.sortOrder || 0), -1);
|
maxSortOrder: sql<number>`COALESCE(MAX(${schema.sites.sortOrder}), -1)`,
|
||||||
return max + 1;
|
}).from(schema.sites).get();
|
||||||
|
return (row?.maxSortOrder ?? -1) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureCodexSite() {
|
async function ensureOauthSite(definition: OAuthProviderDefinition) {
|
||||||
const sites = await db.select().from(schema.sites).all();
|
const existing = await db.select().from(schema.sites).where(and(
|
||||||
const existing = sites.find((site) => (
|
eq(schema.sites.platform, definition.site.platform),
|
||||||
String(site.platform || '').trim().toLowerCase() === 'codex'
|
eq(schema.sites.url, definition.site.url),
|
||||||
&& String(site.url || '').trim() === CODEX_UPSTREAM_BASE_URL
|
)).get();
|
||||||
));
|
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
||||||
return db.insert(schema.sites).values({
|
return db.insert(schema.sites).values({
|
||||||
name: CODEX_SITE_NAME,
|
name: definition.site.name,
|
||||||
url: CODEX_UPSTREAM_BASE_URL,
|
url: definition.site.url,
|
||||||
platform: 'codex',
|
platform: definition.site.platform,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
useSystemProxy: false,
|
useSystemProxy: false,
|
||||||
isPinned: false,
|
isPinned: false,
|
||||||
@@ -102,55 +66,102 @@ async function ensureCodexSite() {
|
|||||||
}).returning().get();
|
}).returning().get();
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesCodexIdentity(
|
async function findExistingOauthAccount(input: {
|
||||||
account: typeof schema.accounts.$inferSelect,
|
provider: string;
|
||||||
exchange: CodexTokenExchangeResult,
|
accountKey?: string;
|
||||||
): boolean {
|
email?: string;
|
||||||
const oauth = getCodexOauthInfoFromExtraConfig(account.extraConfig);
|
projectId?: string;
|
||||||
if (!oauth || oauth.provider !== CODEX_OAUTH_PROVIDER) return false;
|
rebindAccountId?: number;
|
||||||
if (oauth.accountId && exchange.accountId) return oauth.accountId === exchange.accountId;
|
}) {
|
||||||
if (oauth.email && exchange.email) return oauth.email === exchange.email;
|
if (typeof input.rebindAccountId === 'number' && input.rebindAccountId > 0) {
|
||||||
const username = asNonEmptyString(account.username);
|
|
||||||
if (username && exchange.email) return username === exchange.email;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findExistingCodexAccount(exchange: CodexTokenExchangeResult, rebindAccountId?: number) {
|
|
||||||
if (typeof rebindAccountId === 'number' && rebindAccountId > 0) {
|
|
||||||
return db.select().from(schema.accounts)
|
return db.select().from(schema.accounts)
|
||||||
.where(eq(schema.accounts.id, rebindAccountId))
|
.where(eq(schema.accounts.id, input.rebindAccountId))
|
||||||
.get();
|
.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
const accounts = await db.select().from(schema.accounts).all();
|
const accountKey = asNonEmptyString(input.accountKey);
|
||||||
return accounts.find((account) => matchesCodexIdentity(account, exchange)) || null;
|
const email = asNonEmptyString(input.email);
|
||||||
|
const projectId = asNonEmptyString(input.projectId);
|
||||||
|
|
||||||
|
if (accountKey) {
|
||||||
|
const byKey = await db.select().from(schema.accounts).where(and(
|
||||||
|
eq(schema.accounts.oauthProvider, input.provider),
|
||||||
|
eq(schema.accounts.oauthAccountKey, accountKey),
|
||||||
|
projectId
|
||||||
|
? eq(schema.accounts.oauthProjectId, projectId)
|
||||||
|
: or(isNull(schema.accounts.oauthProjectId), eq(schema.accounts.oauthProjectId, '')),
|
||||||
|
)).get();
|
||||||
|
if (byKey) return byKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
const byEmail = await db.select().from(schema.accounts).where(and(
|
||||||
|
eq(schema.accounts.oauthProvider, input.provider),
|
||||||
|
eq(schema.accounts.username, email),
|
||||||
|
)).get();
|
||||||
|
if (byEmail) return byEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upsertCodexAccount(exchange: CodexTokenExchangeResult, rebindAccountId?: number) {
|
async function upsertOauthAccount(input: {
|
||||||
const site = await ensureCodexSite();
|
definition: OAuthProviderDefinition;
|
||||||
const existing = await findExistingCodexAccount(exchange, rebindAccountId);
|
exchange: {
|
||||||
const username = buildUsername(exchange);
|
accessToken: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
tokenExpiresAt?: number;
|
||||||
|
email?: string;
|
||||||
|
accountKey?: string;
|
||||||
|
accountId?: string;
|
||||||
|
planType?: string;
|
||||||
|
projectId?: string;
|
||||||
|
idToken?: string;
|
||||||
|
providerData?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
rebindAccountId?: number;
|
||||||
|
}) {
|
||||||
|
const site = await ensureOauthSite(input.definition);
|
||||||
|
const existing = await findExistingOauthAccount({
|
||||||
|
provider: input.definition.metadata.provider,
|
||||||
|
accountKey: input.exchange.accountKey || input.exchange.accountId,
|
||||||
|
email: input.exchange.email,
|
||||||
|
projectId: input.exchange.projectId,
|
||||||
|
rebindAccountId: input.rebindAccountId,
|
||||||
|
});
|
||||||
|
const username = buildUsername({
|
||||||
|
email: input.exchange.email,
|
||||||
|
accountKey: input.exchange.accountKey || input.exchange.accountId,
|
||||||
|
provider: input.definition.metadata.provider,
|
||||||
|
});
|
||||||
|
const oauth = buildOauthInfo(existing?.extraConfig, {
|
||||||
|
provider: input.definition.metadata.provider,
|
||||||
|
accountId: input.exchange.accountId || input.exchange.accountKey,
|
||||||
|
accountKey: input.exchange.accountKey || input.exchange.accountId,
|
||||||
|
email: input.exchange.email,
|
||||||
|
planType: input.exchange.planType,
|
||||||
|
projectId: input.exchange.projectId,
|
||||||
|
refreshToken: input.exchange.refreshToken,
|
||||||
|
tokenExpiresAt: input.exchange.tokenExpiresAt,
|
||||||
|
idToken: input.exchange.idToken,
|
||||||
|
providerData: input.exchange.providerData,
|
||||||
|
});
|
||||||
const extraConfig = mergeAccountExtraConfig(existing?.extraConfig, {
|
const extraConfig = mergeAccountExtraConfig(existing?.extraConfig, {
|
||||||
credentialMode: 'session',
|
credentialMode: 'session',
|
||||||
oauth: {
|
oauth,
|
||||||
provider: CODEX_OAUTH_PROVIDER,
|
|
||||||
accountId: exchange.accountId,
|
|
||||||
email: exchange.email,
|
|
||||||
planType: exchange.planType,
|
|
||||||
refreshToken: exchange.refreshToken,
|
|
||||||
idToken: exchange.idToken,
|
|
||||||
tokenExpiresAt: exchange.tokenExpiresAt,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await db.update(schema.accounts).set({
|
await db.update(schema.accounts).set({
|
||||||
siteId: site.id,
|
siteId: site.id,
|
||||||
username,
|
username,
|
||||||
accessToken: exchange.accessToken,
|
accessToken: input.exchange.accessToken,
|
||||||
apiToken: null,
|
apiToken: null,
|
||||||
checkinEnabled: false,
|
checkinEnabled: false,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
oauthProvider: input.definition.metadata.provider,
|
||||||
|
oauthAccountKey: oauth.accountKey || oauth.accountId || null,
|
||||||
|
oauthProjectId: oauth.projectId || null,
|
||||||
extraConfig,
|
extraConfig,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
}).where(eq(schema.accounts.id, existing.id)).run();
|
}).where(eq(schema.accounts.id, existing.id)).run();
|
||||||
@@ -164,10 +175,13 @@ async function upsertCodexAccount(exchange: CodexTokenExchangeResult, rebindAcco
|
|||||||
const created = await db.insert(schema.accounts).values({
|
const created = await db.insert(schema.accounts).values({
|
||||||
siteId: site.id,
|
siteId: site.id,
|
||||||
username,
|
username,
|
||||||
accessToken: exchange.accessToken,
|
accessToken: input.exchange.accessToken,
|
||||||
apiToken: null,
|
apiToken: null,
|
||||||
checkinEnabled: false,
|
checkinEnabled: false,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
oauthProvider: input.definition.metadata.provider,
|
||||||
|
oauthAccountKey: oauth.accountKey || oauth.accountId || null,
|
||||||
|
oauthProjectId: oauth.projectId || null,
|
||||||
extraConfig,
|
extraConfig,
|
||||||
isPinned: false,
|
isPinned: false,
|
||||||
sortOrder: await getNextAccountSortOrder(),
|
sortOrder: await getNextAccountSortOrder(),
|
||||||
@@ -176,34 +190,47 @@ async function upsertCodexAccount(exchange: CodexTokenExchangeResult, rebindAcco
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function listOauthProviders() {
|
export function listOauthProviders() {
|
||||||
return OAUTH_PROVIDER_LIST;
|
return listOAuthProviderDefinitions().map((definition) => {
|
||||||
|
const state = getOAuthLoopbackCallbackServerState(definition.metadata.provider);
|
||||||
|
return {
|
||||||
|
...definition.metadata,
|
||||||
|
enabled: state.ready || !state.attempted,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startOauthProviderFlow(input: {
|
export async function startOauthProviderFlow(input: {
|
||||||
provider: string;
|
provider: string;
|
||||||
redirectOrigin: string;
|
|
||||||
rebindAccountId?: number;
|
rebindAccountId?: number;
|
||||||
|
projectId?: string;
|
||||||
|
requestOrigin?: string;
|
||||||
}) {
|
}) {
|
||||||
if (input.provider !== CODEX_OAUTH_PROVIDER) {
|
const definition = getOAuthProviderDefinition(input.provider);
|
||||||
|
if (!definition) {
|
||||||
throw new Error(`unsupported oauth provider: ${input.provider}`);
|
throw new Error(`unsupported oauth provider: ${input.provider}`);
|
||||||
}
|
}
|
||||||
const callbackServerState = getCodexLoopbackCallbackServerState();
|
const redirectUri = definition.resolveRedirectUri?.({
|
||||||
if (callbackServerState.attempted && !callbackServerState.ready) {
|
requestOrigin: input.requestOrigin,
|
||||||
throw new Error(`codex oauth callback listener is unavailable: ${callbackServerState.error || 'unknown error'}`);
|
}) || definition.loopback.redirectUri;
|
||||||
|
const callbackServerState = getOAuthLoopbackCallbackServerState(input.provider);
|
||||||
|
const usesLoopbackCallback = redirectUri === definition.loopback.redirectUri;
|
||||||
|
if (usesLoopbackCallback && callbackServerState.attempted && !callbackServerState.ready) {
|
||||||
|
throw new Error(`${input.provider} oauth callback listener is unavailable: ${callbackServerState.error || 'unknown error'}`);
|
||||||
}
|
}
|
||||||
const redirectUri = CODEX_LOOPBACK_REDIRECT_URI;
|
|
||||||
const session = createOauthSession({
|
const session = createOauthSession({
|
||||||
provider: input.provider,
|
provider: input.provider,
|
||||||
redirectUri,
|
redirectUri,
|
||||||
rebindAccountId: input.rebindAccountId,
|
rebindAccountId: input.rebindAccountId,
|
||||||
|
projectId: input.projectId,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
provider: input.provider,
|
provider: input.provider,
|
||||||
state: session.state,
|
state: session.state,
|
||||||
authorizationUrl: buildCodexAuthorizationUrl({
|
authorizationUrl: await definition.buildAuthorizationUrl({
|
||||||
state: session.state,
|
state: session.state,
|
||||||
redirectUri,
|
redirectUri: session.redirectUri,
|
||||||
codeVerifier: session.codeVerifier,
|
codeVerifier: session.codeVerifier,
|
||||||
|
projectId: session.projectId,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -233,6 +260,11 @@ export async function handleOauthCallback(input: {
|
|||||||
if (!session || session.provider !== input.provider) {
|
if (!session || session.provider !== input.provider) {
|
||||||
throw new Error('oauth session not found or provider mismatch');
|
throw new Error('oauth session not found or provider mismatch');
|
||||||
}
|
}
|
||||||
|
const definition = getOAuthProviderDefinition(input.provider);
|
||||||
|
if (!definition) {
|
||||||
|
markOauthSessionError(input.state, `unsupported oauth provider: ${input.provider}`);
|
||||||
|
throw new Error(`unsupported oauth provider: ${input.provider}`);
|
||||||
|
}
|
||||||
if (input.error) {
|
if (input.error) {
|
||||||
markOauthSessionError(input.state, input.error);
|
markOauthSessionError(input.state, input.error);
|
||||||
throw new Error(input.error);
|
throw new Error(input.error);
|
||||||
@@ -243,12 +275,18 @@ export async function handleOauthCallback(input: {
|
|||||||
throw new Error('missing oauth code');
|
throw new Error('missing oauth code');
|
||||||
}
|
}
|
||||||
|
|
||||||
const exchange = await exchangeCodexAuthorizationCode({
|
const exchange = await definition.exchangeAuthorizationCode({
|
||||||
code,
|
code,
|
||||||
codeVerifier: session.codeVerifier,
|
state: input.state,
|
||||||
redirectUri: session.redirectUri,
|
redirectUri: session.redirectUri,
|
||||||
|
codeVerifier: session.codeVerifier,
|
||||||
|
projectId: session.projectId,
|
||||||
|
});
|
||||||
|
const { account, site, created } = await upsertOauthAccount({
|
||||||
|
definition,
|
||||||
|
exchange,
|
||||||
|
rebindAccountId: session.rebindAccountId,
|
||||||
});
|
});
|
||||||
const { account, site, created } = await upsertCodexAccount(exchange, session.rebindAccountId) as Awaited<ReturnType<typeof upsertCodexAccount>> & { created: boolean };
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
markOauthSessionError(input.state, 'failed to persist oauth account');
|
markOauthSessionError(input.state, 'failed to persist oauth account');
|
||||||
throw new Error('failed to persist oauth account');
|
throw new Error('failed to persist oauth account');
|
||||||
@@ -260,7 +298,7 @@ export async function handleOauthCallback(input: {
|
|||||||
await db.delete(schema.accounts).where(eq(schema.accounts.id, account.id)).run();
|
await db.delete(schema.accounts).where(eq(schema.accounts.id, account.id)).run();
|
||||||
}
|
}
|
||||||
await rebuildTokenRoutesFromAvailability();
|
await rebuildTokenRoutesFromAvailability();
|
||||||
const errorMessage = refreshResult.errorMessage || 'codex model discovery failed';
|
const errorMessage = refreshResult.errorMessage || `${input.provider} model discovery failed`;
|
||||||
markOauthSessionError(input.state, errorMessage);
|
markOauthSessionError(input.state, errorMessage);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
@@ -273,15 +311,41 @@ export async function handleOauthCallback(input: {
|
|||||||
return { accountId: account.id, siteId: site.id };
|
return { accountId: account.id, siteId: site.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listOauthConnections() {
|
export async function listOauthConnections(options: {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
} = {}) {
|
||||||
|
const limit = Math.max(1, Math.min(200, Math.trunc(options.limit ?? 50)));
|
||||||
|
const offset = Math.max(0, Math.trunc(options.offset ?? 0));
|
||||||
|
|
||||||
|
const totalRow = await db.select({
|
||||||
|
count: sql<number>`COUNT(*)`,
|
||||||
|
}).from(schema.accounts)
|
||||||
|
.where(sql`${schema.accounts.oauthProvider} IS NOT NULL`)
|
||||||
|
.get();
|
||||||
|
const total = totalRow?.count ?? 0;
|
||||||
|
|
||||||
const rows = await db.select().from(schema.accounts)
|
const rows = await db.select().from(schema.accounts)
|
||||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||||
|
.where(sql`${schema.accounts.oauthProvider} IS NOT NULL`)
|
||||||
|
.orderBy(desc(schema.accounts.id))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
|
const accountIds = rows.map((row) => row.accounts.id);
|
||||||
|
if (accountIds.length <= 0) {
|
||||||
|
return { items: [], total, limit, offset };
|
||||||
|
}
|
||||||
|
|
||||||
const modelRows = await db.select({
|
const modelRows = await db.select({
|
||||||
accountId: schema.modelAvailability.accountId,
|
accountId: schema.modelAvailability.accountId,
|
||||||
modelName: schema.modelAvailability.modelName,
|
modelName: schema.modelAvailability.modelName,
|
||||||
}).from(schema.modelAvailability)
|
}).from(schema.modelAvailability)
|
||||||
.where(eq(schema.modelAvailability.available, true))
|
.where(and(
|
||||||
|
inArray(schema.modelAvailability.accountId, accountIds),
|
||||||
|
eq(schema.modelAvailability.available, true),
|
||||||
|
))
|
||||||
.all();
|
.all();
|
||||||
const modelMap = new Map<number, string[]>();
|
const modelMap = new Map<number, string[]>();
|
||||||
for (const row of modelRows) {
|
for (const row of modelRows) {
|
||||||
@@ -290,19 +354,21 @@ export async function listOauthConnections() {
|
|||||||
list.push(row.modelName);
|
list.push(row.modelName);
|
||||||
modelMap.set(row.accountId, list);
|
modelMap.set(row.accountId, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
const routeChannelRows = await db.select({
|
const routeChannelRows = await db.select({
|
||||||
accountId: schema.routeChannels.accountId,
|
accountId: schema.routeChannels.accountId,
|
||||||
}).from(schema.routeChannels).all();
|
count: sql<number>`COUNT(*)`,
|
||||||
|
}).from(schema.routeChannels)
|
||||||
|
.where(inArray(schema.routeChannels.accountId, accountIds))
|
||||||
|
.groupBy(schema.routeChannels.accountId)
|
||||||
|
.all();
|
||||||
const routeChannelCountByAccount = new Map<number, number>();
|
const routeChannelCountByAccount = new Map<number, number>();
|
||||||
for (const row of routeChannelRows) {
|
for (const row of routeChannelRows) {
|
||||||
const current = routeChannelCountByAccount.get(row.accountId) || 0;
|
routeChannelCountByAccount.set(row.accountId, row.count ?? 0);
|
||||||
routeChannelCountByAccount.set(row.accountId, current + 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows
|
const items = rows.map((row) => {
|
||||||
.filter((row) => getCodexOauthInfoFromExtraConfig(row.accounts.extraConfig)?.provider === CODEX_OAUTH_PROVIDER)
|
const oauth = getOauthInfoFromExtraConfig(row.accounts.extraConfig)!;
|
||||||
.map((row) => {
|
|
||||||
const oauth = getCodexOauthInfoFromExtraConfig(row.accounts.extraConfig)!;
|
|
||||||
const models = modelMap.get(row.accounts.id) || [];
|
const models = modelMap.get(row.accounts.id) || [];
|
||||||
const status = (
|
const status = (
|
||||||
oauth.modelDiscoveryStatus === 'abnormal'
|
oauth.modelDiscoveryStatus === 'abnormal'
|
||||||
@@ -312,11 +378,12 @@ export async function listOauthConnections() {
|
|||||||
return {
|
return {
|
||||||
accountId: row.accounts.id,
|
accountId: row.accounts.id,
|
||||||
siteId: row.sites.id,
|
siteId: row.sites.id,
|
||||||
provider: CODEX_OAUTH_PROVIDER,
|
provider: oauth.provider,
|
||||||
username: row.accounts.username,
|
username: row.accounts.username,
|
||||||
email: oauth.email,
|
email: oauth.email,
|
||||||
accountKey: oauth.accountId,
|
accountKey: oauth.accountKey || oauth.accountId,
|
||||||
planType: oauth.planType,
|
planType: oauth.planType,
|
||||||
|
projectId: oauth.projectId,
|
||||||
modelCount: models.length,
|
modelCount: models.length,
|
||||||
modelsPreview: models.slice(0, 10),
|
modelsPreview: models.slice(0, 10),
|
||||||
status,
|
status,
|
||||||
@@ -331,6 +398,8 @@ export async function listOauthConnections() {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return { items, total, limit, offset };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteOauthConnection(accountId: number) {
|
export async function deleteOauthConnection(accountId: number) {
|
||||||
@@ -340,7 +409,7 @@ export async function deleteOauthConnection(accountId: number) {
|
|||||||
if (!account) {
|
if (!account) {
|
||||||
throw new Error('oauth account not found');
|
throw new Error('oauth account not found');
|
||||||
}
|
}
|
||||||
const oauth = getCodexOauthInfoFromExtraConfig(account.extraConfig);
|
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
|
||||||
if (!oauth) {
|
if (!oauth) {
|
||||||
throw new Error('account is not managed by oauth');
|
throw new Error('account is not managed by oauth');
|
||||||
}
|
}
|
||||||
@@ -349,21 +418,36 @@ export async function deleteOauthConnection(accountId: number) {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startOauthRebindFlow(accountId: number, redirectOrigin: string) {
|
export async function startOauthRebindFlow(accountId: number, requestOrigin?: string) {
|
||||||
const account = await db.select().from(schema.accounts)
|
const account = await db.select().from(schema.accounts)
|
||||||
.where(eq(schema.accounts.id, accountId))
|
.where(eq(schema.accounts.id, accountId))
|
||||||
.get();
|
.get();
|
||||||
if (!account) {
|
if (!account) {
|
||||||
throw new Error('oauth account not found');
|
throw new Error('oauth account not found');
|
||||||
}
|
}
|
||||||
const oauth = getCodexOauthInfoFromExtraConfig(account.extraConfig);
|
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
|
||||||
if (!oauth) {
|
if (!oauth) {
|
||||||
throw new Error('account is not managed by oauth');
|
throw new Error('account is not managed by oauth');
|
||||||
}
|
}
|
||||||
return startOauthProviderFlow({
|
return startOauthProviderFlow({
|
||||||
provider: oauth.provider,
|
provider: oauth.provider,
|
||||||
redirectOrigin,
|
|
||||||
rebindAccountId: accountId,
|
rebindAccountId: accountId,
|
||||||
|
projectId: oauth.projectId,
|
||||||
|
requestOrigin,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOauthProviderHeaders(input: {
|
||||||
|
extraConfig?: string | null;
|
||||||
|
downstreamHeaders?: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
|
const oauth = getOauthInfoFromExtraConfig(input.extraConfig);
|
||||||
|
if (!oauth) return {};
|
||||||
|
const definition = getOAuthProviderDefinition(oauth.provider);
|
||||||
|
if (!definition?.buildProxyHeaders) return {};
|
||||||
|
return definition.buildProxyHeaders({
|
||||||
|
oauth,
|
||||||
|
downstreamHeaders: input.downstreamHeaders,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,48 +455,62 @@ export function buildCodexOauthProviderHeaders(input: {
|
|||||||
extraConfig?: string | null;
|
extraConfig?: string | null;
|
||||||
downstreamHeaders?: Record<string, unknown>;
|
downstreamHeaders?: Record<string, unknown>;
|
||||||
}) {
|
}) {
|
||||||
const oauth = getCodexOauthInfoFromExtraConfig(input.extraConfig);
|
const oauth = buildCodexOauthInfo(input.extraConfig);
|
||||||
if (!oauth) return {};
|
const definition = getOAuthProviderDefinition('codex');
|
||||||
|
return definition?.buildProxyHeaders?.({
|
||||||
const accountId = getHeaderValue(input.downstreamHeaders, 'chatgpt-account-id') || oauth.accountId;
|
oauth,
|
||||||
const originator = getHeaderValue(input.downstreamHeaders, 'originator') || 'codex_cli_rs';
|
downstreamHeaders: input.downstreamHeaders,
|
||||||
const headers: Record<string, string> = {
|
}) || {};
|
||||||
Originator: originator,
|
|
||||||
};
|
|
||||||
if (accountId) {
|
|
||||||
headers['Chatgpt-Account-Id'] = accountId;
|
|
||||||
}
|
|
||||||
return headers;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshCodexOauthAccessToken(accountId: number) {
|
export async function refreshOauthAccessToken(accountId: number) {
|
||||||
const account = await db.select().from(schema.accounts)
|
const account = await db.select().from(schema.accounts)
|
||||||
.where(eq(schema.accounts.id, accountId))
|
.where(eq(schema.accounts.id, accountId))
|
||||||
.get();
|
.get();
|
||||||
if (!account) {
|
if (!account) {
|
||||||
throw new Error('codex oauth account not found');
|
throw new Error('oauth account not found');
|
||||||
}
|
}
|
||||||
const oauth = getCodexOauthInfoFromExtraConfig(account.extraConfig);
|
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
|
||||||
if (!oauth?.refreshToken) {
|
if (!oauth?.refreshToken) {
|
||||||
throw new Error('codex oauth refresh token missing');
|
throw new Error('oauth refresh token missing');
|
||||||
|
}
|
||||||
|
const definition = getOAuthProviderDefinition(oauth.provider);
|
||||||
|
if (!definition) {
|
||||||
|
throw new Error(`unsupported oauth provider: ${oauth.provider}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshed = await refreshCodexTokens(oauth.refreshToken);
|
const refreshed = await definition.refreshAccessToken({
|
||||||
const extraConfig = mergeAccountExtraConfig(account.extraConfig, {
|
refreshToken: oauth.refreshToken,
|
||||||
credentialMode: 'session',
|
|
||||||
oauth: {
|
oauth: {
|
||||||
provider: CODEX_OAUTH_PROVIDER,
|
projectId: oauth.projectId,
|
||||||
|
providerData: oauth.providerData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const nextOauth = buildOauthInfo(account.extraConfig, {
|
||||||
|
provider: oauth.provider,
|
||||||
accountId: refreshed.accountId || oauth.accountId,
|
accountId: refreshed.accountId || oauth.accountId,
|
||||||
|
accountKey: refreshed.accountKey || oauth.accountKey || refreshed.accountId || oauth.accountId,
|
||||||
email: refreshed.email || oauth.email,
|
email: refreshed.email || oauth.email,
|
||||||
planType: refreshed.planType || oauth.planType,
|
planType: refreshed.planType || oauth.planType,
|
||||||
refreshToken: refreshed.refreshToken,
|
projectId: refreshed.projectId || oauth.projectId,
|
||||||
idToken: refreshed.idToken,
|
refreshToken: refreshed.refreshToken || oauth.refreshToken,
|
||||||
tokenExpiresAt: refreshed.tokenExpiresAt,
|
tokenExpiresAt: refreshed.tokenExpiresAt || oauth.tokenExpiresAt,
|
||||||
|
idToken: refreshed.idToken || oauth.idToken,
|
||||||
|
providerData: {
|
||||||
|
...(oauth.providerData || {}),
|
||||||
|
...(refreshed.providerData || {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const extraConfig = mergeAccountExtraConfig(account.extraConfig, {
|
||||||
|
credentialMode: 'session',
|
||||||
|
oauth: nextOauth,
|
||||||
|
});
|
||||||
|
|
||||||
await db.update(schema.accounts).set({
|
await db.update(schema.accounts).set({
|
||||||
accessToken: refreshed.accessToken,
|
accessToken: refreshed.accessToken,
|
||||||
|
oauthProvider: oauth.provider,
|
||||||
|
oauthAccountKey: nextOauth.accountKey || nextOauth.accountId || null,
|
||||||
|
oauthProjectId: nextOauth.projectId || null,
|
||||||
extraConfig,
|
extraConfig,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
@@ -421,7 +519,13 @@ export async function refreshCodexOauthAccessToken(accountId: number) {
|
|||||||
return {
|
return {
|
||||||
accountId,
|
accountId,
|
||||||
accessToken: refreshed.accessToken,
|
accessToken: refreshed.accessToken,
|
||||||
accountKey: refreshed.accountId || oauth.accountId,
|
accountKey: nextOauth.accountKey || nextOauth.accountId,
|
||||||
extraConfig,
|
extraConfig,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function refreshCodexOauthAccessToken(accountId: number) {
|
||||||
|
return refreshOauthAccessToken(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { OAuthProviderMetadata };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createHash, randomBytes } from 'node:crypto';
|
import { randomBytes, webcrypto } from 'node:crypto';
|
||||||
|
|
||||||
export type OAuthSessionStatus = 'pending' | 'success' | 'error';
|
export type OAuthSessionStatus = 'pending' | 'success' | 'error';
|
||||||
|
|
||||||
@@ -15,10 +15,22 @@ export type OAuthSessionRecord = {
|
|||||||
siteId?: number;
|
siteId?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
rebindAccountId?: number;
|
rebindAccountId?: number;
|
||||||
|
projectId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface OAuthSessionStore {
|
||||||
|
create(input: {
|
||||||
|
provider: string;
|
||||||
|
redirectUri: string;
|
||||||
|
rebindAccountId?: number;
|
||||||
|
projectId?: string;
|
||||||
|
}): OAuthSessionRecord;
|
||||||
|
get(state: string): OAuthSessionRecord | null;
|
||||||
|
markSuccess(state: string, patch: { accountId: number; siteId: number }): OAuthSessionRecord | null;
|
||||||
|
markError(state: string, error: string): OAuthSessionRecord | null;
|
||||||
|
}
|
||||||
|
|
||||||
const SESSION_TTL_MS = 10 * 60 * 1000;
|
const SESSION_TTL_MS = 10 * 60 * 1000;
|
||||||
const oauthSessions = new Map<string, OAuthSessionRecord>();
|
|
||||||
|
|
||||||
function nowIso(): string {
|
function nowIso(): string {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
@@ -28,28 +40,33 @@ function toBase64Url(input: Buffer): string {
|
|||||||
return input.toString('base64url');
|
return input.toString('base64url');
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneExpiredSessions(nowMs = Date.now()) {
|
|
||||||
for (const [state, session] of oauthSessions.entries()) {
|
|
||||||
if (Date.parse(session.expiresAt) <= nowMs) {
|
|
||||||
oauthSessions.delete(state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPkceVerifier(): string {
|
function createPkceVerifier(): string {
|
||||||
return toBase64Url(randomBytes(48));
|
return toBase64Url(randomBytes(48));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPkceChallenge(codeVerifier: string): string {
|
export async function createPkceChallenge(codeVerifier: string): Promise<string> {
|
||||||
return createHash('sha256').update(codeVerifier).digest('base64url');
|
const digest = await webcrypto.subtle.digest('SHA-256', Buffer.from(codeVerifier, 'utf8'));
|
||||||
|
return Buffer.from(digest).toString('base64url');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createOauthSession(input: {
|
class MemoryOAuthSessionStore implements OAuthSessionStore {
|
||||||
|
private readonly sessions = new Map<string, OAuthSessionRecord>();
|
||||||
|
|
||||||
|
private pruneExpiredSessions(nowMs = Date.now()) {
|
||||||
|
for (const [state, session] of this.sessions.entries()) {
|
||||||
|
if (Date.parse(session.expiresAt) <= nowMs) {
|
||||||
|
this.sessions.delete(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
create(input: {
|
||||||
provider: string;
|
provider: string;
|
||||||
redirectUri: string;
|
redirectUri: string;
|
||||||
rebindAccountId?: number;
|
rebindAccountId?: number;
|
||||||
}): OAuthSessionRecord {
|
projectId?: string;
|
||||||
pruneExpiredSessions();
|
}): OAuthSessionRecord {
|
||||||
|
this.pruneExpiredSessions();
|
||||||
const state = toBase64Url(randomBytes(24));
|
const state = toBase64Url(randomBytes(24));
|
||||||
const codeVerifier = createPkceVerifier();
|
const codeVerifier = createPkceVerifier();
|
||||||
const createdAt = nowIso();
|
const createdAt = nowIso();
|
||||||
@@ -64,21 +81,19 @@ export function createOauthSession(input: {
|
|||||||
updatedAt: createdAt,
|
updatedAt: createdAt,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
rebindAccountId: input.rebindAccountId,
|
rebindAccountId: input.rebindAccountId,
|
||||||
|
projectId: input.projectId,
|
||||||
};
|
};
|
||||||
oauthSessions.set(state, record);
|
this.sessions.set(state, record);
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getOauthSession(state: string): OAuthSessionRecord | null {
|
get(state: string): OAuthSessionRecord | null {
|
||||||
pruneExpiredSessions();
|
this.pruneExpiredSessions();
|
||||||
return oauthSessions.get(state) || null;
|
return this.sessions.get(state) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function markOauthSessionSuccess(
|
markSuccess(state: string, patch: { accountId: number; siteId: number }): OAuthSessionRecord | null {
|
||||||
state: string,
|
const existing = this.get(state);
|
||||||
patch: { accountId: number; siteId: number },
|
|
||||||
): OAuthSessionRecord | null {
|
|
||||||
const existing = getOauthSession(state);
|
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
const next: OAuthSessionRecord = {
|
const next: OAuthSessionRecord = {
|
||||||
...existing,
|
...existing,
|
||||||
@@ -88,12 +103,12 @@ export function markOauthSessionSuccess(
|
|||||||
siteId: patch.siteId,
|
siteId: patch.siteId,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
};
|
};
|
||||||
oauthSessions.set(state, next);
|
this.sessions.set(state, next);
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function markOauthSessionError(state: string, error: string): OAuthSessionRecord | null {
|
markError(state: string, error: string): OAuthSessionRecord | null {
|
||||||
const existing = getOauthSession(state);
|
const existing = this.get(state);
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
const next: OAuthSessionRecord = {
|
const next: OAuthSessionRecord = {
|
||||||
...existing,
|
...existing,
|
||||||
@@ -101,6 +116,37 @@ export function markOauthSessionError(state: string, error: string): OAuthSessio
|
|||||||
updatedAt: nowIso(),
|
updatedAt: nowIso(),
|
||||||
error: error.trim() || 'OAuth failed',
|
error: error.trim() || 'OAuth failed',
|
||||||
};
|
};
|
||||||
oauthSessions.set(state, next);
|
this.sessions.set(state, next);
|
||||||
return next;
|
return next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let oauthSessionStore: OAuthSessionStore = new MemoryOAuthSessionStore();
|
||||||
|
|
||||||
|
export function setOauthSessionStore(store: OAuthSessionStore) {
|
||||||
|
oauthSessionStore = store;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOauthSession(input: {
|
||||||
|
provider: string;
|
||||||
|
redirectUri: string;
|
||||||
|
rebindAccountId?: number;
|
||||||
|
projectId?: string;
|
||||||
|
}): OAuthSessionRecord {
|
||||||
|
return oauthSessionStore.create(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOauthSession(state: string): OAuthSessionRecord | null {
|
||||||
|
return oauthSessionStore.get(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markOauthSessionSuccess(
|
||||||
|
state: string,
|
||||||
|
patch: { accountId: number; siteId: number },
|
||||||
|
): OAuthSessionRecord | null {
|
||||||
|
return oauthSessionStore.markSuccess(state, patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markOauthSessionError(state: string, error: string): OAuthSessionRecord | null {
|
||||||
|
return oauthSessionStore.markError(state, error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js';
|
import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js';
|
||||||
|
|
||||||
function normalizeBaseUrl(baseUrl: string): string {
|
function normalizeBaseUrl(baseUrl: string): string {
|
||||||
return (baseUrl || '').replace(/\/+$/, '');
|
let normalized = (baseUrl || '').trim();
|
||||||
|
while (normalized.endsWith('/')) {
|
||||||
|
normalized = normalized.slice(0, -1);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CodexAdapter extends BasePlatformAdapter {
|
export class CodexAdapter extends BasePlatformAdapter {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function resolveGeminiNativeModelsUrl(baseUrl: string, apiToken: string): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class GeminiAdapter extends BasePlatformAdapter {
|
export class GeminiAdapter extends BasePlatformAdapter {
|
||||||
readonly platformName = 'gemini';
|
readonly platformName: string = 'gemini';
|
||||||
|
|
||||||
async detect(url: string): Promise<boolean> {
|
async detect(url: string): Promise<boolean> {
|
||||||
const normalized = (url || '').toLowerCase();
|
const normalized = (url || '').toLowerCase();
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { GeminiAdapter } from './gemini.js';
|
||||||
|
|
||||||
|
export class GeminiCliAdapter extends GeminiAdapter {
|
||||||
|
override readonly platformName = 'gemini-cli';
|
||||||
|
|
||||||
|
override async detect(url: string): Promise<boolean> {
|
||||||
|
const normalized = (url || '').toLowerCase();
|
||||||
|
return normalized.includes('cloudcode-pa.googleapis.com');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { OpenAiAdapter } from './openai.js';
|
|||||||
import { CodexAdapter } from './codex.js';
|
import { CodexAdapter } from './codex.js';
|
||||||
import { ClaudeAdapter } from './claude.js';
|
import { ClaudeAdapter } from './claude.js';
|
||||||
import { GeminiAdapter } from './gemini.js';
|
import { GeminiAdapter } from './gemini.js';
|
||||||
|
import { GeminiCliAdapter } from './geminiCli.js';
|
||||||
import { CliProxyApiAdapter } from './cliproxyapi.js';
|
import { CliProxyApiAdapter } from './cliproxyapi.js';
|
||||||
import { detectPlatformByTitle } from './titleHint.js';
|
import { detectPlatformByTitle } from './titleHint.js';
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ const adapters: PlatformAdapter[] = [
|
|||||||
new CodexAdapter(),
|
new CodexAdapter(),
|
||||||
new ClaudeAdapter(),
|
new ClaudeAdapter(),
|
||||||
new GeminiAdapter(),
|
new GeminiAdapter(),
|
||||||
|
new GeminiCliAdapter(),
|
||||||
new CliProxyApiAdapter(),
|
new CliProxyApiAdapter(),
|
||||||
new AnyRouterAdapter(),
|
new AnyRouterAdapter(),
|
||||||
new DoneHubAdapter(),
|
new DoneHubAdapter(),
|
||||||
@@ -57,6 +59,7 @@ const platformAliases: Record<string, string> = {
|
|||||||
anthropic: 'claude',
|
anthropic: 'claude',
|
||||||
claude: 'claude',
|
claude: 'claude',
|
||||||
gemini: 'gemini',
|
gemini: 'gemini',
|
||||||
|
'gemini-cli': 'gemini-cli',
|
||||||
google: 'gemini',
|
google: 'gemini',
|
||||||
// CLIProxyAPI aliases
|
// CLIProxyAPI aliases
|
||||||
cliproxyapi: 'cliproxyapi',
|
cliproxyapi: 'cliproxyapi',
|
||||||
@@ -97,6 +100,7 @@ function detectPlatformByUrlHint(url: string): string | undefined {
|
|||||||
) {
|
) {
|
||||||
return 'gemini';
|
return 'gemini';
|
||||||
}
|
}
|
||||||
|
if (normalized.includes('cloudcode-pa.googleapis.com')) return 'gemini-cli';
|
||||||
|
|
||||||
// NewAPI-family forks and common aliases.
|
// NewAPI-family forks and common aliases.
|
||||||
if (normalized.includes('anyrouter')) return 'anyrouter';
|
if (normalized.includes('anyrouter')) return 'anyrouter';
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from './routeRoutingStrategy.js';
|
} from './routeRoutingStrategy.js';
|
||||||
import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js';
|
import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js';
|
||||||
import { isUsableAccountToken } from './accountTokenService.js';
|
import { isUsableAccountToken } from './accountTokenService.js';
|
||||||
import { isCodexPlatform } from './oauth/codexAccount.js';
|
import { getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js';
|
||||||
|
|
||||||
interface RouteMatch {
|
interface RouteMatch {
|
||||||
route: typeof schema.tokenRoutes.$inferSelect;
|
route: typeof schema.tokenRoutes.$inferSelect;
|
||||||
@@ -1045,7 +1045,7 @@ export class TokenRouter {
|
|||||||
const fallback = candidate.account.apiToken?.trim();
|
const fallback = candidate.account.apiToken?.trim();
|
||||||
if (fallback) return fallback;
|
if (fallback) return fallback;
|
||||||
|
|
||||||
if ((candidate.site?.platform || '').trim().toLowerCase() === 'codex' || isCodexPlatform(candidate.account)) {
|
if (getOauthInfoFromExtraConfig(candidate.account.extraConfig)) {
|
||||||
const accessToken = candidate.account.accessToken?.trim();
|
const accessToken = candidate.account.accessToken?.trim();
|
||||||
return accessToken || null;
|
return accessToken || null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,8 +213,8 @@ export function buildResponsesCompatibilityBodies(
|
|||||||
if (temperature !== null) richCandidate.temperature = temperature;
|
if (temperature !== null) richCandidate.temperature = temperature;
|
||||||
const topP = toFiniteNumber(body.top_p);
|
const topP = toFiniteNumber(body.top_p);
|
||||||
if (topP !== null) richCandidate.top_p = topP;
|
if (topP !== null) richCandidate.top_p = topP;
|
||||||
const instructions = typeof body.instructions === 'string' ? body.instructions.trim() : '';
|
const instructions = getExplicitResponsesInstructions(body);
|
||||||
if (instructions) richCandidate.instructions = instructions;
|
if (instructions !== null) richCandidate.instructions = instructions;
|
||||||
|
|
||||||
const passthroughFields = [
|
const passthroughFields = [
|
||||||
'reasoning',
|
'reasoning',
|
||||||
@@ -398,6 +398,11 @@ function parseUpstreamErrorShape(rawText: string): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getExplicitResponsesInstructions(body: Record<string, unknown>): string | null {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(body, 'instructions')) return null;
|
||||||
|
return typeof body.instructions === 'string' ? body.instructions.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
function stripResponsesMetadata(
|
function stripResponsesMetadata(
|
||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
): Record<string, unknown> | null {
|
): Record<string, unknown> | null {
|
||||||
@@ -431,8 +436,8 @@ function buildCoreResponsesBody(
|
|||||||
const topP = toFiniteNumber(body.top_p);
|
const topP = toFiniteNumber(body.top_p);
|
||||||
if (topP !== null) core.top_p = topP;
|
if (topP !== null) core.top_p = topP;
|
||||||
|
|
||||||
const instructions = typeof body.instructions === 'string' ? body.instructions.trim() : '';
|
const instructions = getExplicitResponsesInstructions(body);
|
||||||
if (instructions) core.instructions = instructions;
|
if (instructions !== null) core.instructions = instructions;
|
||||||
|
|
||||||
const passthroughFields = [
|
const passthroughFields = [
|
||||||
'reasoning',
|
'reasoning',
|
||||||
@@ -458,10 +463,15 @@ function buildStrictResponsesBody(
|
|||||||
if (!model) return null;
|
if (!model) return null;
|
||||||
if (body.input === undefined) return null;
|
if (body.input === undefined) return null;
|
||||||
|
|
||||||
|
const explicitInstructions = getExplicitResponsesInstructions(body);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
model,
|
model,
|
||||||
input: body.input,
|
input: body.input,
|
||||||
stream: body.stream === true,
|
stream: body.stream === true,
|
||||||
|
...(explicitInstructions !== null
|
||||||
|
? { instructions: explicitInstructions }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-2
@@ -252,6 +252,10 @@ export type OAuthProviderInfo = {
|
|||||||
platform: string;
|
platform: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
loginType: 'oauth';
|
loginType: 'oauth';
|
||||||
|
requiresProjectId: boolean;
|
||||||
|
supportsDirectAccountRouting: boolean;
|
||||||
|
supportsCloudValidation: boolean;
|
||||||
|
supportsNativeProxy: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OAuthSessionInfo = {
|
export type OAuthSessionInfo = {
|
||||||
@@ -271,6 +275,7 @@ export type OAuthConnectionInfo = {
|
|||||||
email?: string | null;
|
email?: string | null;
|
||||||
accountKey?: string | null;
|
accountKey?: string | null;
|
||||||
planType?: string | null;
|
planType?: string | null;
|
||||||
|
projectId?: string | null;
|
||||||
modelCount: number;
|
modelCount: number;
|
||||||
modelsPreview: string[];
|
modelsPreview: string[];
|
||||||
status: 'healthy' | 'abnormal';
|
status: 'healthy' | 'abnormal';
|
||||||
@@ -280,6 +285,13 @@ export type OAuthConnectionInfo = {
|
|||||||
site?: { id: number; name: string; url: string; platform: string } | null;
|
site?: { id: number; name: string; url: string; platform: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OAuthConnectionsResponse = {
|
||||||
|
items: OAuthConnectionInfo[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
// Sites
|
// Sites
|
||||||
getSites: () => request('/api/sites'),
|
getSites: () => request('/api/sites'),
|
||||||
@@ -393,12 +405,13 @@ export const api = {
|
|||||||
|
|
||||||
// OAuth
|
// OAuth
|
||||||
getOAuthProviders: () => request('/api/oauth/providers') as Promise<{ providers: OAuthProviderInfo[] }>,
|
getOAuthProviders: () => request('/api/oauth/providers') as Promise<{ providers: OAuthProviderInfo[] }>,
|
||||||
startOAuthProvider: (provider: string, data?: { accountId?: number }) => request(`/api/oauth/providers/${encodeURIComponent(provider)}/start`, {
|
startOAuthProvider: (provider: string, data?: { accountId?: number; projectId?: string }) => request(`/api/oauth/providers/${encodeURIComponent(provider)}/start`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data || {}),
|
body: JSON.stringify(data || {}),
|
||||||
}) as Promise<{ provider: string; state: string; authorizationUrl: string }>,
|
}) as Promise<{ provider: string; state: string; authorizationUrl: string }>,
|
||||||
getOAuthSession: (state: string) => request(`/api/oauth/sessions/${encodeURIComponent(state)}`) as Promise<OAuthSessionInfo>,
|
getOAuthSession: (state: string) => request(`/api/oauth/sessions/${encodeURIComponent(state)}`) as Promise<OAuthSessionInfo>,
|
||||||
getOAuthConnections: () => request('/api/oauth/connections') as Promise<{ items: OAuthConnectionInfo[] }>,
|
getOAuthConnections: (params?: { limit?: number; offset?: number }) =>
|
||||||
|
request(`/api/oauth/connections${buildQueryString(params)}`) as Promise<OAuthConnectionsResponse>,
|
||||||
rebindOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}/rebind`, {
|
rebindOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}/rebind`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { MemoryRouter } from 'react-router-dom';
|
|||||||
import { ToastProvider } from '../components/Toast.js';
|
import { ToastProvider } from '../components/Toast.js';
|
||||||
import OAuthManagement from './OAuthManagement.js';
|
import OAuthManagement from './OAuthManagement.js';
|
||||||
|
|
||||||
const { apiMock, openMock, focusMock, confirmMock } = vi.hoisted(() => ({
|
const { apiMock, openMock, focusMock, confirmMock, promptMock } = vi.hoisted(() => ({
|
||||||
apiMock: {
|
apiMock: {
|
||||||
getOAuthProviders: vi.fn(),
|
getOAuthProviders: vi.fn(),
|
||||||
getOAuthConnections: vi.fn(),
|
getOAuthConnections: vi.fn(),
|
||||||
@@ -16,6 +16,7 @@ const { apiMock, openMock, focusMock, confirmMock } = vi.hoisted(() => ({
|
|||||||
openMock: vi.fn(),
|
openMock: vi.fn(),
|
||||||
focusMock: vi.fn(),
|
focusMock: vi.fn(),
|
||||||
confirmMock: vi.fn(),
|
confirmMock: vi.fn(),
|
||||||
|
promptMock: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../api.js', () => ({
|
vi.mock('../api.js', () => ({
|
||||||
@@ -41,11 +42,14 @@ describe('OAuthManagement page', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
Object.values(apiMock).forEach((mock) => mock.mockReset());
|
||||||
openMock.mockReturnValue({ focus: focusMock });
|
openMock.mockReturnValue({ focus: focusMock });
|
||||||
confirmMock.mockReturnValue(true);
|
confirmMock.mockReturnValue(true);
|
||||||
|
promptMock.mockReturnValue('project-demo');
|
||||||
vi.stubGlobal('window', {
|
vi.stubGlobal('window', {
|
||||||
open: openMock,
|
open: openMock,
|
||||||
confirm: confirmMock,
|
confirm: confirmMock,
|
||||||
|
prompt: promptMock,
|
||||||
setTimeout,
|
setTimeout,
|
||||||
clearTimeout,
|
clearTimeout,
|
||||||
} as unknown as Window & typeof globalThis);
|
} as unknown as Window & typeof globalThis);
|
||||||
@@ -60,7 +64,28 @@ describe('OAuthManagement page', () => {
|
|||||||
it('renders available oauth providers and existing oauth connections', async () => {
|
it('renders available oauth providers and existing oauth connections', async () => {
|
||||||
apiMock.getOAuthProviders.mockResolvedValue({
|
apiMock.getOAuthProviders.mockResolvedValue({
|
||||||
providers: [
|
providers: [
|
||||||
{ provider: 'codex', label: 'Codex', platform: 'codex', enabled: true, loginType: 'oauth' },
|
{
|
||||||
|
provider: 'codex',
|
||||||
|
label: 'Codex',
|
||||||
|
platform: 'codex',
|
||||||
|
enabled: true,
|
||||||
|
loginType: 'oauth',
|
||||||
|
requiresProjectId: false,
|
||||||
|
supportsDirectAccountRouting: true,
|
||||||
|
supportsCloudValidation: true,
|
||||||
|
supportsNativeProxy: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: 'gemini-cli',
|
||||||
|
label: 'Gemini CLI',
|
||||||
|
platform: 'gemini-cli',
|
||||||
|
enabled: true,
|
||||||
|
loginType: 'oauth',
|
||||||
|
requiresProjectId: true,
|
||||||
|
supportsDirectAccountRouting: true,
|
||||||
|
supportsCloudValidation: true,
|
||||||
|
supportsNativeProxy: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
apiMock.getOAuthConnections.mockResolvedValue({
|
apiMock.getOAuthConnections.mockResolvedValue({
|
||||||
@@ -72,9 +97,12 @@ describe('OAuthManagement page', () => {
|
|||||||
planType: 'plus',
|
planType: 'plus',
|
||||||
modelCount: 3,
|
modelCount: 3,
|
||||||
modelsPreview: ['gpt-5', 'gpt-5-mini', 'gpt-5.2-codex'],
|
modelsPreview: ['gpt-5', 'gpt-5-mini', 'gpt-5.2-codex'],
|
||||||
status: 'active',
|
status: 'healthy',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
total: 1,
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
let root: ReturnType<typeof create> | null = null;
|
let root: ReturnType<typeof create> | null = null;
|
||||||
@@ -88,14 +116,16 @@ describe('OAuthManagement page', () => {
|
|||||||
</ToastProvider>,
|
</ToastProvider>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
await vi.waitFor(async () => {
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|
||||||
const text = collectText(root!.root);
|
const text = collectText(root!.root);
|
||||||
expect(text).toContain('OAuth 管理');
|
expect(text).toContain('OAuth 管理');
|
||||||
expect(text).toContain('Codex');
|
expect(text).toContain('Codex');
|
||||||
|
expect(text).toContain('Gemini CLI');
|
||||||
expect(text).toContain('codex-user@example.com');
|
expect(text).toContain('codex-user@example.com');
|
||||||
expect(text).toContain('plus');
|
expect(text).toContain('plus');
|
||||||
expect(text).toContain('3 个模型');
|
expect(text).toContain('3 个模型');
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
root?.unmount();
|
root?.unmount();
|
||||||
}
|
}
|
||||||
@@ -104,11 +134,32 @@ describe('OAuthManagement page', () => {
|
|||||||
it('starts oauth, opens popup, polls status, and refreshes connection list after success', async () => {
|
it('starts oauth, opens popup, polls status, and refreshes connection list after success', async () => {
|
||||||
apiMock.getOAuthProviders.mockResolvedValue({
|
apiMock.getOAuthProviders.mockResolvedValue({
|
||||||
providers: [
|
providers: [
|
||||||
{ provider: 'codex', label: 'Codex', platform: 'codex', enabled: true, loginType: 'oauth' },
|
{
|
||||||
|
provider: 'codex',
|
||||||
|
label: 'Codex',
|
||||||
|
platform: 'codex',
|
||||||
|
enabled: true,
|
||||||
|
loginType: 'oauth',
|
||||||
|
requiresProjectId: false,
|
||||||
|
supportsDirectAccountRouting: true,
|
||||||
|
supportsCloudValidation: true,
|
||||||
|
supportsNativeProxy: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: 'gemini-cli',
|
||||||
|
label: 'Gemini CLI',
|
||||||
|
platform: 'gemini-cli',
|
||||||
|
enabled: true,
|
||||||
|
loginType: 'oauth',
|
||||||
|
requiresProjectId: true,
|
||||||
|
supportsDirectAccountRouting: true,
|
||||||
|
supportsCloudValidation: true,
|
||||||
|
supportsNativeProxy: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
apiMock.getOAuthConnections
|
apiMock.getOAuthConnections
|
||||||
.mockResolvedValueOnce({ items: [] })
|
.mockResolvedValueOnce({ items: [], total: 0, limit: 100, offset: 0 })
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
@@ -118,9 +169,12 @@ describe('OAuthManagement page', () => {
|
|||||||
planType: 'plus',
|
planType: 'plus',
|
||||||
modelCount: 3,
|
modelCount: 3,
|
||||||
modelsPreview: ['gpt-5', 'gpt-5-mini', 'gpt-5.2-codex'],
|
modelsPreview: ['gpt-5', 'gpt-5-mini', 'gpt-5.2-codex'],
|
||||||
status: 'active',
|
status: 'healthy',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
total: 1,
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
});
|
});
|
||||||
apiMock.startOAuthProvider.mockResolvedValue({
|
apiMock.startOAuthProvider.mockResolvedValue({
|
||||||
provider: 'codex',
|
provider: 'codex',
|
||||||
@@ -151,7 +205,9 @@ describe('OAuthManagement page', () => {
|
|||||||
</ToastProvider>,
|
</ToastProvider>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
await vi.waitFor(async () => {
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
const startButton = root!.root.find((node) => (
|
const startButton = root!.root.find((node) => (
|
||||||
node.type === 'button'
|
node.type === 'button'
|
||||||
@@ -162,9 +218,11 @@ describe('OAuthManagement page', () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
await startButton.props.onClick();
|
await startButton.props.onClick();
|
||||||
});
|
});
|
||||||
|
await vi.waitFor(async () => {
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
expect(apiMock.startOAuthProvider).toHaveBeenCalledWith('codex');
|
expect(apiMock.startOAuthProvider).toHaveBeenCalledWith('codex', { projectId: undefined });
|
||||||
expect(openMock).toHaveBeenCalledWith(
|
expect(openMock).toHaveBeenCalledWith(
|
||||||
'https://auth.openai.com/oauth/authorize?state=oauth-state-123',
|
'https://auth.openai.com/oauth/authorize?state=oauth-state-123',
|
||||||
'oauth-codex',
|
'oauth-codex',
|
||||||
@@ -174,12 +232,16 @@ describe('OAuthManagement page', () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(1600);
|
vi.advanceTimersByTime(1600);
|
||||||
});
|
});
|
||||||
|
await vi.waitFor(async () => {
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(1600);
|
vi.advanceTimersByTime(1600);
|
||||||
});
|
});
|
||||||
|
await vi.waitFor(async () => {
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
expect(apiMock.getOAuthSession).toHaveBeenCalledWith('oauth-state-123');
|
expect(apiMock.getOAuthSession).toHaveBeenCalledWith('oauth-state-123');
|
||||||
expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(2);
|
expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(2);
|
||||||
@@ -194,7 +256,17 @@ describe('OAuthManagement page', () => {
|
|||||||
it('shows oauth connection status metadata and allows deleting a connection', async () => {
|
it('shows oauth connection status metadata and allows deleting a connection', async () => {
|
||||||
apiMock.getOAuthProviders.mockResolvedValue({
|
apiMock.getOAuthProviders.mockResolvedValue({
|
||||||
providers: [
|
providers: [
|
||||||
{ provider: 'codex', label: 'Codex', platform: 'codex', enabled: true, loginType: 'oauth' },
|
{
|
||||||
|
provider: 'codex',
|
||||||
|
label: 'Codex',
|
||||||
|
platform: 'codex',
|
||||||
|
enabled: true,
|
||||||
|
loginType: 'oauth',
|
||||||
|
requiresProjectId: false,
|
||||||
|
supportsDirectAccountRouting: true,
|
||||||
|
supportsCloudValidation: true,
|
||||||
|
supportsNativeProxy: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
apiMock.getOAuthConnections
|
apiMock.getOAuthConnections
|
||||||
@@ -213,8 +285,11 @@ describe('OAuthManagement page', () => {
|
|||||||
lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)',
|
lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
total: 1,
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
})
|
})
|
||||||
.mockResolvedValueOnce({ items: [] });
|
.mockResolvedValueOnce({ items: [], total: 0, limit: 100, offset: 0 });
|
||||||
apiMock.deleteOAuthConnection.mockResolvedValue({ success: true });
|
apiMock.deleteOAuthConnection.mockResolvedValue({ success: true });
|
||||||
|
|
||||||
let root: ReturnType<typeof create> | null = null;
|
let root: ReturnType<typeof create> | null = null;
|
||||||
@@ -228,12 +303,13 @@ describe('OAuthManagement page', () => {
|
|||||||
</ToastProvider>,
|
</ToastProvider>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
await vi.waitFor(async () => {
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|
||||||
const text = collectText(root!.root);
|
const text = collectText(root!.root);
|
||||||
expect(text).toContain('异常');
|
expect(text).toContain('异常');
|
||||||
expect(text).toContain('1 条路由');
|
expect(text).toContain('1 条路由');
|
||||||
expect(text).toContain('Codex 模型获取失败');
|
expect(text).toContain('Codex 模型获取失败');
|
||||||
|
});
|
||||||
|
|
||||||
const deleteButton = root!.root.find((node) => (
|
const deleteButton = root!.root.find((node) => (
|
||||||
node.type === 'button'
|
node.type === 'button'
|
||||||
@@ -244,7 +320,9 @@ describe('OAuthManagement page', () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
await deleteButton.props.onClick();
|
await deleteButton.props.onClick();
|
||||||
});
|
});
|
||||||
|
await vi.waitFor(async () => {
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
expect(confirmMock).toHaveBeenCalled();
|
expect(confirmMock).toHaveBeenCalled();
|
||||||
expect(apiMock.deleteOAuthConnection).toHaveBeenCalledWith(7);
|
expect(apiMock.deleteOAuthConnection).toHaveBeenCalledWith(7);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { api, type OAuthConnectionInfo, type OAuthProviderInfo } from '../api.js';
|
import { api, type OAuthConnectionInfo, type OAuthProviderInfo } from '../api.js';
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 1500;
|
const POLL_INTERVAL_MS = 1500;
|
||||||
|
const CONNECTION_PAGE_LIMIT = 100;
|
||||||
|
|
||||||
type ActiveSession = {
|
type ActiveSession = {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -13,8 +14,15 @@ function openOAuthPopup(provider: string, authorizationUrl: string) {
|
|||||||
const popup = window.open(
|
const popup = window.open(
|
||||||
authorizationUrl,
|
authorizationUrl,
|
||||||
`oauth-${provider}`,
|
`oauth-${provider}`,
|
||||||
'popup=yes,width=540,height=760,resizable=yes,scrollbars=yes',
|
'popup=yes,width=540,height=760,resizable=yes,scrollbars=yes,noopener,noreferrer',
|
||||||
);
|
);
|
||||||
|
if (popup) {
|
||||||
|
try {
|
||||||
|
popup.opener = null;
|
||||||
|
} catch {
|
||||||
|
// Ignore cross-window opener hardening failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
if (popup && typeof popup.focus === 'function') {
|
if (popup && typeof popup.focus === 'function') {
|
||||||
popup.focus();
|
popup.focus();
|
||||||
}
|
}
|
||||||
@@ -33,7 +41,10 @@ export default function OAuthManagement() {
|
|||||||
const [actionLoadingKey, setActionLoadingKey] = useState('');
|
const [actionLoadingKey, setActionLoadingKey] = useState('');
|
||||||
|
|
||||||
const loadConnections = async () => {
|
const loadConnections = async () => {
|
||||||
const response = await api.getOAuthConnections();
|
const response = await api.getOAuthConnections({
|
||||||
|
limit: CONNECTION_PAGE_LIMIT,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
setConnections(Array.isArray(response?.items) ? response.items : []);
|
setConnections(Array.isArray(response?.items) ? response.items : []);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -44,6 +55,9 @@ export default function OAuthManagement() {
|
|||||||
loadConnections(),
|
loadConnections(),
|
||||||
]);
|
]);
|
||||||
setProviders(Array.isArray(providersResponse?.providers) ? providersResponse.providers : []);
|
setProviders(Array.isArray(providersResponse?.providers) ? providersResponse.providers : []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('failed to load oauth management data', error);
|
||||||
|
setSessionMessage('OAuth 管理数据加载失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
}
|
}
|
||||||
@@ -93,19 +107,30 @@ export default function OAuthManagement() {
|
|||||||
};
|
};
|
||||||
}, [activeSession]);
|
}, [activeSession]);
|
||||||
|
|
||||||
const handleStart = async (provider: string, accountId?: number) => {
|
const handleStart = async (provider: OAuthProviderInfo, accountId?: number) => {
|
||||||
const actionKey = `start:${provider}:${accountId || 0}`;
|
const actionKey = `start:${provider.provider}:${accountId || 0}`;
|
||||||
setActionLoadingKey(actionKey);
|
setActionLoadingKey(actionKey);
|
||||||
try {
|
try {
|
||||||
|
const projectId = provider.requiresProjectId
|
||||||
|
? (() => {
|
||||||
|
if (typeof window === 'undefined' || typeof window.prompt !== 'function') return undefined;
|
||||||
|
const value = window.prompt('输入 Google Cloud Project ID');
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||||
|
})()
|
||||||
|
: undefined;
|
||||||
|
if (provider.requiresProjectId && !projectId && !accountId) {
|
||||||
|
setSessionMessage('Gemini CLI 连接需要 Project ID');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const started = accountId
|
const started = accountId
|
||||||
? await api.rebindOAuthConnection(accountId)
|
? await api.rebindOAuthConnection(accountId)
|
||||||
: await api.startOAuthProvider(provider);
|
: await api.startOAuthProvider(provider.provider, { projectId });
|
||||||
setSessionMessage('等待授权完成');
|
setSessionMessage('等待授权完成');
|
||||||
setActiveSession({
|
setActiveSession({
|
||||||
provider: started.provider,
|
provider: started.provider,
|
||||||
state: started.state,
|
state: started.state,
|
||||||
});
|
});
|
||||||
openOAuthPopup(provider, started.authorizationUrl);
|
openOAuthPopup(provider.provider, started.authorizationUrl);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setSessionMessage(error?.message || '无法启动 OAuth 授权');
|
setSessionMessage(error?.message || '无法启动 OAuth 授权');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -136,7 +161,7 @@ export default function OAuthManagement() {
|
|||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<div className="page-title">OAuth 管理</div>
|
<div className="page-title">OAuth 管理</div>
|
||||||
<div className="page-subtitle">统一管理需要浏览器授权的上游连接。首版支持 Codex。</div>
|
<div className="page-subtitle">统一管理需要浏览器授权的上游连接,包括 Codex、Claude 和 Gemini CLI。</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -155,12 +180,16 @@ export default function OAuthManagement() {
|
|||||||
<div key={provider.provider} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
<div key={provider.provider} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 14, fontWeight: 600 }}>{provider.label}</div>
|
<div style={{ fontSize: 14, fontWeight: 600 }}>{provider.label}</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{provider.platform}</div>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||||
|
{provider.platform}
|
||||||
|
{provider.requiresProjectId ? ' · 需要 Project ID' : ''}
|
||||||
|
{provider.supportsNativeProxy ? ' · 原生代理' : ''}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={() => handleStart(provider.provider)}
|
onClick={() => handleStart(provider)}
|
||||||
disabled={actionLoadingKey === actionKey}
|
disabled={actionLoadingKey === actionKey}
|
||||||
>
|
>
|
||||||
{actionLoadingKey === actionKey ? '启动中...' : `连接 ${provider.label}`}
|
{actionLoadingKey === actionKey ? '启动中...' : `连接 ${provider.label}`}
|
||||||
@@ -188,6 +217,11 @@ export default function OAuthManagement() {
|
|||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||||
{connection.planType || 'unknown'} · {connection.modelCount} 个模型
|
{connection.planType || 'unknown'} · {connection.modelCount} 个模型
|
||||||
</div>
|
</div>
|
||||||
|
{connection.projectId && (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||||
|
Project: {connection.projectId}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||||
{resolveConnectionStatusLabel(connection.status)} · {connection.routeChannelCount || 0} 条路由
|
{resolveConnectionStatusLabel(connection.status)} · {connection.routeChannelCount || 0} 条路由
|
||||||
</div>
|
</div>
|
||||||
@@ -211,7 +245,20 @@ export default function OAuthManagement() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
onClick={() => handleStart(connection.provider, connection.accountId)}
|
onClick={() => handleStart(
|
||||||
|
providers.find((provider) => provider.provider === connection.provider) || {
|
||||||
|
provider: connection.provider,
|
||||||
|
label: connection.provider,
|
||||||
|
platform: connection.site?.platform || connection.provider,
|
||||||
|
enabled: true,
|
||||||
|
loginType: 'oauth',
|
||||||
|
requiresProjectId: false,
|
||||||
|
supportsDirectAccountRouting: true,
|
||||||
|
supportsCloudValidation: true,
|
||||||
|
supportsNativeProxy: false,
|
||||||
|
},
|
||||||
|
connection.accountId,
|
||||||
|
)}
|
||||||
disabled={actionLoadingKey === rebindActionKey || actionLoadingKey === deleteActionKey}
|
disabled={actionLoadingKey === rebindActionKey || actionLoadingKey === deleteActionKey}
|
||||||
>
|
>
|
||||||
{actionLoadingKey === rebindActionKey ? '启动中...' : '重新授权'}
|
{actionLoadingKey === rebindActionKey ? '启动中...' : '重新授权'}
|
||||||
|
|||||||
Reference in New Issue
Block a user