feat: add multi-provider OAuth support for Claude and Gemini CLI
This commit is contained in:
@@ -38,6 +38,16 @@ describe('buildConfig', () => {
|
||||
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 () => {
|
||||
const app = Fastify(buildFastifyOptions(buildConfig({})));
|
||||
const largeText = 'a'.repeat(2 * 1024 * 1024);
|
||||
|
||||
@@ -24,6 +24,10 @@ function parseCsvList(value: string | undefined): string[] {
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function parseOptionalSecret(value: string | undefined): string {
|
||||
return (value || '').trim();
|
||||
}
|
||||
|
||||
function parseDbType(value: string | undefined): 'sqlite' | 'mysql' | 'postgres' {
|
||||
const normalized = (value || 'sqlite').trim().toLowerCase();
|
||||
if (normalized === 'mysql') return 'mysql';
|
||||
@@ -41,6 +45,11 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
|
||||
return {
|
||||
authToken: env.AUTH_TOKEN || 'change-me-admin-token',
|
||||
proxyToken: env.PROXY_TOKEN || 'change-me-proxy-sk-token',
|
||||
codexClientId: parseOptionalSecret(env.CODEX_CLIENT_ID),
|
||||
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 || '',
|
||||
accountCredentialSecret: env.ACCOUNT_CREDENTIAL_SECRET || env.AUTH_TOKEN || 'change-me-admin-token',
|
||||
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 `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 `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);
|
||||
@@ -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_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 `sites_platform_url_unique` ON `sites` (`platform`(191), `url`(191));
|
||||
CREATE UNIQUE INDEX `token_model_availability_token_model_unique` ON `token_model_availability` (`token_id`, `model_name`(191));
|
||||
CREATE INDEX `account_tokens_account_enabled_idx` ON `account_tokens` (`account_id`, `enabled`);
|
||||
CREATE INDEX `account_tokens_account_id_idx` ON `account_tokens` (`account_id`);
|
||||
CREATE INDEX `account_tokens_enabled_idx` ON `account_tokens` (`enabled`);
|
||||
CREATE INDEX `accounts_oauth_identity_idx` ON `accounts` (`oauth_provider`(191), `oauth_account_key`(191), `oauth_project_id`(191));
|
||||
CREATE INDEX `accounts_oauth_provider_idx` ON `accounts` (`oauth_provider`(191));
|
||||
CREATE INDEX `accounts_site_id_idx` ON `accounts` (`site_id`);
|
||||
CREATE INDEX `accounts_site_status_idx` ON `accounts` (`site_id`, `status`(191));
|
||||
CREATE INDEX `accounts_status_idx` ON `accounts` (`status`(191));
|
||||
|
||||
@@ -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 "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 "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);
|
||||
@@ -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_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 "sites_platform_url_unique" ON "sites" ("platform", "url");
|
||||
CREATE UNIQUE INDEX "token_model_availability_token_model_unique" ON "token_model_availability" ("token_id", "model_name");
|
||||
CREATE INDEX "account_tokens_account_enabled_idx" ON "account_tokens" ("account_id", "enabled");
|
||||
CREATE INDEX "account_tokens_account_id_idx" ON "account_tokens" ("account_id");
|
||||
CREATE INDEX "account_tokens_enabled_idx" ON "account_tokens" ("enabled");
|
||||
CREATE INDEX "accounts_oauth_identity_idx" ON "accounts" ("oauth_provider", "oauth_account_key", "oauth_project_id");
|
||||
CREATE INDEX "accounts_oauth_provider_idx" ON "accounts" ("oauth_provider");
|
||||
CREATE INDEX "accounts_site_id_idx" ON "accounts" ("site_id");
|
||||
CREATE INDEX "accounts_site_status_idx" ON "accounts" ("site_id", "status");
|
||||
CREATE INDEX "accounts_status_idx" ON "accounts" ("status");
|
||||
|
||||
@@ -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,
|
||||
"defaultValue": "0",
|
||||
"primaryKey": false
|
||||
},
|
||||
"oauth_provider": {
|
||||
"logicalType": "text",
|
||||
"notNull": false,
|
||||
"defaultValue": null,
|
||||
"primaryKey": false
|
||||
},
|
||||
"oauth_account_key": {
|
||||
"logicalType": "text",
|
||||
"notNull": false,
|
||||
"defaultValue": null,
|
||||
"primaryKey": false
|
||||
},
|
||||
"oauth_project_id": {
|
||||
"logicalType": "text",
|
||||
"notNull": false,
|
||||
"defaultValue": null,
|
||||
"primaryKey": false
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1125,6 +1143,24 @@
|
||||
],
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"name": "accounts_oauth_identity_idx",
|
||||
"table": "accounts",
|
||||
"columns": [
|
||||
"oauth_provider",
|
||||
"oauth_account_key",
|
||||
"oauth_project_id"
|
||||
],
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"name": "accounts_oauth_provider_idx",
|
||||
"table": "accounts",
|
||||
"columns": [
|
||||
"oauth_provider"
|
||||
],
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"name": "accounts_site_id_idx",
|
||||
"table": "accounts",
|
||||
@@ -1404,6 +1440,15 @@
|
||||
],
|
||||
"unique": true
|
||||
},
|
||||
{
|
||||
"name": "sites_platform_url_unique",
|
||||
"table": "sites",
|
||||
"columns": [
|
||||
"platform",
|
||||
"url"
|
||||
],
|
||||
"unique": true
|
||||
},
|
||||
{
|
||||
"name": "sites_status_idx",
|
||||
"table": "sites",
|
||||
@@ -1501,6 +1546,14 @@
|
||||
"model_name"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "sites_platform_url_unique",
|
||||
"table": "sites",
|
||||
"columns": [
|
||||
"platform",
|
||||
"url"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "token_model_availability_token_model_unique",
|
||||
"table": "token_model_availability",
|
||||
|
||||
@@ -344,6 +344,7 @@ describe('sqlite migrate bootstrap', () => {
|
||||
'0010_proxy_logs_downstream_api_key',
|
||||
'0011_downstream_api_key_metadata',
|
||||
'0012_account_token_value_status',
|
||||
'0013_oauth_multi_provider',
|
||||
]);
|
||||
const appliedEntries = journalEntries.filter((entry) => !missingTags.has(entry.tag));
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ export const sites = sqliteTable('sites', {
|
||||
name: text('name').notNull(),
|
||||
url: text('url').notNull(),
|
||||
externalCheckinUrl: text('external_checkin_url'),
|
||||
platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api' | 'openai' | 'claude' | 'gemini'
|
||||
platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api' | 'openai' | 'claude' | 'gemini' | 'codex' | 'gemini-cli'
|
||||
proxyUrl: text('proxy_url'),
|
||||
useSystemProxy: integer('use_system_proxy', { mode: 'boolean' }).default(false),
|
||||
customHeaders: text('custom_headers'),
|
||||
@@ -19,6 +19,7 @@ export const sites = sqliteTable('sites', {
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
statusIdx: index('sites_status_idx').on(table.status),
|
||||
platformUrlUnique: uniqueIndex('sites_platform_url_unique').on(table.platform, table.url),
|
||||
}));
|
||||
|
||||
export const siteDisabledModels = sqliteTable('site_disabled_models', {
|
||||
@@ -48,6 +49,9 @@ export const accounts = sqliteTable('accounts', {
|
||||
checkinEnabled: integer('checkin_enabled', { mode: 'boolean' }).default(true),
|
||||
lastCheckinAt: text('last_checkin_at'),
|
||||
lastBalanceRefresh: text('last_balance_refresh'),
|
||||
oauthProvider: text('oauth_provider'),
|
||||
oauthAccountKey: text('oauth_account_key'),
|
||||
oauthProjectId: text('oauth_project_id'),
|
||||
extraConfig: text('extra_config'), // JSON string
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
@@ -55,6 +59,8 @@ export const accounts = sqliteTable('accounts', {
|
||||
siteIdIdx: index('accounts_site_id_idx').on(table.siteId),
|
||||
statusIdx: index('accounts_status_idx').on(table.status),
|
||||
siteStatusIdx: index('accounts_site_status_idx').on(table.siteId, table.status),
|
||||
oauthProviderIdx: index('accounts_oauth_provider_idx').on(table.oauthProvider),
|
||||
oauthIdentityIdx: index('accounts_oauth_identity_idx').on(table.oauthProvider, table.oauthAccountKey, table.oauthProjectId),
|
||||
}));
|
||||
|
||||
export const accountTokens = sqliteTable('account_tokens', {
|
||||
|
||||
+4
-4
@@ -25,7 +25,7 @@ import { buildStartupSummaryLines } from './services/startupInfo.js';
|
||||
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js';
|
||||
import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js';
|
||||
import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js';
|
||||
import { startCodexLoopbackCallbackServer, stopCodexLoopbackCallbackServer } from './services/oauth/localCallbackServer.js';
|
||||
import { startOAuthLoopbackCallbackServers, stopOAuthLoopbackCallbackServers } from './services/oauth/localCallbackServer.js';
|
||||
import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js';
|
||||
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
|
||||
import { existsSync } from 'fs';
|
||||
@@ -349,14 +349,14 @@ if (existsSync(webDir)) {
|
||||
// Start scheduler
|
||||
await startScheduler();
|
||||
try {
|
||||
await startCodexLoopbackCallbackServer();
|
||||
await startOAuthLoopbackCallbackServers();
|
||||
} 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);
|
||||
app.addHook('onClose', async () => {
|
||||
stopProxyLogRetentionService();
|
||||
await stopCodexLoopbackCallbackServer();
|
||||
await stopOAuthLoopbackCallbackServers();
|
||||
});
|
||||
|
||||
// Start server
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock('../../services/platforms/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 db: DbModule['db'];
|
||||
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({
|
||||
name: 'Codex Site',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
@@ -221,12 +221,12 @@ describe('accounts credential mode', { timeout: 15_000 }, () => {
|
||||
}>;
|
||||
const item = list.find((entry) => entry.id === account.id);
|
||||
expect(item?.credentialMode).toBe('session');
|
||||
expect(item?.capabilities).toMatchObject({
|
||||
canCheckin: false,
|
||||
canRefreshBalance: false,
|
||||
proxyOnly: false,
|
||||
expect(item?.capabilities).toMatchObject({
|
||||
canCheckin: false,
|
||||
canRefreshBalance: false,
|
||||
proxyOnly: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('stores managed refresh token for sub2api session account', async () => {
|
||||
verifyTokenMock.mockResolvedValueOnce({
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ensureDefaultTokenForAccount, syncTokensFromUpstream } from '../../serv
|
||||
import {
|
||||
getCredentialModeFromExtraConfig,
|
||||
guessPlatformUserIdFromUsername,
|
||||
hasOauthProvider,
|
||||
getSub2ApiAuthFromExtraConfig,
|
||||
mergeAccountExtraConfig,
|
||||
normalizeCredentialMode as normalizeCredentialModeInput,
|
||||
@@ -19,7 +20,6 @@ import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
||||
import { parseCheckinRewardAmount } from '../../services/checkinRewardParser.js';
|
||||
import { estimateRewardWithTodayIncomeFallback } from '../../services/todayIncomeRewardService.js';
|
||||
import { getLocalDayRangeUtc } from '../../services/localTimeService.js';
|
||||
import { isCodexPlatform } from '../../services/oauth/codexAccount.js';
|
||||
import {
|
||||
buildRuntimeHealthForAccount,
|
||||
setAccountRuntimeHealth,
|
||||
@@ -82,11 +82,11 @@ function buildCapabilitiesFromCredentialMode(
|
||||
hasSessionToken: boolean,
|
||||
extraConfig?: string | null,
|
||||
): AccountCapabilities {
|
||||
if (isCodexPlatform(extraConfig)) {
|
||||
if (hasOauthProvider(extraConfig)) {
|
||||
return {
|
||||
canCheckin: false,
|
||||
canRefreshBalance: false,
|
||||
proxyOnly: false,
|
||||
proxyOnly: true,
|
||||
};
|
||||
}
|
||||
const sessionCapable = credentialMode === 'session'
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import Fastify from 'fastify';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { oauthRoutes } from './oauth.js';
|
||||
import { isPublicApiRoute } from '../../desktop.js';
|
||||
|
||||
describe('oauth route registration', () => {
|
||||
it('registers oauth routes in the main server entry', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/server/index.ts'), 'utf8');
|
||||
const apps: Array<Awaited<ReturnType<typeof Fastify>>> = [];
|
||||
|
||||
expect(source).toContain("import { oauthRoutes } from './routes/api/oauth.js';");
|
||||
expect(source).toContain('await app.register(oauthRoutes);');
|
||||
afterEach(async () => {
|
||||
await Promise.all(apps.splice(0).map((app) => app.close()));
|
||||
});
|
||||
|
||||
it('treats oauth callback as a public API route', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/server/desktop.ts'), 'utf8');
|
||||
it('registers oauth routes on a Fastify instance at runtime', async () => {
|
||||
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 () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-oauth-routes-'));
|
||||
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');
|
||||
const dbModule = await import('../../db/index.js');
|
||||
@@ -52,9 +56,13 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
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({
|
||||
method: 'GET',
|
||||
url: '/api/oauth/providers',
|
||||
@@ -62,14 +70,27 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
providers: [
|
||||
providers: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
provider: 'codex',
|
||||
platform: 'codex',
|
||||
enabled: true,
|
||||
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.authorizationUrl).toContain('https://auth.openai.com/oauth/authorize?');
|
||||
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('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 () => {
|
||||
const jwt = buildJwt({
|
||||
email: 'codex-user@example.com',
|
||||
@@ -163,6 +204,9 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
||||
});
|
||||
expect(callbackResponse.statusCode).toBe(200);
|
||||
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({
|
||||
method: 'GET',
|
||||
@@ -284,7 +328,12 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
||||
url: '/api/oauth/connections',
|
||||
});
|
||||
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 () => {
|
||||
@@ -301,6 +350,8 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
||||
accessToken: 'oauth-access-token',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
oauthProvider: 'codex',
|
||||
oauthAccountKey: 'chatgpt-account-123',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
@@ -342,8 +393,8 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
||||
});
|
||||
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
expect(listResponse.json()).toEqual({
|
||||
items: [
|
||||
expect(listResponse.json()).toMatchObject({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
accountId: account.id,
|
||||
provider: 'codex',
|
||||
@@ -353,7 +404,10 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
||||
lastModelSyncAt: '2026-03-17T08:00:00.000Z',
|
||||
lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)',
|
||||
}),
|
||||
],
|
||||
]),
|
||||
total: 1,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
const deleteResponse = await app.inject({
|
||||
|
||||
+147
-35
@@ -1,4 +1,5 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { createRateLimitGuard } from '../../middleware/requestRateLimit.js';
|
||||
import {
|
||||
deleteOauthConnection,
|
||||
getOauthSessionStatus,
|
||||
@@ -9,7 +10,80 @@ import {
|
||||
startOauthRebindFlow,
|
||||
} 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'
|
||||
? 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()
|
||||
: '';
|
||||
const host = forwardedHost
|
||||
|| (typeof request.headers.host === 'string' ? request.headers.host.trim() : '')
|
||||
|| 'localhost';
|
||||
|| (typeof request.headers.host === 'string' ? request.headers.host.trim() : '');
|
||||
if (!host) return undefined;
|
||||
return `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
export async function oauthRoutes(app: FastifyInstance) {
|
||||
app.get('/api/oauth/providers', async () => ({
|
||||
app.get('/api/oauth/providers', { preHandler: [limitOauthProviderRead] }, async () => ({
|
||||
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',
|
||||
{ preHandler: [limitOauthStart] },
|
||||
async (request, reply) => {
|
||||
const rebindAccountId = request.body?.accountId === undefined
|
||||
? undefined
|
||||
: parsePositiveInteger(request.body.accountId);
|
||||
if (request.body?.accountId !== undefined && rebindAccountId === null) {
|
||||
return reply.code(400).send({ message: 'invalid account id' });
|
||||
}
|
||||
const projectId = parseOptionalProjectId(request.body?.projectId);
|
||||
if (request.body?.projectId !== undefined && projectId === null) {
|
||||
return reply.code(400).send({ message: 'invalid project id' });
|
||||
}
|
||||
|
||||
try {
|
||||
return startOauthProviderFlow({
|
||||
return await startOauthProviderFlow({
|
||||
provider: request.params.provider,
|
||||
redirectOrigin: resolveRequestOrigin(request),
|
||||
rebindAccountId: request.body?.accountId,
|
||||
rebindAccountId: rebindAccountId ?? undefined,
|
||||
projectId: projectId ?? undefined,
|
||||
requestOrigin: resolveRequestOrigin(request),
|
||||
});
|
||||
} catch (error: any) {
|
||||
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) => {
|
||||
const session = getOauthSessionStatus(request.params.state);
|
||||
if (!session) {
|
||||
return reply.code(404).send({ message: 'oauth session not found' });
|
||||
}
|
||||
return session;
|
||||
});
|
||||
app.get<{ Params: { state: string } }>(
|
||||
'/api/oauth/sessions/:state',
|
||||
{ preHandler: [limitOauthSessionRead] },
|
||||
async (request, reply) => {
|
||||
const session = getOauthSessionStatus(request.params.state);
|
||||
if (!session) {
|
||||
return reply.code(404).send({ message: 'oauth session not found' });
|
||||
}
|
||||
return session;
|
||||
},
|
||||
);
|
||||
|
||||
app.get('/api/oauth/connections', async () => ({
|
||||
items: await listOauthConnections(),
|
||||
}));
|
||||
app.get<{ Querystring: { limit?: string; offset?: string } }>(
|
||||
'/api/oauth/connections',
|
||||
{ preHandler: [limitOauthConnectionRead] },
|
||||
async (request, reply) => {
|
||||
const limit = request.query.limit === undefined ? undefined : parsePositiveInteger(request.query.limit);
|
||||
const offset = request.query.offset === undefined
|
||||
? undefined
|
||||
: (() => {
|
||||
if (typeof request.query.offset !== 'string') return null;
|
||||
const parsed = Number.parseInt(request.query.offset.trim(), 10);
|
||||
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
|
||||
})();
|
||||
if (request.query.limit !== undefined && limit === null) {
|
||||
return reply.code(400).send({ message: 'invalid limit' });
|
||||
}
|
||||
if (request.query.offset !== undefined && offset === null) {
|
||||
return reply.code(400).send({ message: 'invalid offset' });
|
||||
}
|
||||
return listOauthConnections({
|
||||
limit: limit ?? undefined,
|
||||
offset: offset ?? undefined,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Params: { accountId: string } }>(
|
||||
'/api/oauth/connections/:accountId/rebind',
|
||||
{ preHandler: [limitOauthConnectionMutate] },
|
||||
async (request, reply) => {
|
||||
const accountId = parsePositiveInteger(request.params.accountId);
|
||||
if (accountId === null) {
|
||||
return reply.code(400).send({ message: 'invalid account id' });
|
||||
}
|
||||
try {
|
||||
const accountId = Number.parseInt(request.params.accountId, 10);
|
||||
return await startOauthRebindFlow(accountId, resolveRequestOrigin(request));
|
||||
} catch (error: any) {
|
||||
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 } }>(
|
||||
'/api/oauth/connections/:accountId',
|
||||
{ preHandler: [limitOauthConnectionMutate] },
|
||||
async (request, reply) => {
|
||||
const accountId = parsePositiveInteger(request.params.accountId);
|
||||
if (accountId === null) {
|
||||
return reply.code(400).send({ message: 'invalid account id' });
|
||||
}
|
||||
try {
|
||||
const accountId = Number.parseInt(request.params.accountId, 10);
|
||||
return await deleteOauthConnection(accountId);
|
||||
} catch (error: any) {
|
||||
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 } }>(
|
||||
'/api/oauth/callback/:provider',
|
||||
{ preHandler: [limitOauthCallback] },
|
||||
async (request, reply) => {
|
||||
let message = 'OAuth callback received.';
|
||||
try {
|
||||
@@ -91,21 +212,12 @@ export async function oauthRoutes(app: FastifyInstance) {
|
||||
error: request.query.error,
|
||||
});
|
||||
message = 'OAuth authorization succeeded. You can close this window.';
|
||||
} catch (error: any) {
|
||||
message = `OAuth authorization failed: ${error?.message || 'unknown error'}`;
|
||||
} catch {
|
||||
message = 'OAuth authorization failed. Return to metapi and review the server logs.';
|
||||
}
|
||||
|
||||
reply.type('text/html; charset=utf-8');
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>OAuth Callback</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>window.close();</script>
|
||||
${message}
|
||||
</body>
|
||||
</html>`;
|
||||
});
|
||||
reply.type('text/html; charset=utf-8');
|
||||
return renderCallbackPage(message);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
parseStoredUtcDateTime,
|
||||
toLocalDayKeyFromStoredUtc,
|
||||
} from '../../services/localTimeService.js';
|
||||
import { createRateLimitGuard } from '../../middleware/requestRateLimit.js';
|
||||
|
||||
function parseBooleanFlag(raw?: string): boolean {
|
||||
if (!raw) return false;
|
||||
@@ -36,6 +37,11 @@ function parseBooleanFlag(raw?: string): boolean {
|
||||
|
||||
const MODELS_MARKETPLACE_BASE_TTL_MS = 15_000;
|
||||
const MODELS_MARKETPLACE_PRICING_TTL_MS = 90_000;
|
||||
const limitModelTokenCandidatesRead = createRateLimitGuard({
|
||||
bucket: 'models-token-candidates-read',
|
||||
max: 30,
|
||||
windowMs: 60_000,
|
||||
});
|
||||
|
||||
type ModelsMarketplaceCacheEntry = {
|
||||
expiresAt: number;
|
||||
@@ -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 explicit = (tokenGroup || '').trim();
|
||||
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,
|
||||
} from '../../services/proxyInputFileResolver.js';
|
||||
import {
|
||||
buildCodexOauthProviderHeaders,
|
||||
refreshCodexOauthAccessToken,
|
||||
buildOauthProviderHeaders,
|
||||
} 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;
|
||||
|
||||
@@ -120,6 +126,8 @@ async function handleChatProxyRequest(
|
||||
excludeChannelIds.push(selected.channel.id);
|
||||
|
||||
const modelName = selected.actualModel || requestedModel;
|
||||
const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
|
||||
const isCodexSite = String(selected.site.platform || '').trim().toLowerCase() === 'codex';
|
||||
const endpointCandidates = [
|
||||
...await resolveUpstreamEndpointCandidates(
|
||||
{
|
||||
@@ -135,25 +143,26 @@ async function handleChatProxyRequest(
|
||||
),
|
||||
];
|
||||
const buildProviderHeaders = () => (
|
||||
String(selected.site.platform || '').trim().toLowerCase() === 'codex'
|
||||
? buildCodexOauthProviderHeaders({
|
||||
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
})
|
||||
: {}
|
||||
buildOauthProviderHeaders({
|
||||
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
})
|
||||
);
|
||||
const buildEndpointRequest = (
|
||||
endpoint: 'chat' | 'messages' | 'responses',
|
||||
options: { forceNormalizeClaudeBody?: boolean } = {},
|
||||
) => {
|
||||
const endpointRequest = buildUpstreamEndpointRequest({
|
||||
endpoint,
|
||||
modelName,
|
||||
stream: isStream,
|
||||
tokenValue: selected.tokenValue,
|
||||
sitePlatform: selected.site.platform,
|
||||
siteUrl: selected.site.url,
|
||||
openaiBody: resolvedOpenAiBody,
|
||||
const upstreamStream = isStream || (isCodexSite && endpoint === 'responses');
|
||||
const endpointRequest = buildUpstreamEndpointRequest({
|
||||
endpoint,
|
||||
modelName,
|
||||
stream: upstreamStream,
|
||||
tokenValue: selected.tokenValue,
|
||||
oauthProvider: oauth?.provider,
|
||||
oauthProjectId: oauth?.projectId,
|
||||
sitePlatform: selected.site.platform,
|
||||
siteUrl: selected.site.url,
|
||||
openaiBody: resolvedOpenAiBody,
|
||||
downstreamFormat,
|
||||
claudeOriginalBody,
|
||||
forceNormalizeClaudeBody: options.forceNormalizeClaudeBody,
|
||||
@@ -173,7 +182,7 @@ async function handleChatProxyRequest(
|
||||
modelName,
|
||||
requestedModelHint: requestedModel,
|
||||
sitePlatform: selected.site.platform,
|
||||
isStream,
|
||||
isStream: isStream || isCodexSite,
|
||||
buildRequest: ({ endpoint, forceNormalizeClaudeBody }) => buildEndpointRequest(
|
||||
endpoint,
|
||||
{ forceNormalizeClaudeBody },
|
||||
@@ -188,33 +197,37 @@ async function handleChatProxyRequest(
|
||||
),
|
||||
});
|
||||
const tryRecover = async (ctx: Parameters<NonNullable<typeof endpointStrategy.tryRecover>>[0]) => {
|
||||
if (ctx.response.status === 401 && String(selected.site.platform || '').trim().toLowerCase() === 'codex') {
|
||||
const refreshed = await refreshCodexOauthAccessToken(selected.account.id);
|
||||
selected.tokenValue = refreshed.accessToken;
|
||||
selected.account = {
|
||||
...selected.account,
|
||||
accessToken: refreshed.accessToken,
|
||||
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
|
||||
};
|
||||
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint);
|
||||
const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`;
|
||||
const refreshedResponse = await fetch(
|
||||
refreshedTargetUrl,
|
||||
withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: refreshedRequest.headers,
|
||||
body: JSON.stringify(refreshedRequest.body),
|
||||
}),
|
||||
);
|
||||
if (refreshedResponse.ok) {
|
||||
return {
|
||||
upstream: refreshedResponse,
|
||||
upstreamPath: refreshedRequest.path,
|
||||
if (ctx.response.status === 401 && oauth) {
|
||||
try {
|
||||
const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id);
|
||||
selected.tokenValue = refreshed.accessToken;
|
||||
selected.account = {
|
||||
...selected.account,
|
||||
accessToken: refreshed.accessToken,
|
||||
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
|
||||
};
|
||||
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint);
|
||||
const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`;
|
||||
const refreshedResponse = await fetch(
|
||||
refreshedTargetUrl,
|
||||
withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: refreshedRequest.headers,
|
||||
body: JSON.stringify(refreshedRequest.body),
|
||||
}),
|
||||
);
|
||||
if (refreshedResponse.ok) {
|
||||
return {
|
||||
upstream: refreshedResponse,
|
||||
upstreamPath: refreshedRequest.path,
|
||||
};
|
||||
}
|
||||
ctx.request = refreshedRequest;
|
||||
ctx.response = refreshedResponse;
|
||||
ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error');
|
||||
} catch {
|
||||
return endpointStrategy.tryRecover(ctx);
|
||||
}
|
||||
ctx.request = refreshedRequest;
|
||||
ctx.response = refreshedResponse;
|
||||
ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error');
|
||||
}
|
||||
return endpointStrategy.tryRecover(ctx);
|
||||
};
|
||||
@@ -345,6 +358,9 @@ async function handleChatProxyRequest(
|
||||
} catch {
|
||||
fallbackData = fallbackText;
|
||||
}
|
||||
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
|
||||
fallbackData = unwrapGeminiCliPayload(fallbackData);
|
||||
}
|
||||
streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw);
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
@@ -395,7 +411,10 @@ async function handleChatProxyRequest(
|
||||
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);
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
@@ -446,12 +465,24 @@ async function handleChatProxyRequest(
|
||||
return;
|
||||
}
|
||||
|
||||
const rawText = await upstream.text();
|
||||
let upstreamData: unknown = rawText;
|
||||
try {
|
||||
upstreamData = JSON.parse(rawText);
|
||||
} catch {
|
||||
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
|
||||
let rawText = '';
|
||||
let upstreamData: unknown;
|
||||
if (upstreamContentType.includes('text/event-stream') && successfulUpstreamPath.endsWith('/responses')) {
|
||||
const collected = await collectResponsesFinalPayloadFromSse(upstream, modelName);
|
||||
rawText = collected.rawText;
|
||||
upstreamData = collected.payload;
|
||||
} else {
|
||||
rawText = await upstream.text();
|
||||
upstreamData = rawText;
|
||||
try {
|
||||
upstreamData = JSON.parse(rawText);
|
||||
} catch {
|
||||
upstreamData = rawText;
|
||||
}
|
||||
}
|
||||
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
|
||||
upstreamData = unwrapGeminiCliPayload(upstreamData);
|
||||
}
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
|
||||
@@ -210,6 +210,52 @@ describe('gemini native proxy routes', () => {
|
||||
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 () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
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 () => {
|
||||
authorizeDownstreamTokenMock.mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
@@ -6,11 +6,18 @@ import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||
import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js';
|
||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||
import { buildOauthProviderHeaders } from '../../services/oauth/service.js';
|
||||
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
|
||||
import { getDownstreamRoutingPolicy } from './downstreamPolicy.js';
|
||||
import { composeProxyLogMessage } from './logPathMeta.js';
|
||||
import {
|
||||
geminiGenerateContentTransformer,
|
||||
} from '../../transformers/gemini/generate-content/index.js';
|
||||
import {
|
||||
createGeminiCliStreamReader,
|
||||
unwrapGeminiCliPayload,
|
||||
wrapGeminiCliRequest,
|
||||
} from './geminiCliCompat.js';
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
const GEMINI_MODEL_PROBES = [
|
||||
@@ -19,12 +26,34 @@ const GEMINI_MODEL_PROBES = [
|
||||
'gemini-1.5-flash',
|
||||
'gemini-pro',
|
||||
];
|
||||
const GEMINI_CLI_STATIC_MODELS = [
|
||||
{ name: 'models/gemini-2.5-pro', displayName: 'Gemini 2.5 Pro' },
|
||||
{ name: 'models/gemini-2.5-flash', displayName: 'Gemini 2.5 Flash' },
|
||||
{ name: 'models/gemini-2.5-flash-lite', displayName: 'Gemini 2.5 Flash Lite' },
|
||||
{ name: 'models/gemini-3-pro-preview', displayName: 'Gemini 3 Pro Preview' },
|
||||
{ name: 'models/gemini-3.1-pro-preview', displayName: 'Gemini 3.1 Pro Preview' },
|
||||
{ name: 'models/gemini-3-flash-preview', displayName: 'Gemini 3 Flash Preview' },
|
||||
{ name: 'models/gemini-3.1-flash-lite-preview', displayName: 'Gemini 3.1 Flash Lite Preview' },
|
||||
];
|
||||
const EMPTY_PROXY_USAGE = {
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
};
|
||||
|
||||
function isGeminiCliPlatform(platform: unknown): boolean {
|
||||
return String(platform || '').trim().toLowerCase() === 'gemini-cli';
|
||||
}
|
||||
|
||||
function 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) {
|
||||
const policy = getDownstreamRoutingPolicy(request);
|
||||
for (const candidate of GEMINI_MODEL_PROBES) {
|
||||
@@ -165,6 +194,14 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
||||
excludeChannelIds.push(selected.channel.id);
|
||||
|
||||
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(
|
||||
geminiGenerateContentTransformer.resolveModelsUrl(selected.site.url, apiVersion, selected.tokenValue),
|
||||
{ method: 'GET' },
|
||||
@@ -212,6 +249,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
||||
params: request.params as { geminiApiVersion?: string } | undefined,
|
||||
});
|
||||
const { apiVersion, modelActionPath, isStreamAction, requestedModel } = parsedPath;
|
||||
const isCountTokensAction = modelActionPath.endsWith(':countTokens');
|
||||
if (!requestedModel) {
|
||||
return reply.code(400).send({
|
||||
error: { message: 'Gemini model path is required', type: 'invalid_request_error' },
|
||||
@@ -240,31 +278,72 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
||||
request.body || {},
|
||||
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(
|
||||
/^models\/[^:]+/,
|
||||
`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 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();
|
||||
try {
|
||||
const upstream = await fetch(
|
||||
geminiGenerateContentTransformer.resolveActionUrl(
|
||||
const targetUrl = isGeminiCli
|
||||
? `${selected.site.url}${upstreamPath}`
|
||||
: geminiGenerateContentTransformer.resolveActionUrl(
|
||||
selected.site.url,
|
||||
apiVersion,
|
||||
actualModelAction,
|
||||
selected.tokenValue,
|
||||
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';
|
||||
if (!upstream.ok) {
|
||||
lastStatus = upstream.status;
|
||||
@@ -298,7 +377,10 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
||||
reply.hijack();
|
||||
reply.raw.statusCode = upstream.status;
|
||||
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) {
|
||||
const latency = Date.now() - startTime;
|
||||
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
|
||||
@@ -373,11 +455,14 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
||||
let parsedUsage = EMPTY_PROXY_USAGE;
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
const responsePayload = geminiGenerateContentTransformer.stream.serializeUpstreamJsonPayload(
|
||||
aggregateState,
|
||||
parsed,
|
||||
isStreamAction,
|
||||
);
|
||||
const unwrappedPayload = unwrapGeminiCliPayload(parsed);
|
||||
const responsePayload = isCountTokensAction
|
||||
? unwrappedPayload
|
||||
: geminiGenerateContentTransformer.stream.serializeUpstreamJsonPayload(
|
||||
aggregateState,
|
||||
unwrappedPayload,
|
||||
isStreamAction,
|
||||
);
|
||||
parsedUsage = parseProxyUsage(aggregateState);
|
||||
const latency = Date.now() - startTime;
|
||||
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
|
||||
|
||||
@@ -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,
|
||||
recoveredFromSelfLog: false,
|
||||
}));
|
||||
const refreshCodexAccessTokenMock = vi.fn();
|
||||
const refreshOauthAccessTokenSingleflightMock = vi.fn();
|
||||
const dbInsertMock = vi.fn((_arg?: any) => ({
|
||||
values: () => ({
|
||||
run: () => undefined,
|
||||
@@ -61,13 +61,9 @@ vi.mock('../../services/proxyUsageFallbackService.js', () => ({
|
||||
resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/oauth/service.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../services/oauth/service.js')>('../../services/oauth/service.js');
|
||||
return {
|
||||
...actual,
|
||||
refreshCodexOauthAccessToken: (...args: unknown[]) => refreshCodexAccessTokenMock(...args),
|
||||
};
|
||||
});
|
||||
vi.mock('../../services/oauth/refreshSingleflight.js', () => ({
|
||||
refreshOauthAccessTokenSingleflight: (...args: unknown[]) => refreshOauthAccessTokenSingleflightMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../db/index.js', () => ({
|
||||
db: {
|
||||
@@ -82,6 +78,21 @@ vi.mock('../../db/index.js', () => ({
|
||||
describe('responses proxy codex oauth refresh', () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
const createSseResponse = (chunks: string[], status = 200) => {
|
||||
const encoder = new TextEncoder();
|
||||
return new Response(new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
}), {
|
||||
status,
|
||||
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
|
||||
});
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const { responsesProxyRoute } = await import('./responses.js');
|
||||
app = Fastify();
|
||||
@@ -98,7 +109,7 @@ describe('responses proxy codex oauth refresh', () => {
|
||||
reportProxyAllFailedMock.mockReset();
|
||||
reportTokenExpiredMock.mockReset();
|
||||
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
|
||||
refreshCodexAccessTokenMock.mockReset();
|
||||
refreshOauthAccessTokenSingleflightMock.mockReset();
|
||||
dbInsertMock.mockClear();
|
||||
|
||||
selectChannelMock.mockReturnValue({
|
||||
@@ -122,7 +133,7 @@ describe('responses proxy codex oauth refresh', () => {
|
||||
actualModel: 'gpt-5.2-codex',
|
||||
});
|
||||
selectNextChannelMock.mockReturnValue(null);
|
||||
refreshCodexAccessTokenMock.mockResolvedValue({
|
||||
refreshOauthAccessTokenSingleflightMock.mockResolvedValue({
|
||||
accessToken: 'fresh-access-token',
|
||||
accountId: 33,
|
||||
accountKey: 'chatgpt-account-123',
|
||||
@@ -163,7 +174,7 @@ describe('responses proxy codex oauth refresh', () => {
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(refreshCodexAccessTokenMock).toHaveBeenCalledWith(33);
|
||||
expect(refreshOauthAccessTokenSingleflightMock).toHaveBeenCalledWith(33);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
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 forwardedBody = JSON.parse(options.body);
|
||||
expect(forwardedBody.instructions).toBe('');
|
||||
expect(forwardedBody.stream).toBe(true);
|
||||
expect(forwardedBody.input).toEqual([
|
||||
{
|
||||
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,
|
||||
} from '../../services/proxyInputFileResolver.js';
|
||||
import {
|
||||
buildCodexOauthProviderHeaders,
|
||||
refreshCodexOauthAccessToken,
|
||||
buildOauthProviderHeaders,
|
||||
} 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;
|
||||
|
||||
@@ -180,6 +186,8 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
excludeChannelIds.push(selected.channel.id);
|
||||
|
||||
const modelName = selected.actualModel || requestedModel;
|
||||
const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
|
||||
const isCodexSite = String(selected.site.platform || '').trim().toLowerCase() === 'codex';
|
||||
const owner = getProxyResourceOwner(request);
|
||||
let normalizedResponsesBody: Record<string, unknown> = {
|
||||
...requestEnvelope.parsed.normalizedBody,
|
||||
@@ -233,19 +241,20 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
endpointCandidates.push('responses', 'chat', 'messages');
|
||||
}
|
||||
const buildProviderHeaders = () => (
|
||||
String(selected.site.platform || '').trim().toLowerCase() === 'codex'
|
||||
? buildCodexOauthProviderHeaders({
|
||||
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
})
|
||||
: {}
|
||||
buildOauthProviderHeaders({
|
||||
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
})
|
||||
);
|
||||
const buildEndpointRequest = (endpoint: 'chat' | 'messages' | 'responses') => {
|
||||
const upstreamStream = isStream || (isCodexSite && endpoint === 'responses');
|
||||
const endpointRequest = buildUpstreamEndpointRequest({
|
||||
endpoint,
|
||||
modelName,
|
||||
stream: isStream,
|
||||
stream: upstreamStream,
|
||||
tokenValue: selected.tokenValue,
|
||||
oauthProvider: oauth?.provider,
|
||||
oauthProjectId: oauth?.projectId,
|
||||
sitePlatform: selected.site.platform,
|
||||
siteUrl: selected.site.url,
|
||||
openaiBody: openAiBody,
|
||||
@@ -267,7 +276,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
};
|
||||
};
|
||||
const endpointStrategy = openAiResponsesTransformer.compatibility.createEndpointStrategy({
|
||||
isStream,
|
||||
isStream: isStream || isCodexSite,
|
||||
requiresNativeResponsesFileUrl,
|
||||
dispatchRequest: (compatibilityRequest, targetUrl) => fetch(
|
||||
targetUrl ?? `${selected.site.url}${compatibilityRequest.path}`,
|
||||
@@ -279,33 +288,37 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
),
|
||||
});
|
||||
const tryRecover = async (ctx: Parameters<NonNullable<typeof endpointStrategy.tryRecover>>[0]) => {
|
||||
if (ctx.response.status === 401 && String(selected.site.platform || '').trim().toLowerCase() === 'codex') {
|
||||
const refreshed = await refreshCodexOauthAccessToken(selected.account.id);
|
||||
selected.tokenValue = refreshed.accessToken;
|
||||
selected.account = {
|
||||
...selected.account,
|
||||
accessToken: refreshed.accessToken,
|
||||
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
|
||||
};
|
||||
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint);
|
||||
const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`;
|
||||
const refreshedResponse = await fetch(
|
||||
refreshedTargetUrl,
|
||||
withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: refreshedRequest.headers,
|
||||
body: JSON.stringify(refreshedRequest.body),
|
||||
}),
|
||||
);
|
||||
if (refreshedResponse.ok) {
|
||||
return {
|
||||
upstream: refreshedResponse,
|
||||
upstreamPath: refreshedRequest.path,
|
||||
if (ctx.response.status === 401 && oauth) {
|
||||
try {
|
||||
const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id);
|
||||
selected.tokenValue = refreshed.accessToken;
|
||||
selected.account = {
|
||||
...selected.account,
|
||||
accessToken: refreshed.accessToken,
|
||||
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
|
||||
};
|
||||
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint);
|
||||
const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`;
|
||||
const refreshedResponse = await fetch(
|
||||
refreshedTargetUrl,
|
||||
withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: refreshedRequest.headers,
|
||||
body: JSON.stringify(refreshedRequest.body),
|
||||
}),
|
||||
);
|
||||
if (refreshedResponse.ok) {
|
||||
return {
|
||||
upstream: refreshedResponse,
|
||||
upstreamPath: refreshedRequest.path,
|
||||
};
|
||||
}
|
||||
ctx.request = refreshedRequest;
|
||||
ctx.response = refreshedResponse;
|
||||
ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error');
|
||||
} catch {
|
||||
return endpointStrategy.tryRecover(ctx);
|
||||
}
|
||||
ctx.request = refreshedRequest;
|
||||
ctx.response = refreshedResponse;
|
||||
ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error');
|
||||
}
|
||||
return endpointStrategy.tryRecover(ctx);
|
||||
};
|
||||
@@ -386,8 +399,8 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
return reply.code(status).send({ error: { message: errText, type: 'upstream_error' } });
|
||||
}
|
||||
|
||||
const upstream = endpointResult.upstream;
|
||||
const successfulUpstreamPath = endpointResult.upstreamPath;
|
||||
const upstream = endpointResult.upstream;
|
||||
const successfulUpstreamPath = endpointResult.upstreamPath;
|
||||
|
||||
if (isStream) {
|
||||
reply.hijack();
|
||||
@@ -397,7 +410,10 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
reply.raw.setHeader('Connection', 'keep-alive');
|
||||
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
||||
|
||||
const reader = upstream.body?.getReader();
|
||||
const upstreamReader = upstream.body?.getReader();
|
||||
const reader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader
|
||||
? createGeminiCliStreamReader(upstreamReader)
|
||||
: upstreamReader;
|
||||
let parsedUsage: UsageSummary = {
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
@@ -460,12 +476,24 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawText = await upstream.text();
|
||||
let upstreamData: unknown = rawText;
|
||||
try {
|
||||
upstreamData = JSON.parse(rawText);
|
||||
} catch {
|
||||
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
|
||||
let rawText = '';
|
||||
let upstreamData: unknown;
|
||||
if (upstreamContentType.includes('text/event-stream') && successfulUpstreamPath.endsWith('/responses')) {
|
||||
const collected = await collectResponsesFinalPayloadFromSse(upstream, modelName);
|
||||
rawText = collected.rawText;
|
||||
upstreamData = collected.payload;
|
||||
} else {
|
||||
rawText = await upstream.text();
|
||||
upstreamData = rawText;
|
||||
try {
|
||||
upstreamData = JSON.parse(rawText);
|
||||
} catch {
|
||||
upstreamData = rawText;
|
||||
}
|
||||
}
|
||||
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
|
||||
upstreamData = unwrapGeminiCliPayload(upstreamData);
|
||||
}
|
||||
const latency = Date.now() - startTime;
|
||||
const parsedUsage = parseProxyUsage(upstreamData);
|
||||
|
||||
@@ -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: {
|
||||
model: 'gpt-5.2-codex',
|
||||
messages: [{ role: 'user', content: 'hello codex' }],
|
||||
temperature: 0.2,
|
||||
top_p: 0.9,
|
||||
user: 'drop-me',
|
||||
service_tier: 'auto',
|
||||
},
|
||||
downstreamFormat: 'openai',
|
||||
providerHeaders: {
|
||||
@@ -352,6 +356,125 @@ describe('buildUpstreamEndpointRequest', () => {
|
||||
expect(request.headers.Authorization).toBe('Bearer oauth-access-token');
|
||||
expect(request.headers.Originator).toBe('codex_cli_rs');
|
||||
expect(request.headers['Chatgpt-Account-Id']).toBe('chatgpt-account-123');
|
||||
expect(request.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', () => {
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
convertOpenAiBodyToAnthropicMessagesBody,
|
||||
sanitizeAnthropicMessagesBody,
|
||||
} from '../../transformers/anthropic/messages/conversion.js';
|
||||
import {
|
||||
buildGeminiGenerateContentRequestFromOpenAi,
|
||||
wrapGeminiCliRequest,
|
||||
} from './geminiCliCompat.js';
|
||||
export {
|
||||
buildMinimalJsonHeadersForCompatibility,
|
||||
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[] {
|
||||
const raw = asTrimmedString(value).toLowerCase();
|
||||
@@ -258,6 +328,10 @@ function preferredEndpointOrder(
|
||||
return ['chat'];
|
||||
}
|
||||
|
||||
if (platform === 'gemini-cli') {
|
||||
return ['chat'];
|
||||
}
|
||||
|
||||
if (platform === 'openai') {
|
||||
if (preferMessagesForClaudeModel && downstreamFormat !== 'responses') {
|
||||
// Some OpenAI-compatible gateways expose Claude natively via /v1/messages.
|
||||
@@ -438,6 +512,8 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
modelName: string;
|
||||
stream: boolean;
|
||||
tokenValue: string;
|
||||
oauthProvider?: string;
|
||||
oauthProjectId?: string;
|
||||
sitePlatform?: string;
|
||||
siteUrl?: string;
|
||||
openaiBody: Record<string, unknown>;
|
||||
@@ -455,6 +531,8 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
const sitePlatform = normalizePlatformName(input.sitePlatform);
|
||||
const isClaudeUpstream = sitePlatform === 'claude';
|
||||
const isGeminiUpstream = sitePlatform === 'gemini';
|
||||
const isGeminiCliUpstream = sitePlatform === 'gemini-cli';
|
||||
const isClaudeOauthUpstream = isClaudeUpstream && input.oauthProvider === 'claude';
|
||||
|
||||
const resolveGeminiEndpointPath = (endpoint: UpstreamEndpoint): string => {
|
||||
const normalizedSiteUrl = asTrimmedString(input.siteUrl).toLowerCase();
|
||||
@@ -484,6 +562,12 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
return '/responses';
|
||||
}
|
||||
|
||||
if (sitePlatform === 'gemini-cli') {
|
||||
return input.stream
|
||||
? '/v1internal:streamGenerateContent?alt=sse'
|
||||
: '/v1internal:generateContent';
|
||||
}
|
||||
|
||||
if (sitePlatform === 'claude') {
|
||||
return '/v1/messages';
|
||||
}
|
||||
@@ -505,7 +589,7 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
|
||||
const stripGeminiUnsupportedFields = (body: Record<string, unknown>) => {
|
||||
const next = { ...body };
|
||||
if (isGeminiUpstream) {
|
||||
if (isGeminiUpstream || isGeminiCliUpstream) {
|
||||
for (const key of [
|
||||
'frequency_penalty',
|
||||
'presence_penalty',
|
||||
@@ -522,6 +606,34 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
|
||||
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') {
|
||||
const claudeHeaders = input.downstreamFormat === 'claude'
|
||||
? extractClaudePassthroughHeaders(input.downstreamHeaders)
|
||||
@@ -562,8 +674,10 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
const headers = ensureStreamAcceptHeader({
|
||||
...commonHeaders,
|
||||
...claudeHeaders,
|
||||
'x-api-key': input.tokenValue,
|
||||
'anthropic-version': anthropicVersion,
|
||||
...(isClaudeOauthUpstream
|
||||
? { Authorization: `Bearer ${input.tokenValue}` }
|
||||
: { 'x-api-key': input.tokenValue }),
|
||||
}, input.stream);
|
||||
|
||||
return {
|
||||
@@ -586,8 +700,14 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
}
|
||||
: convertOpenAiBodyToResponsesBodyViaTransformer(openaiBody, input.modelName, input.stream)
|
||||
);
|
||||
const body = ensureCodexResponsesInstructions(
|
||||
sanitizeResponsesBodyForProxyViaTransformer(rawBody, input.modelName, input.stream),
|
||||
const body = ensureCodexResponsesStoreFalse(
|
||||
ensureCodexResponsesInstructions(
|
||||
applyCodexResponsesCompatibility(
|
||||
sanitizeResponsesBodyForProxyViaTransformer(rawBody, input.modelName, input.stream),
|
||||
sitePlatform,
|
||||
),
|
||||
sitePlatform,
|
||||
),
|
||||
sitePlatform,
|
||||
);
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
mergeAccountExtraConfig,
|
||||
normalizeCredentialMode,
|
||||
resolvePlatformUserId,
|
||||
requiresManagedAccountTokens,
|
||||
supportsDirectAccountRoutingConnection,
|
||||
} from './accountExtraConfig.js';
|
||||
|
||||
describe('accountExtraConfig', () => {
|
||||
@@ -67,4 +69,40 @@ describe('accountExtraConfig', () => {
|
||||
sub2apiAuth: { refreshToken: ' ' },
|
||||
}))).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 {
|
||||
const credentialMode = getCredentialModeFromExtraConfig(account.extraConfig);
|
||||
if (hasOauthProvider(account.extraConfig)) {
|
||||
return hasCredentialValue(account.accessToken) || hasCredentialValue(account.apiToken);
|
||||
}
|
||||
if (credentialMode === 'apikey') {
|
||||
return hasCredentialValue(account.apiToken);
|
||||
}
|
||||
if (hasOauthProvider(account.extraConfig)) {
|
||||
return hasCredentialValue(account.accessToken);
|
||||
if (credentialMode === 'session') {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
if (hasCredentialValue(account.accessToken)) return false;
|
||||
return hasCredentialValue(account.apiToken);
|
||||
}
|
||||
|
||||
export function requiresManagedAccountTokens(account: DirectAccountRoutingInput): boolean {
|
||||
const credentialMode = getCredentialModeFromExtraConfig(account.extraConfig);
|
||||
if (credentialMode === 'apikey') 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -215,6 +215,9 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
|
||||
username,
|
||||
accessToken: accountAccessToken,
|
||||
apiToken,
|
||||
oauthProvider: null,
|
||||
oauthAccountKey: null,
|
||||
oauthProjectId: null,
|
||||
balance: importedBalance,
|
||||
balanceUsed: importedUsed,
|
||||
quota: importedQuota > 0 ? importedQuota : importedBalance,
|
||||
|
||||
@@ -19,12 +19,40 @@ import { invalidateTokenRouterCache } from './tokenRouter.js';
|
||||
import { setAccountRuntimeHealth } from './accountHealthService.js';
|
||||
import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.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 MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
|
||||
const MODEL_REFRESH_BATCH_SIZE = 3;
|
||||
const CODEX_MODELS_CLIENT_VERSION = '1.0.0';
|
||||
const CLAUDE_STATIC_MODELS = [
|
||||
'claude-haiku-4-5-20251001',
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'claude-sonnet-4-6',
|
||||
'claude-opus-4-6',
|
||||
'claude-opus-4-5-20251101',
|
||||
'claude-opus-4-1-20250805',
|
||||
'claude-opus-4-20250514',
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-3-7-sonnet-20250219',
|
||||
'claude-3-5-haiku-20241022',
|
||||
];
|
||||
const GEMINI_CLI_STATIC_MODELS = [
|
||||
'gemini-2.5-pro',
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-flash-lite',
|
||||
'gemini-3-pro-preview',
|
||||
'gemini-3.1-pro-preview',
|
||||
'gemini-3-flash-preview',
|
||||
'gemini-3.1-flash-lite-preview',
|
||||
];
|
||||
|
||||
type ModelRefreshErrorCode = 'timeout' | 'unauthorized' | 'empty_models' | 'unknown';
|
||||
type ModelRefreshSkipCode = 'site_disabled' | 'adapter_or_status';
|
||||
@@ -156,16 +184,18 @@ function extractCodexModelIds(payload: unknown): string[] {
|
||||
});
|
||||
}
|
||||
|
||||
async function updateCodexOauthModelDiscoveryState(input: {
|
||||
async function updateOauthModelDiscoveryState(input: {
|
||||
account: typeof schema.accounts.$inferSelect;
|
||||
checkedAt: string;
|
||||
status: 'healthy' | 'abnormal';
|
||||
lastModelSyncError?: 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, {
|
||||
oauth: buildCodexOauthInfo(input.account.extraConfig, {
|
||||
oauth: buildOauthInfo(input.account.extraConfig, {
|
||||
provider: oauth.provider,
|
||||
modelDiscoveryStatus: input.status,
|
||||
lastModelSyncAt: input.checkedAt,
|
||||
lastModelSyncError: input.lastModelSyncError,
|
||||
@@ -176,7 +206,6 @@ async function updateCodexOauthModelDiscoveryState(input: {
|
||||
extraConfig,
|
||||
updatedAt: input.checkedAt,
|
||||
}).where(eq(schema.accounts.id, input.account.id)).run();
|
||||
input.account.extraConfig = extraConfig;
|
||||
return extraConfig;
|
||||
}
|
||||
|
||||
@@ -209,6 +238,65 @@ async function discoverCodexModelsFromCloud(input: {
|
||||
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 {
|
||||
const normalized = modelPattern.trim();
|
||||
if (!normalized) return false;
|
||||
@@ -316,6 +404,7 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
|
||||
|
||||
const account = row.accounts;
|
||||
const site = row.sites;
|
||||
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
|
||||
const adapter = getAdapter(site.platform);
|
||||
|
||||
const accountTokens = await db.select()
|
||||
@@ -337,11 +426,11 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
|
||||
return buildSkippedRefreshResult(accountId, 'site_disabled', '站点已禁用');
|
||||
}
|
||||
|
||||
if (!adapter || account.status !== 'active') {
|
||||
if (account.status !== 'active') {
|
||||
return buildSkippedRefreshResult(accountId, 'adapter_or_status', '平台不可用或账号未激活');
|
||||
}
|
||||
|
||||
if (isCodexPlatform(account)) {
|
||||
if (oauth?.provider === 'codex') {
|
||||
const checkedAt = new Date().toISOString();
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
@@ -363,7 +452,7 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
|
||||
checkedAt,
|
||||
})),
|
||||
).run();
|
||||
await updateCodexOauthModelDiscoveryState({
|
||||
await updateOauthModelDiscoveryState({
|
||||
account,
|
||||
checkedAt,
|
||||
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 errorCode = classifyModelDiscoveryError(rawMessage);
|
||||
const errorMessage = `Codex 模型获取失败(${rawMessage})`;
|
||||
await updateCodexOauthModelDiscoveryState({
|
||||
await updateOauthModelDiscoveryState({
|
||||
account,
|
||||
checkedAt,
|
||||
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);
|
||||
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 {
|
||||
buildOauthInfo,
|
||||
getOauthInfoFromExtraConfig,
|
||||
isOauthProvider,
|
||||
type OauthInfo,
|
||||
} from './oauthAccount.js';
|
||||
|
||||
type ParsedOauthInfo = {
|
||||
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 = {
|
||||
export type CodexOauthInfo = OauthInfo & {
|
||||
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 {
|
||||
const parsed = parseExtraConfig(extraConfig).oauth;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||
const provider = asTrimmedString(parsed.provider);
|
||||
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),
|
||||
};
|
||||
const oauth = getOauthInfoFromExtraConfig(extraConfig);
|
||||
if (!oauth || oauth.provider !== 'codex') return null;
|
||||
return oauth as CodexOauthInfo;
|
||||
}
|
||||
|
||||
export function isCodexPlatform(account: Pick<typeof schema.accounts.$inferSelect, 'extraConfig'> | string | null | undefined): boolean {
|
||||
const extraConfig = typeof account === 'string' || account == null
|
||||
? account
|
||||
: account.extraConfig;
|
||||
return getCodexOauthInfoFromExtraConfig(extraConfig)?.provider === 'codex';
|
||||
export function isCodexPlatform(
|
||||
account: Pick<typeof schema.accounts.$inferSelect, 'extraConfig'> | string | null | undefined,
|
||||
): boolean {
|
||||
return isOauthProvider(account, 'codex');
|
||||
}
|
||||
|
||||
export function buildCodexOauthInfo(
|
||||
extraConfig?: string | null,
|
||||
patch: Partial<CodexOauthInfo> = {},
|
||||
): CodexOauthInfo {
|
||||
const current = getCodexOauthInfoFromExtraConfig(extraConfig);
|
||||
return {
|
||||
provider: 'codex',
|
||||
...(current || {}),
|
||||
...patch,
|
||||
};
|
||||
return buildOauthInfo(extraConfig, { provider: 'codex', ...patch }) as CodexOauthInfo;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,51 @@
|
||||
import { fetch } from 'undici';
|
||||
import { config } from '../../config.js';
|
||||
import { createPkceChallenge } from './sessionStore.js';
|
||||
import type { OAuthProviderDefinition } from './providers.js';
|
||||
|
||||
export const CODEX_OAUTH_PROVIDER = 'codex';
|
||||
export const CODEX_AUTH_URL = 'https://auth.openai.com/oauth/authorize';
|
||||
export const CODEX_TOKEN_URL = 'https://auth.openai.com/oauth/token';
|
||||
export const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
||||
export const CODEX_CLIENT_ID = config.codexClientId;
|
||||
export const CODEX_CALLBACK_PATH = '/api/oauth/callback/codex';
|
||||
export const CODEX_LOOPBACK_CALLBACK_PATH = '/auth/callback';
|
||||
export const CODEX_LOOPBACK_CALLBACK_PORT = 1455;
|
||||
export const CODEX_LOOPBACK_REDIRECT_URI = `http://localhost:${CODEX_LOOPBACK_CALLBACK_PORT}${CODEX_LOOPBACK_CALLBACK_PATH}`;
|
||||
export const CODEX_UPSTREAM_BASE_URL = 'https://chatgpt.com/backend-api/codex';
|
||||
|
||||
function requireCodexClientId(): string {
|
||||
if (!CODEX_CLIENT_ID) {
|
||||
throw new Error('CODEX_CLIENT_ID is not configured');
|
||||
}
|
||||
return CODEX_CLIENT_ID;
|
||||
}
|
||||
|
||||
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 = {
|
||||
email?: unknown;
|
||||
'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;
|
||||
redirectUri: string;
|
||||
codeVerifier: string;
|
||||
}): string {
|
||||
}): Promise<string> {
|
||||
const params = new URLSearchParams({
|
||||
client_id: CODEX_CLIENT_ID,
|
||||
client_id: requireCodexClientId(),
|
||||
response_type: 'code',
|
||||
redirect_uri: input.redirectUri,
|
||||
scope: 'openid email profile offline_access',
|
||||
state: input.state,
|
||||
code_challenge: createPkceChallenge(input.codeVerifier),
|
||||
code_challenge: await createPkceChallenge(input.codeVerifier),
|
||||
code_challenge_method: 'S256',
|
||||
prompt: 'login',
|
||||
id_token_add_organizations: 'true',
|
||||
@@ -116,7 +151,7 @@ export async function exchangeCodexAuthorizationCode(input: {
|
||||
}): Promise<CodexTokenExchangeResult> {
|
||||
return exchangeCodexToken(new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: CODEX_CLIENT_ID,
|
||||
client_id: requireCodexClientId(),
|
||||
code: input.code,
|
||||
redirect_uri: input.redirectUri,
|
||||
code_verifier: input.codeVerifier,
|
||||
@@ -125,9 +160,101 @@ export async function exchangeCodexAuthorizationCode(input: {
|
||||
|
||||
export async function refreshCodexTokens(refreshToken: string): Promise<CodexTokenExchangeResult> {
|
||||
return exchangeCodexToken(new URLSearchParams({
|
||||
client_id: CODEX_CLIENT_ID,
|
||||
client_id: requireCodexClientId(),
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
scope: 'openid profile email',
|
||||
}));
|
||||
}
|
||||
|
||||
function getHeaderValue(headers: Record<string, unknown> | undefined, key: string): string | undefined {
|
||||
if (!headers) return undefined;
|
||||
const loweredKey = key.toLowerCase();
|
||||
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
||||
if (rawKey.toLowerCase() !== loweredKey) continue;
|
||||
if (typeof rawValue === 'string') {
|
||||
const trimmed = rawValue.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
if (Array.isArray(rawValue)) {
|
||||
for (const item of rawValue) {
|
||||
if (typeof item !== 'string') continue;
|
||||
const trimmed = item.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const codexOauthProvider: OAuthProviderDefinition = {
|
||||
metadata: {
|
||||
provider: CODEX_OAUTH_PROVIDER,
|
||||
label: 'Codex',
|
||||
platform: 'codex',
|
||||
enabled: true,
|
||||
loginType: 'oauth',
|
||||
requiresProjectId: false,
|
||||
supportsDirectAccountRouting: true,
|
||||
supportsCloudValidation: true,
|
||||
supportsNativeProxy: true,
|
||||
},
|
||||
site: {
|
||||
name: 'ChatGPT Codex OAuth',
|
||||
url: CODEX_UPSTREAM_BASE_URL,
|
||||
platform: 'codex',
|
||||
},
|
||||
loopback: {
|
||||
host: '127.0.0.1',
|
||||
port: CODEX_LOOPBACK_CALLBACK_PORT,
|
||||
path: CODEX_LOOPBACK_CALLBACK_PATH,
|
||||
redirectUri: CODEX_LOOPBACK_REDIRECT_URI,
|
||||
},
|
||||
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 {
|
||||
startCodexLoopbackCallbackServer,
|
||||
stopCodexLoopbackCallbackServer,
|
||||
startOAuthLoopbackCallbackServer,
|
||||
stopOAuthLoopbackCallbackServers,
|
||||
} from './localCallbackServer.js';
|
||||
|
||||
describe('codex loopback callback server', () => {
|
||||
describe('oauth loopback callback server', () => {
|
||||
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 started = await startCodexLoopbackCallbackServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
const started = await startOAuthLoopbackCallbackServer('codex', {
|
||||
callbackHandler,
|
||||
});
|
||||
|
||||
@@ -30,20 +28,19 @@ describe('codex loopback callback server', () => {
|
||||
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 () => {
|
||||
throw new Error('oauth failed');
|
||||
});
|
||||
const started = await startCodexLoopbackCallbackServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
const started = await startOAuthLoopbackCallbackServer('claude', {
|
||||
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();
|
||||
|
||||
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 { type AddressInfo } from 'node:net';
|
||||
import {
|
||||
CODEX_LOOPBACK_CALLBACK_PATH,
|
||||
CODEX_LOOPBACK_CALLBACK_PORT,
|
||||
CODEX_OAUTH_PROVIDER,
|
||||
} from './codexProvider.js';
|
||||
import { getOAuthProviderDefinition, listOAuthProviderDefinitions } from './providers.js';
|
||||
import { handleOauthCallback } from './service.js';
|
||||
|
||||
type CallbackHandler = typeof handleOauthCallback;
|
||||
|
||||
type StartCodexLoopbackCallbackServerOptions = {
|
||||
port?: number;
|
||||
type StartOAuthLoopbackCallbackServerOptions = {
|
||||
host?: string;
|
||||
port?: number;
|
||||
callbackHandler?: CallbackHandler;
|
||||
};
|
||||
|
||||
export type CodexLoopbackCallbackServerState = {
|
||||
export type OAuthLoopbackCallbackServerState = {
|
||||
provider: string;
|
||||
attempted: boolean;
|
||||
ready: boolean;
|
||||
host?: string;
|
||||
port: number;
|
||||
path: string;
|
||||
origin: string;
|
||||
redirectUri: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_STATE: CodexLoopbackCallbackServerState = {
|
||||
attempted: false,
|
||||
ready: false,
|
||||
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;
|
||||
const servers = new Map<string, Server>();
|
||||
const states = new Map<string, OAuthLoopbackCallbackServerState>();
|
||||
const startPromises = new Map<string, Promise<OAuthLoopbackCallbackServerState>>();
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
@@ -50,7 +42,7 @@ function renderCompletionPage(message: string): string {
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Codex OAuth Callback</title>
|
||||
<title>OAuth Callback</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>window.close();</script>
|
||||
@@ -67,37 +59,6 @@ function respondHtml(response: ServerResponse, statusCode: number, message: stri
|
||||
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 {
|
||||
if (!host || host === '::' || host === '0.0.0.0') {
|
||||
return `http://localhost:${port}`;
|
||||
@@ -108,76 +69,177 @@ function normalizeOrigin(host: string | undefined, port: number): string {
|
||||
return `http://${host}:${port}`;
|
||||
}
|
||||
|
||||
export function getCodexLoopbackCallbackServerState(): CodexLoopbackCallbackServerState {
|
||||
return { ...callbackServerState };
|
||||
function createDefaultState(provider: string): OAuthLoopbackCallbackServerState {
|
||||
const definition = getOAuthProviderDefinition(provider);
|
||||
if (!definition) {
|
||||
throw new Error(`unsupported oauth provider: ${provider}`);
|
||||
}
|
||||
return {
|
||||
provider,
|
||||
attempted: false,
|
||||
ready: false,
|
||||
host: definition.loopback.host,
|
||||
port: definition.loopback.port,
|
||||
path: definition.loopback.path,
|
||||
origin: normalizeOrigin(definition.loopback.host, definition.loopback.port),
|
||||
redirectUri: definition.loopback.redirectUri,
|
||||
};
|
||||
}
|
||||
|
||||
export async function startCodexLoopbackCallbackServer(
|
||||
options: StartCodexLoopbackCallbackServerOptions = {},
|
||||
): Promise<CodexLoopbackCallbackServerState> {
|
||||
if (callbackServer) {
|
||||
return getCodexLoopbackCallbackServerState();
|
||||
async function handleCallbackRequest(
|
||||
provider: string,
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
callbackHandler: CallbackHandler,
|
||||
) {
|
||||
const definition = getOAuthProviderDefinition(provider);
|
||||
if (!definition) {
|
||||
response.writeHead(404);
|
||||
response.end('Not found');
|
||||
return;
|
||||
}
|
||||
if (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 requestedPort = options.port ?? CODEX_LOOPBACK_CALLBACK_PORT;
|
||||
const requestedHost = options.host;
|
||||
|
||||
startPromise = new Promise<CodexLoopbackCallbackServerState>((resolve, reject) => {
|
||||
const host = options.host || definition.loopback.host;
|
||||
const port = options.port ?? definition.loopback.port;
|
||||
const { path, redirectUri } = definition.loopback;
|
||||
const startPromise = new Promise<OAuthLoopbackCallbackServerState>((resolve, reject) => {
|
||||
const server = createServer((request, response) => {
|
||||
void handleCallbackRequest(request, response, callbackHandler);
|
||||
void handleCallbackRequest(provider, request, response, callbackHandler);
|
||||
});
|
||||
|
||||
const finalizeFailure = (error: Error) => {
|
||||
callbackServerState = {
|
||||
const failedState: OAuthLoopbackCallbackServerState = {
|
||||
provider,
|
||||
attempted: true,
|
||||
ready: false,
|
||||
host: requestedHost,
|
||||
port: requestedPort,
|
||||
origin: normalizeOrigin(requestedHost, requestedPort),
|
||||
error: error.message || 'failed to start codex oauth callback server',
|
||||
host,
|
||||
port,
|
||||
path,
|
||||
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);
|
||||
};
|
||||
|
||||
server.once('error', (error) => {
|
||||
finalizeFailure(error as Error);
|
||||
});
|
||||
const onStartupError = (error: Error) => {
|
||||
finalizeFailure(error);
|
||||
};
|
||||
|
||||
server.listen(requestedPort, requestedHost, () => {
|
||||
server.removeAllListeners('error');
|
||||
callbackServer = server;
|
||||
server.once('error', onStartupError);
|
||||
server.listen(port, host, () => {
|
||||
server.off('error', onStartupError);
|
||||
servers.set(provider, server);
|
||||
const address = server.address() as AddressInfo | null;
|
||||
const port = address?.port || requestedPort;
|
||||
const host = address?.address || requestedHost;
|
||||
callbackServerState = {
|
||||
const listeningPort = address?.port || port;
|
||||
const listeningHost = address?.address || host;
|
||||
const nextState: OAuthLoopbackCallbackServerState = {
|
||||
provider,
|
||||
attempted: true,
|
||||
ready: true,
|
||||
host,
|
||||
port,
|
||||
origin: normalizeOrigin(host, port),
|
||||
host: listeningHost,
|
||||
port: listeningPort,
|
||||
path,
|
||||
origin: normalizeOrigin(listeningHost, listeningPort),
|
||||
redirectUri,
|
||||
};
|
||||
resolve(getCodexLoopbackCallbackServerState());
|
||||
states.set(provider, nextState);
|
||||
resolve(getOAuthLoopbackCallbackServerState(provider));
|
||||
});
|
||||
}).finally(() => {
|
||||
startPromise = null;
|
||||
startPromises.delete(provider);
|
||||
});
|
||||
|
||||
startPromises.set(provider, startPromise);
|
||||
return startPromise;
|
||||
}
|
||||
|
||||
export async function stopCodexLoopbackCallbackServer(): Promise<void> {
|
||||
const server = callbackServer;
|
||||
callbackServer = null;
|
||||
callbackServerState = { ...DEFAULT_STATE };
|
||||
|
||||
if (!server) return;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
export async function startOAuthLoopbackCallbackServers(
|
||||
options: StartOAuthLoopbackCallbackServerOptions = {},
|
||||
): Promise<OAuthLoopbackCallbackServerState[]> {
|
||||
const results = await Promise.allSettled(
|
||||
listOAuthProviderDefinitions().map((provider) =>
|
||||
startOAuthLoopbackCallbackServer(provider.metadata.provider, options)),
|
||||
);
|
||||
return results.map((result, index) => {
|
||||
if (result.status === 'fulfilled') return result.value;
|
||||
return getOAuthLoopbackCallbackServerState(listOAuthProviderDefinitions()[index]!.metadata.provider);
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopOAuthLoopbackCallbackServers(): Promise<void> {
|
||||
const activeServers = Array.from(servers.entries());
|
||||
servers.clear();
|
||||
states.clear();
|
||||
startPromises.clear();
|
||||
|
||||
await Promise.all(activeServers.map(async ([provider, server]) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
}).catch(() => undefined);
|
||||
states.set(provider, createDefaultState(provider));
|
||||
}));
|
||||
}
|
||||
|
||||
export async function startCodexLoopbackCallbackServer(
|
||||
options: StartOAuthLoopbackCallbackServerOptions = {},
|
||||
): Promise<OAuthLoopbackCallbackServerState> {
|
||||
return startOAuthLoopbackCallbackServer('codex', options);
|
||||
}
|
||||
|
||||
export async function stopCodexLoopbackCallbackServer(): Promise<void> {
|
||||
await stopOAuthLoopbackCallbackServers();
|
||||
}
|
||||
|
||||
@@ -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 { mergeAccountExtraConfig } from '../accountExtraConfig.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 {
|
||||
createOauthSession,
|
||||
getOauthSession,
|
||||
markOauthSessionError,
|
||||
markOauthSessionSuccess,
|
||||
} from './sessionStore.js';
|
||||
import { getCodexOauthInfoFromExtraConfig } from './codexAccount.js';
|
||||
import { getCodexLoopbackCallbackServerState } from './localCallbackServer.js';
|
||||
import { getOAuthLoopbackCallbackServerState } from './localCallbackServer.js';
|
||||
import {
|
||||
getOAuthProviderDefinition,
|
||||
listOAuthProviderDefinitions,
|
||||
type OAuthProviderDefinition,
|
||||
} from './providers.js';
|
||||
import { buildOauthInfo, getOauthInfoFromExtraConfig } from './oauthAccount.js';
|
||||
import { buildCodexOauthInfo } from './codexAccount.js';
|
||||
|
||||
type OAuthProviderMetadata = {
|
||||
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';
|
||||
type OAuthProviderMetadata = ReturnType<typeof listOauthProviders>[number];
|
||||
|
||||
function asNonEmptyString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
@@ -46,54 +25,39 @@ function asNonEmptyString(value: unknown): string | undefined {
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function buildUsername(exchange: CodexTokenExchangeResult): string {
|
||||
return exchange.email || exchange.accountId || 'codex-user';
|
||||
function buildUsername(input: {
|
||||
email?: string;
|
||||
accountKey?: string;
|
||||
provider: string;
|
||||
}) {
|
||||
return input.email || input.accountKey || `${input.provider}-user`;
|
||||
}
|
||||
|
||||
async function getNextAccountSortOrder(): Promise<number> {
|
||||
const rows = await db.select({ sortOrder: schema.accounts.sortOrder }).from(schema.accounts).all();
|
||||
const max = rows.reduce((currentMax, row) => Math.max(currentMax, row.sortOrder || 0), -1);
|
||||
return max + 1;
|
||||
const row = await db.select({
|
||||
maxSortOrder: sql<number>`COALESCE(MAX(${schema.accounts.sortOrder}), -1)`,
|
||||
}).from(schema.accounts).get();
|
||||
return (row?.maxSortOrder ?? -1) + 1;
|
||||
}
|
||||
|
||||
async function getNextSiteSortOrder(): Promise<number> {
|
||||
const rows = await db.select({ sortOrder: schema.sites.sortOrder }).from(schema.sites).all();
|
||||
const max = rows.reduce((currentMax, row) => Math.max(currentMax, row.sortOrder || 0), -1);
|
||||
return max + 1;
|
||||
const row = await db.select({
|
||||
maxSortOrder: sql<number>`COALESCE(MAX(${schema.sites.sortOrder}), -1)`,
|
||||
}).from(schema.sites).get();
|
||||
return (row?.maxSortOrder ?? -1) + 1;
|
||||
}
|
||||
|
||||
async function ensureCodexSite() {
|
||||
const sites = await db.select().from(schema.sites).all();
|
||||
const existing = sites.find((site) => (
|
||||
String(site.platform || '').trim().toLowerCase() === 'codex'
|
||||
&& String(site.url || '').trim() === CODEX_UPSTREAM_BASE_URL
|
||||
));
|
||||
async function ensureOauthSite(definition: OAuthProviderDefinition) {
|
||||
const existing = await db.select().from(schema.sites).where(and(
|
||||
eq(schema.sites.platform, definition.site.platform),
|
||||
eq(schema.sites.url, definition.site.url),
|
||||
)).get();
|
||||
if (existing) return existing;
|
||||
|
||||
return db.insert(schema.sites).values({
|
||||
name: CODEX_SITE_NAME,
|
||||
url: CODEX_UPSTREAM_BASE_URL,
|
||||
platform: 'codex',
|
||||
name: definition.site.name,
|
||||
url: definition.site.url,
|
||||
platform: definition.site.platform,
|
||||
status: 'active',
|
||||
useSystemProxy: false,
|
||||
isPinned: false,
|
||||
@@ -102,55 +66,102 @@ async function ensureCodexSite() {
|
||||
}).returning().get();
|
||||
}
|
||||
|
||||
function matchesCodexIdentity(
|
||||
account: typeof schema.accounts.$inferSelect,
|
||||
exchange: CodexTokenExchangeResult,
|
||||
): boolean {
|
||||
const oauth = getCodexOauthInfoFromExtraConfig(account.extraConfig);
|
||||
if (!oauth || oauth.provider !== CODEX_OAUTH_PROVIDER) return false;
|
||||
if (oauth.accountId && exchange.accountId) return oauth.accountId === exchange.accountId;
|
||||
if (oauth.email && exchange.email) return oauth.email === exchange.email;
|
||||
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) {
|
||||
async function findExistingOauthAccount(input: {
|
||||
provider: string;
|
||||
accountKey?: string;
|
||||
email?: string;
|
||||
projectId?: string;
|
||||
rebindAccountId?: number;
|
||||
}) {
|
||||
if (typeof input.rebindAccountId === 'number' && input.rebindAccountId > 0) {
|
||||
return db.select().from(schema.accounts)
|
||||
.where(eq(schema.accounts.id, rebindAccountId))
|
||||
.where(eq(schema.accounts.id, input.rebindAccountId))
|
||||
.get();
|
||||
}
|
||||
|
||||
const accounts = await db.select().from(schema.accounts).all();
|
||||
return accounts.find((account) => matchesCodexIdentity(account, exchange)) || null;
|
||||
const accountKey = asNonEmptyString(input.accountKey);
|
||||
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) {
|
||||
const site = await ensureCodexSite();
|
||||
const existing = await findExistingCodexAccount(exchange, rebindAccountId);
|
||||
const username = buildUsername(exchange);
|
||||
async function upsertOauthAccount(input: {
|
||||
definition: OAuthProviderDefinition;
|
||||
exchange: {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
tokenExpiresAt?: number;
|
||||
email?: string;
|
||||
accountKey?: string;
|
||||
accountId?: string;
|
||||
planType?: string;
|
||||
projectId?: string;
|
||||
idToken?: string;
|
||||
providerData?: Record<string, unknown>;
|
||||
};
|
||||
rebindAccountId?: number;
|
||||
}) {
|
||||
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, {
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: CODEX_OAUTH_PROVIDER,
|
||||
accountId: exchange.accountId,
|
||||
email: exchange.email,
|
||||
planType: exchange.planType,
|
||||
refreshToken: exchange.refreshToken,
|
||||
idToken: exchange.idToken,
|
||||
tokenExpiresAt: exchange.tokenExpiresAt,
|
||||
},
|
||||
oauth,
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await db.update(schema.accounts).set({
|
||||
siteId: site.id,
|
||||
username,
|
||||
accessToken: exchange.accessToken,
|
||||
accessToken: input.exchange.accessToken,
|
||||
apiToken: null,
|
||||
checkinEnabled: false,
|
||||
status: 'active',
|
||||
oauthProvider: input.definition.metadata.provider,
|
||||
oauthAccountKey: oauth.accountKey || oauth.accountId || null,
|
||||
oauthProjectId: oauth.projectId || null,
|
||||
extraConfig,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).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({
|
||||
siteId: site.id,
|
||||
username,
|
||||
accessToken: exchange.accessToken,
|
||||
accessToken: input.exchange.accessToken,
|
||||
apiToken: null,
|
||||
checkinEnabled: false,
|
||||
status: 'active',
|
||||
oauthProvider: input.definition.metadata.provider,
|
||||
oauthAccountKey: oauth.accountKey || oauth.accountId || null,
|
||||
oauthProjectId: oauth.projectId || null,
|
||||
extraConfig,
|
||||
isPinned: false,
|
||||
sortOrder: await getNextAccountSortOrder(),
|
||||
@@ -176,34 +190,47 @@ async function upsertCodexAccount(exchange: CodexTokenExchangeResult, rebindAcco
|
||||
}
|
||||
|
||||
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;
|
||||
redirectOrigin: string;
|
||||
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}`);
|
||||
}
|
||||
const callbackServerState = getCodexLoopbackCallbackServerState();
|
||||
if (callbackServerState.attempted && !callbackServerState.ready) {
|
||||
throw new Error(`codex oauth callback listener is unavailable: ${callbackServerState.error || 'unknown error'}`);
|
||||
const redirectUri = definition.resolveRedirectUri?.({
|
||||
requestOrigin: input.requestOrigin,
|
||||
}) || 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({
|
||||
provider: input.provider,
|
||||
redirectUri,
|
||||
rebindAccountId: input.rebindAccountId,
|
||||
projectId: input.projectId,
|
||||
});
|
||||
return {
|
||||
provider: input.provider,
|
||||
state: session.state,
|
||||
authorizationUrl: buildCodexAuthorizationUrl({
|
||||
authorizationUrl: await definition.buildAuthorizationUrl({
|
||||
state: session.state,
|
||||
redirectUri,
|
||||
redirectUri: session.redirectUri,
|
||||
codeVerifier: session.codeVerifier,
|
||||
projectId: session.projectId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -233,6 +260,11 @@ export async function handleOauthCallback(input: {
|
||||
if (!session || session.provider !== input.provider) {
|
||||
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) {
|
||||
markOauthSessionError(input.state, input.error);
|
||||
throw new Error(input.error);
|
||||
@@ -243,12 +275,18 @@ export async function handleOauthCallback(input: {
|
||||
throw new Error('missing oauth code');
|
||||
}
|
||||
|
||||
const exchange = await exchangeCodexAuthorizationCode({
|
||||
const exchange = await definition.exchangeAuthorizationCode({
|
||||
code,
|
||||
codeVerifier: session.codeVerifier,
|
||||
state: input.state,
|
||||
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) {
|
||||
markOauthSessionError(input.state, '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 rebuildTokenRoutesFromAvailability();
|
||||
const errorMessage = refreshResult.errorMessage || 'codex model discovery failed';
|
||||
const errorMessage = refreshResult.errorMessage || `${input.provider} model discovery failed`;
|
||||
markOauthSessionError(input.state, errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
@@ -273,15 +311,41 @@ export async function handleOauthCallback(input: {
|
||||
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)
|
||||
.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();
|
||||
|
||||
const accountIds = rows.map((row) => row.accounts.id);
|
||||
if (accountIds.length <= 0) {
|
||||
return { items: [], total, limit, offset };
|
||||
}
|
||||
|
||||
const modelRows = await db.select({
|
||||
accountId: schema.modelAvailability.accountId,
|
||||
modelName: schema.modelAvailability.modelName,
|
||||
}).from(schema.modelAvailability)
|
||||
.where(eq(schema.modelAvailability.available, true))
|
||||
.where(and(
|
||||
inArray(schema.modelAvailability.accountId, accountIds),
|
||||
eq(schema.modelAvailability.available, true),
|
||||
))
|
||||
.all();
|
||||
const modelMap = new Map<number, string[]>();
|
||||
for (const row of modelRows) {
|
||||
@@ -290,47 +354,52 @@ export async function listOauthConnections() {
|
||||
list.push(row.modelName);
|
||||
modelMap.set(row.accountId, list);
|
||||
}
|
||||
|
||||
const routeChannelRows = await db.select({
|
||||
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>();
|
||||
for (const row of routeChannelRows) {
|
||||
const current = routeChannelCountByAccount.get(row.accountId) || 0;
|
||||
routeChannelCountByAccount.set(row.accountId, current + 1);
|
||||
routeChannelCountByAccount.set(row.accountId, row.count ?? 0);
|
||||
}
|
||||
|
||||
return rows
|
||||
.filter((row) => getCodexOauthInfoFromExtraConfig(row.accounts.extraConfig)?.provider === CODEX_OAUTH_PROVIDER)
|
||||
.map((row) => {
|
||||
const oauth = getCodexOauthInfoFromExtraConfig(row.accounts.extraConfig)!;
|
||||
const models = modelMap.get(row.accounts.id) || [];
|
||||
const status = (
|
||||
oauth.modelDiscoveryStatus === 'abnormal'
|
||||
|| row.accounts.status !== 'active'
|
||||
|| row.sites.status !== 'active'
|
||||
) ? 'abnormal' : 'healthy';
|
||||
return {
|
||||
accountId: row.accounts.id,
|
||||
siteId: row.sites.id,
|
||||
provider: CODEX_OAUTH_PROVIDER,
|
||||
username: row.accounts.username,
|
||||
email: oauth.email,
|
||||
accountKey: oauth.accountId,
|
||||
planType: oauth.planType,
|
||||
modelCount: models.length,
|
||||
modelsPreview: models.slice(0, 10),
|
||||
status,
|
||||
routeChannelCount: routeChannelCountByAccount.get(row.accounts.id) || 0,
|
||||
lastModelSyncAt: oauth.lastModelSyncAt,
|
||||
lastModelSyncError: oauth.lastModelSyncError,
|
||||
site: {
|
||||
id: row.sites.id,
|
||||
name: row.sites.name,
|
||||
url: row.sites.url,
|
||||
platform: row.sites.platform,
|
||||
},
|
||||
};
|
||||
});
|
||||
const items = rows.map((row) => {
|
||||
const oauth = getOauthInfoFromExtraConfig(row.accounts.extraConfig)!;
|
||||
const models = modelMap.get(row.accounts.id) || [];
|
||||
const status = (
|
||||
oauth.modelDiscoveryStatus === 'abnormal'
|
||||
|| row.accounts.status !== 'active'
|
||||
|| row.sites.status !== 'active'
|
||||
) ? 'abnormal' : 'healthy';
|
||||
return {
|
||||
accountId: row.accounts.id,
|
||||
siteId: row.sites.id,
|
||||
provider: oauth.provider,
|
||||
username: row.accounts.username,
|
||||
email: oauth.email,
|
||||
accountKey: oauth.accountKey || oauth.accountId,
|
||||
planType: oauth.planType,
|
||||
projectId: oauth.projectId,
|
||||
modelCount: models.length,
|
||||
modelsPreview: models.slice(0, 10),
|
||||
status,
|
||||
routeChannelCount: routeChannelCountByAccount.get(row.accounts.id) || 0,
|
||||
lastModelSyncAt: oauth.lastModelSyncAt,
|
||||
lastModelSyncError: oauth.lastModelSyncError,
|
||||
site: {
|
||||
id: row.sites.id,
|
||||
name: row.sites.name,
|
||||
url: row.sites.url,
|
||||
platform: row.sites.platform,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { items, total, limit, offset };
|
||||
}
|
||||
|
||||
export async function deleteOauthConnection(accountId: number) {
|
||||
@@ -340,7 +409,7 @@ export async function deleteOauthConnection(accountId: number) {
|
||||
if (!account) {
|
||||
throw new Error('oauth account not found');
|
||||
}
|
||||
const oauth = getCodexOauthInfoFromExtraConfig(account.extraConfig);
|
||||
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
|
||||
if (!oauth) {
|
||||
throw new Error('account is not managed by oauth');
|
||||
}
|
||||
@@ -349,21 +418,36 @@ export async function deleteOauthConnection(accountId: number) {
|
||||
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)
|
||||
.where(eq(schema.accounts.id, accountId))
|
||||
.get();
|
||||
if (!account) {
|
||||
throw new Error('oauth account not found');
|
||||
}
|
||||
const oauth = getCodexOauthInfoFromExtraConfig(account.extraConfig);
|
||||
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
|
||||
if (!oauth) {
|
||||
throw new Error('account is not managed by oauth');
|
||||
}
|
||||
return startOauthProviderFlow({
|
||||
provider: oauth.provider,
|
||||
redirectOrigin,
|
||||
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;
|
||||
downstreamHeaders?: Record<string, unknown>;
|
||||
}) {
|
||||
const oauth = getCodexOauthInfoFromExtraConfig(input.extraConfig);
|
||||
if (!oauth) return {};
|
||||
|
||||
const accountId = getHeaderValue(input.downstreamHeaders, 'chatgpt-account-id') || oauth.accountId;
|
||||
const originator = getHeaderValue(input.downstreamHeaders, 'originator') || 'codex_cli_rs';
|
||||
const headers: Record<string, string> = {
|
||||
Originator: originator,
|
||||
};
|
||||
if (accountId) {
|
||||
headers['Chatgpt-Account-Id'] = accountId;
|
||||
}
|
||||
return headers;
|
||||
const oauth = buildCodexOauthInfo(input.extraConfig);
|
||||
const definition = getOAuthProviderDefinition('codex');
|
||||
return definition?.buildProxyHeaders?.({
|
||||
oauth,
|
||||
downstreamHeaders: input.downstreamHeaders,
|
||||
}) || {};
|
||||
}
|
||||
|
||||
export async function refreshCodexOauthAccessToken(accountId: number) {
|
||||
export async function refreshOauthAccessToken(accountId: number) {
|
||||
const account = await db.select().from(schema.accounts)
|
||||
.where(eq(schema.accounts.id, accountId))
|
||||
.get();
|
||||
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) {
|
||||
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({
|
||||
refreshToken: oauth.refreshToken,
|
||||
oauth: {
|
||||
projectId: oauth.projectId,
|
||||
providerData: oauth.providerData,
|
||||
},
|
||||
});
|
||||
const nextOauth = buildOauthInfo(account.extraConfig, {
|
||||
provider: oauth.provider,
|
||||
accountId: refreshed.accountId || oauth.accountId,
|
||||
accountKey: refreshed.accountKey || oauth.accountKey || refreshed.accountId || oauth.accountId,
|
||||
email: refreshed.email || oauth.email,
|
||||
planType: refreshed.planType || oauth.planType,
|
||||
projectId: refreshed.projectId || oauth.projectId,
|
||||
refreshToken: refreshed.refreshToken || oauth.refreshToken,
|
||||
tokenExpiresAt: refreshed.tokenExpiresAt || oauth.tokenExpiresAt,
|
||||
idToken: refreshed.idToken || oauth.idToken,
|
||||
providerData: {
|
||||
...(oauth.providerData || {}),
|
||||
...(refreshed.providerData || {}),
|
||||
},
|
||||
});
|
||||
const extraConfig = mergeAccountExtraConfig(account.extraConfig, {
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: CODEX_OAUTH_PROVIDER,
|
||||
accountId: refreshed.accountId || oauth.accountId,
|
||||
email: refreshed.email || oauth.email,
|
||||
planType: refreshed.planType || oauth.planType,
|
||||
refreshToken: refreshed.refreshToken,
|
||||
idToken: refreshed.idToken,
|
||||
tokenExpiresAt: refreshed.tokenExpiresAt,
|
||||
},
|
||||
oauth: nextOauth,
|
||||
});
|
||||
|
||||
await db.update(schema.accounts).set({
|
||||
accessToken: refreshed.accessToken,
|
||||
oauthProvider: oauth.provider,
|
||||
oauthAccountKey: nextOauth.accountKey || nextOauth.accountId || null,
|
||||
oauthProjectId: nextOauth.projectId || null,
|
||||
extraConfig,
|
||||
status: 'active',
|
||||
updatedAt: new Date().toISOString(),
|
||||
@@ -421,7 +519,13 @@ export async function refreshCodexOauthAccessToken(accountId: number) {
|
||||
return {
|
||||
accountId,
|
||||
accessToken: refreshed.accessToken,
|
||||
accountKey: refreshed.accountId || oauth.accountId,
|
||||
accountKey: nextOauth.accountKey || nextOauth.accountId,
|
||||
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';
|
||||
|
||||
@@ -15,10 +15,22 @@ export type OAuthSessionRecord = {
|
||||
siteId?: number;
|
||||
error?: string;
|
||||
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 oauthSessions = new Map<string, OAuthSessionRecord>();
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
@@ -28,79 +40,113 @@ function toBase64Url(input: Buffer): string {
|
||||
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 {
|
||||
return toBase64Url(randomBytes(48));
|
||||
}
|
||||
|
||||
export function createPkceChallenge(codeVerifier: string): string {
|
||||
return createHash('sha256').update(codeVerifier).digest('base64url');
|
||||
export async function createPkceChallenge(codeVerifier: string): Promise<string> {
|
||||
const digest = await webcrypto.subtle.digest('SHA-256', Buffer.from(codeVerifier, 'utf8'));
|
||||
return Buffer.from(digest).toString('base64url');
|
||||
}
|
||||
|
||||
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;
|
||||
redirectUri: string;
|
||||
rebindAccountId?: number;
|
||||
projectId?: string;
|
||||
}): OAuthSessionRecord {
|
||||
this.pruneExpiredSessions();
|
||||
const state = toBase64Url(randomBytes(24));
|
||||
const codeVerifier = createPkceVerifier();
|
||||
const createdAt = nowIso();
|
||||
const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString();
|
||||
const record: OAuthSessionRecord = {
|
||||
provider: input.provider,
|
||||
state,
|
||||
status: 'pending',
|
||||
codeVerifier,
|
||||
redirectUri: input.redirectUri,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
expiresAt,
|
||||
rebindAccountId: input.rebindAccountId,
|
||||
projectId: input.projectId,
|
||||
};
|
||||
this.sessions.set(state, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
get(state: string): OAuthSessionRecord | null {
|
||||
this.pruneExpiredSessions();
|
||||
return this.sessions.get(state) || null;
|
||||
}
|
||||
|
||||
markSuccess(state: string, patch: { accountId: number; siteId: number }): OAuthSessionRecord | null {
|
||||
const existing = this.get(state);
|
||||
if (!existing) return null;
|
||||
const next: OAuthSessionRecord = {
|
||||
...existing,
|
||||
status: 'success',
|
||||
updatedAt: nowIso(),
|
||||
accountId: patch.accountId,
|
||||
siteId: patch.siteId,
|
||||
error: undefined,
|
||||
};
|
||||
this.sessions.set(state, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
markError(state: string, error: string): OAuthSessionRecord | null {
|
||||
const existing = this.get(state);
|
||||
if (!existing) return null;
|
||||
const next: OAuthSessionRecord = {
|
||||
...existing,
|
||||
status: 'error',
|
||||
updatedAt: nowIso(),
|
||||
error: error.trim() || 'OAuth failed',
|
||||
};
|
||||
this.sessions.set(state, 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 {
|
||||
pruneExpiredSessions();
|
||||
const state = toBase64Url(randomBytes(24));
|
||||
const codeVerifier = createPkceVerifier();
|
||||
const createdAt = nowIso();
|
||||
const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString();
|
||||
const record: OAuthSessionRecord = {
|
||||
provider: input.provider,
|
||||
state,
|
||||
status: 'pending',
|
||||
codeVerifier,
|
||||
redirectUri: input.redirectUri,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
expiresAt,
|
||||
rebindAccountId: input.rebindAccountId,
|
||||
};
|
||||
oauthSessions.set(state, record);
|
||||
return record;
|
||||
return oauthSessionStore.create(input);
|
||||
}
|
||||
|
||||
export function getOauthSession(state: string): OAuthSessionRecord | null {
|
||||
pruneExpiredSessions();
|
||||
return oauthSessions.get(state) || null;
|
||||
return oauthSessionStore.get(state);
|
||||
}
|
||||
|
||||
export function markOauthSessionSuccess(
|
||||
state: string,
|
||||
patch: { accountId: number; siteId: number },
|
||||
): OAuthSessionRecord | null {
|
||||
const existing = getOauthSession(state);
|
||||
if (!existing) return null;
|
||||
const next: OAuthSessionRecord = {
|
||||
...existing,
|
||||
status: 'success',
|
||||
updatedAt: nowIso(),
|
||||
accountId: patch.accountId,
|
||||
siteId: patch.siteId,
|
||||
error: undefined,
|
||||
};
|
||||
oauthSessions.set(state, next);
|
||||
return next;
|
||||
return oauthSessionStore.markSuccess(state, patch);
|
||||
}
|
||||
|
||||
export function markOauthSessionError(state: string, error: string): OAuthSessionRecord | null {
|
||||
const existing = getOauthSession(state);
|
||||
if (!existing) return null;
|
||||
const next: OAuthSessionRecord = {
|
||||
...existing,
|
||||
status: 'error',
|
||||
updatedAt: nowIso(),
|
||||
error: error.trim() || 'OAuth failed',
|
||||
};
|
||||
oauthSessions.set(state, next);
|
||||
return next;
|
||||
return oauthSessionStore.markError(state, error);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js';
|
||||
|
||||
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 {
|
||||
|
||||
@@ -42,7 +42,7 @@ function resolveGeminiNativeModelsUrl(baseUrl: string, apiToken: string): string
|
||||
}
|
||||
|
||||
export class GeminiAdapter extends BasePlatformAdapter {
|
||||
readonly platformName = 'gemini';
|
||||
readonly platformName: string = 'gemini';
|
||||
|
||||
async detect(url: string): Promise<boolean> {
|
||||
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 { ClaudeAdapter } from './claude.js';
|
||||
import { GeminiAdapter } from './gemini.js';
|
||||
import { GeminiCliAdapter } from './geminiCli.js';
|
||||
import { CliProxyApiAdapter } from './cliproxyapi.js';
|
||||
import { detectPlatformByTitle } from './titleHint.js';
|
||||
|
||||
@@ -19,6 +20,7 @@ const adapters: PlatformAdapter[] = [
|
||||
new CodexAdapter(),
|
||||
new ClaudeAdapter(),
|
||||
new GeminiAdapter(),
|
||||
new GeminiCliAdapter(),
|
||||
new CliProxyApiAdapter(),
|
||||
new AnyRouterAdapter(),
|
||||
new DoneHubAdapter(),
|
||||
@@ -57,6 +59,7 @@ const platformAliases: Record<string, string> = {
|
||||
anthropic: 'claude',
|
||||
claude: 'claude',
|
||||
gemini: 'gemini',
|
||||
'gemini-cli': 'gemini-cli',
|
||||
google: 'gemini',
|
||||
// CLIProxyAPI aliases
|
||||
cliproxyapi: 'cliproxyapi',
|
||||
@@ -97,6 +100,7 @@ function detectPlatformByUrlHint(url: string): string | undefined {
|
||||
) {
|
||||
return 'gemini';
|
||||
}
|
||||
if (normalized.includes('cloudcode-pa.googleapis.com')) return 'gemini-cli';
|
||||
|
||||
// NewAPI-family forks and common aliases.
|
||||
if (normalized.includes('anyrouter')) return 'anyrouter';
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from './routeRoutingStrategy.js';
|
||||
import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js';
|
||||
import { isUsableAccountToken } from './accountTokenService.js';
|
||||
import { isCodexPlatform } from './oauth/codexAccount.js';
|
||||
import { getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js';
|
||||
|
||||
interface RouteMatch {
|
||||
route: typeof schema.tokenRoutes.$inferSelect;
|
||||
@@ -1045,7 +1045,7 @@ export class TokenRouter {
|
||||
const fallback = candidate.account.apiToken?.trim();
|
||||
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();
|
||||
return accessToken || null;
|
||||
}
|
||||
|
||||
@@ -213,8 +213,8 @@ export function buildResponsesCompatibilityBodies(
|
||||
if (temperature !== null) richCandidate.temperature = temperature;
|
||||
const topP = toFiniteNumber(body.top_p);
|
||||
if (topP !== null) richCandidate.top_p = topP;
|
||||
const instructions = typeof body.instructions === 'string' ? body.instructions.trim() : '';
|
||||
if (instructions) richCandidate.instructions = instructions;
|
||||
const instructions = getExplicitResponsesInstructions(body);
|
||||
if (instructions !== null) richCandidate.instructions = instructions;
|
||||
|
||||
const passthroughFields = [
|
||||
'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(
|
||||
body: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
@@ -431,8 +436,8 @@ function buildCoreResponsesBody(
|
||||
const topP = toFiniteNumber(body.top_p);
|
||||
if (topP !== null) core.top_p = topP;
|
||||
|
||||
const instructions = typeof body.instructions === 'string' ? body.instructions.trim() : '';
|
||||
if (instructions) core.instructions = instructions;
|
||||
const instructions = getExplicitResponsesInstructions(body);
|
||||
if (instructions !== null) core.instructions = instructions;
|
||||
|
||||
const passthroughFields = [
|
||||
'reasoning',
|
||||
@@ -458,10 +463,15 @@ function buildStrictResponsesBody(
|
||||
if (!model) return null;
|
||||
if (body.input === undefined) return null;
|
||||
|
||||
const explicitInstructions = getExplicitResponsesInstructions(body);
|
||||
|
||||
return {
|
||||
model,
|
||||
input: body.input,
|
||||
stream: body.stream === true,
|
||||
...(explicitInstructions !== null
|
||||
? { instructions: explicitInstructions }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+15
-2
@@ -252,6 +252,10 @@ export type OAuthProviderInfo = {
|
||||
platform: string;
|
||||
enabled: boolean;
|
||||
loginType: 'oauth';
|
||||
requiresProjectId: boolean;
|
||||
supportsDirectAccountRouting: boolean;
|
||||
supportsCloudValidation: boolean;
|
||||
supportsNativeProxy: boolean;
|
||||
};
|
||||
|
||||
export type OAuthSessionInfo = {
|
||||
@@ -271,6 +275,7 @@ export type OAuthConnectionInfo = {
|
||||
email?: string | null;
|
||||
accountKey?: string | null;
|
||||
planType?: string | null;
|
||||
projectId?: string | null;
|
||||
modelCount: number;
|
||||
modelsPreview: string[];
|
||||
status: 'healthy' | 'abnormal';
|
||||
@@ -280,6 +285,13 @@ export type OAuthConnectionInfo = {
|
||||
site?: { id: number; name: string; url: string; platform: string } | null;
|
||||
};
|
||||
|
||||
export type OAuthConnectionsResponse = {
|
||||
items: OAuthConnectionInfo[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
export const api = {
|
||||
// Sites
|
||||
getSites: () => request('/api/sites'),
|
||||
@@ -393,12 +405,13 @@ export const api = {
|
||||
|
||||
// OAuth
|
||||
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',
|
||||
body: JSON.stringify(data || {}),
|
||||
}) as Promise<{ provider: string; state: string; authorizationUrl: string }>,
|
||||
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`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import OAuthManagement from './OAuthManagement.js';
|
||||
|
||||
const { apiMock, openMock, focusMock, confirmMock } = vi.hoisted(() => ({
|
||||
const { apiMock, openMock, focusMock, confirmMock, promptMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getOAuthProviders: vi.fn(),
|
||||
getOAuthConnections: vi.fn(),
|
||||
@@ -16,6 +16,7 @@ const { apiMock, openMock, focusMock, confirmMock } = vi.hoisted(() => ({
|
||||
openMock: vi.fn(),
|
||||
focusMock: vi.fn(),
|
||||
confirmMock: vi.fn(),
|
||||
promptMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
@@ -41,11 +42,14 @@ describe('OAuthManagement page', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
Object.values(apiMock).forEach((mock) => mock.mockReset());
|
||||
openMock.mockReturnValue({ focus: focusMock });
|
||||
confirmMock.mockReturnValue(true);
|
||||
promptMock.mockReturnValue('project-demo');
|
||||
vi.stubGlobal('window', {
|
||||
open: openMock,
|
||||
confirm: confirmMock,
|
||||
prompt: promptMock,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
} as unknown as Window & typeof globalThis);
|
||||
@@ -60,7 +64,28 @@ describe('OAuthManagement page', () => {
|
||||
it('renders available oauth providers and existing oauth connections', async () => {
|
||||
apiMock.getOAuthProviders.mockResolvedValue({
|
||||
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({
|
||||
@@ -72,9 +97,12 @@ describe('OAuthManagement page', () => {
|
||||
planType: 'plus',
|
||||
modelCount: 3,
|
||||
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;
|
||||
@@ -88,14 +116,16 @@ describe('OAuthManagement page', () => {
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const text = collectText(root!.root);
|
||||
expect(text).toContain('OAuth 管理');
|
||||
expect(text).toContain('Codex');
|
||||
expect(text).toContain('codex-user@example.com');
|
||||
expect(text).toContain('plus');
|
||||
expect(text).toContain('3 个模型');
|
||||
await vi.waitFor(async () => {
|
||||
await flushMicrotasks();
|
||||
const text = collectText(root!.root);
|
||||
expect(text).toContain('OAuth 管理');
|
||||
expect(text).toContain('Codex');
|
||||
expect(text).toContain('Gemini CLI');
|
||||
expect(text).toContain('codex-user@example.com');
|
||||
expect(text).toContain('plus');
|
||||
expect(text).toContain('3 个模型');
|
||||
});
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
@@ -104,11 +134,32 @@ describe('OAuthManagement page', () => {
|
||||
it('starts oauth, opens popup, polls status, and refreshes connection list after success', async () => {
|
||||
apiMock.getOAuthProviders.mockResolvedValue({
|
||||
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
|
||||
.mockResolvedValueOnce({ items: [] })
|
||||
.mockResolvedValueOnce({ items: [], total: 0, limit: 100, offset: 0 })
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
@@ -118,9 +169,12 @@ describe('OAuthManagement page', () => {
|
||||
planType: 'plus',
|
||||
modelCount: 3,
|
||||
modelsPreview: ['gpt-5', 'gpt-5-mini', 'gpt-5.2-codex'],
|
||||
status: 'active',
|
||||
status: 'healthy',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
apiMock.startOAuthProvider.mockResolvedValue({
|
||||
provider: 'codex',
|
||||
@@ -151,7 +205,9 @@ describe('OAuthManagement page', () => {
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
await vi.waitFor(async () => {
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
const startButton = root!.root.find((node) => (
|
||||
node.type === 'button'
|
||||
@@ -162,9 +218,11 @@ describe('OAuthManagement page', () => {
|
||||
await act(async () => {
|
||||
await startButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
await vi.waitFor(async () => {
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(apiMock.startOAuthProvider).toHaveBeenCalledWith('codex');
|
||||
expect(apiMock.startOAuthProvider).toHaveBeenCalledWith('codex', { projectId: undefined });
|
||||
expect(openMock).toHaveBeenCalledWith(
|
||||
'https://auth.openai.com/oauth/authorize?state=oauth-state-123',
|
||||
'oauth-codex',
|
||||
@@ -174,12 +232,16 @@ describe('OAuthManagement page', () => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1600);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
await vi.waitFor(async () => {
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1600);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
await vi.waitFor(async () => {
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(apiMock.getOAuthSession).toHaveBeenCalledWith('oauth-state-123');
|
||||
expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(2);
|
||||
@@ -194,7 +256,17 @@ describe('OAuthManagement page', () => {
|
||||
it('shows oauth connection status metadata and allows deleting a connection', async () => {
|
||||
apiMock.getOAuthProviders.mockResolvedValue({
|
||||
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
|
||||
@@ -213,8 +285,11 @@ describe('OAuthManagement page', () => {
|
||||
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 });
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
@@ -228,12 +303,13 @@ describe('OAuthManagement page', () => {
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const text = collectText(root!.root);
|
||||
expect(text).toContain('异常');
|
||||
expect(text).toContain('1 条路由');
|
||||
expect(text).toContain('Codex 模型获取失败');
|
||||
await vi.waitFor(async () => {
|
||||
await flushMicrotasks();
|
||||
const text = collectText(root!.root);
|
||||
expect(text).toContain('异常');
|
||||
expect(text).toContain('1 条路由');
|
||||
expect(text).toContain('Codex 模型获取失败');
|
||||
});
|
||||
|
||||
const deleteButton = root!.root.find((node) => (
|
||||
node.type === 'button'
|
||||
@@ -244,7 +320,9 @@ describe('OAuthManagement page', () => {
|
||||
await act(async () => {
|
||||
await deleteButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
await vi.waitFor(async () => {
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(confirmMock).toHaveBeenCalled();
|
||||
expect(apiMock.deleteOAuthConnection).toHaveBeenCalledWith(7);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { api, type OAuthConnectionInfo, type OAuthProviderInfo } from '../api.js';
|
||||
|
||||
const POLL_INTERVAL_MS = 1500;
|
||||
const CONNECTION_PAGE_LIMIT = 100;
|
||||
|
||||
type ActiveSession = {
|
||||
provider: string;
|
||||
@@ -13,8 +14,15 @@ function openOAuthPopup(provider: string, authorizationUrl: string) {
|
||||
const popup = window.open(
|
||||
authorizationUrl,
|
||||
`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') {
|
||||
popup.focus();
|
||||
}
|
||||
@@ -33,7 +41,10 @@ export default function OAuthManagement() {
|
||||
const [actionLoadingKey, setActionLoadingKey] = useState('');
|
||||
|
||||
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 : []);
|
||||
};
|
||||
|
||||
@@ -44,6 +55,9 @@ export default function OAuthManagement() {
|
||||
loadConnections(),
|
||||
]);
|
||||
setProviders(Array.isArray(providersResponse?.providers) ? providersResponse.providers : []);
|
||||
} catch (error) {
|
||||
console.error('failed to load oauth management data', error);
|
||||
setSessionMessage('OAuth 管理数据加载失败');
|
||||
} finally {
|
||||
setLoaded(true);
|
||||
}
|
||||
@@ -93,19 +107,30 @@ export default function OAuthManagement() {
|
||||
};
|
||||
}, [activeSession]);
|
||||
|
||||
const handleStart = async (provider: string, accountId?: number) => {
|
||||
const actionKey = `start:${provider}:${accountId || 0}`;
|
||||
const handleStart = async (provider: OAuthProviderInfo, accountId?: number) => {
|
||||
const actionKey = `start:${provider.provider}:${accountId || 0}`;
|
||||
setActionLoadingKey(actionKey);
|
||||
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
|
||||
? await api.rebindOAuthConnection(accountId)
|
||||
: await api.startOAuthProvider(provider);
|
||||
: await api.startOAuthProvider(provider.provider, { projectId });
|
||||
setSessionMessage('等待授权完成');
|
||||
setActiveSession({
|
||||
provider: started.provider,
|
||||
state: started.state,
|
||||
});
|
||||
openOAuthPopup(provider, started.authorizationUrl);
|
||||
openOAuthPopup(provider.provider, started.authorizationUrl);
|
||||
} catch (error: any) {
|
||||
setSessionMessage(error?.message || '无法启动 OAuth 授权');
|
||||
} finally {
|
||||
@@ -136,7 +161,7 @@ export default function OAuthManagement() {
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">OAuth 管理</div>
|
||||
<div className="page-subtitle">统一管理需要浏览器授权的上游连接。首版支持 Codex。</div>
|
||||
<div className="page-subtitle">统一管理需要浏览器授权的上游连接,包括 Codex、Claude 和 Gemini CLI。</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>
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => handleStart(provider.provider)}
|
||||
onClick={() => handleStart(provider)}
|
||||
disabled={actionLoadingKey === actionKey}
|
||||
>
|
||||
{actionLoadingKey === actionKey ? '启动中...' : `连接 ${provider.label}`}
|
||||
@@ -188,6 +217,11 @@ export default function OAuthManagement() {
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||
{connection.planType || 'unknown'} · {connection.modelCount} 个模型
|
||||
</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 }}>
|
||||
{resolveConnectionStatusLabel(connection.status)} · {connection.routeChannelCount || 0} 条路由
|
||||
</div>
|
||||
@@ -211,7 +245,20 @@ export default function OAuthManagement() {
|
||||
<button
|
||||
type="button"
|
||||
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}
|
||||
>
|
||||
{actionLoadingKey === rebindActionKey ? '启动中...' : '重新授权'}
|
||||
|
||||
Reference in New Issue
Block a user