diff --git a/src/server/desktop.ts b/src/server/desktop.ts index bc844b0..02bb29b 100644 --- a/src/server/desktop.ts +++ b/src/server/desktop.ts @@ -3,7 +3,7 @@ import type { FastifyInstance } from 'fastify'; const DESKTOP_HEALTH_ROUTE = '/api/desktop/health'; export function isPublicApiRoute(url: string): boolean { - return url === DESKTOP_HEALTH_ROUTE; + return url === DESKTOP_HEALTH_ROUTE || url.startsWith('/api/oauth/callback/'); } export async function registerDesktopRoutes(app: FastifyInstance) { diff --git a/src/server/index.ts b/src/server/index.ts index b2179ec..aead5f7 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -17,6 +17,7 @@ import { taskRoutes } from './routes/api/tasks.js'; import { testRoutes } from './routes/api/test.js'; import { monitorRoutes } from './routes/api/monitor.js'; import { downstreamApiKeysRoutes } from './routes/api/downstreamApiKeys.js'; +import { oauthRoutes } from './routes/api/oauth.js'; import { proxyRoutes } from './routes/proxy/router.js'; import { startScheduler } from './services/checkinScheduler.js'; import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js'; @@ -24,6 +25,7 @@ import { buildStartupSummaryLines } from './services/startupInfo.js'; import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js'; import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js'; import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js'; +import { startCodexLoopbackCallbackServer, stopCodexLoopbackCallbackServer } from './services/oauth/localCallbackServer.js'; import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js'; import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js'; import { existsSync } from 'fs'; @@ -312,6 +314,7 @@ await app.register(taskRoutes); await app.register(testRoutes); await app.register(monitorRoutes); await app.register(downstreamApiKeysRoutes); +await app.register(oauthRoutes); // Register OpenAI-compatible proxy routes await app.register(proxyRoutes); @@ -345,9 +348,15 @@ if (existsSync(webDir)) { // Start scheduler await startScheduler(); +try { + await startCodexLoopbackCallbackServer(); +} catch (error) { + console.warn(`Failed to start Codex OAuth callback listener: ${(error as Error)?.message || 'unknown error'}`); +} setLegacyProxyLogRetentionFallbackEnabled(!config.logCleanupConfigured); app.addHook('onClose', async () => { stopProxyLogRetentionService(); + await stopCodexLoopbackCallbackServer(); }); // Start server diff --git a/src/server/routes/api/accounts.credentialMode.test.ts b/src/server/routes/api/accounts.credentialMode.test.ts index d8eb354..f4f618e 100644 --- a/src/server/routes/api/accounts.credentialMode.test.ts +++ b/src/server/routes/api/accounts.credentialMode.test.ts @@ -172,6 +172,62 @@ describe('accounts credential mode', { timeout: 15_000 }, () => { }); }); + it('marks codex oauth connection as proxy-capable but not checkin/balance-capable', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Codex Site', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-user@example.com', + accessToken: 'oauth-access-token', + apiToken: null, + status: 'active', + checkinEnabled: false, + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'plus', + }, + }), + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-5.2-codex', + available: true, + checkedAt: '2026-03-16T12:00:00.000Z', + }).run(); + + const listResponse = await app.inject({ + method: 'GET', + url: '/api/accounts', + }); + expect(listResponse.statusCode).toBe(200); + + const list = listResponse.json() as Array<{ + id: number; + credentialMode?: string; + capabilities?: { + canCheckin?: boolean; + canRefreshBalance?: boolean; + proxyOnly?: boolean; + }; + }>; + const item = list.find((entry) => entry.id === account.id); + expect(item?.credentialMode).toBe('session'); + expect(item?.capabilities).toMatchObject({ + canCheckin: false, + canRefreshBalance: false, + proxyOnly: false, + }); + }); + it('stores managed refresh token for sub2api session account', async () => { verifyTokenMock.mockResolvedValueOnce({ tokenType: 'session', diff --git a/src/server/routes/api/accounts.ts b/src/server/routes/api/accounts.ts index c298ec8..9c66eec 100644 --- a/src/server/routes/api/accounts.ts +++ b/src/server/routes/api/accounts.ts @@ -19,6 +19,7 @@ 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, @@ -79,7 +80,15 @@ function resolveStoredCredentialMode(account: typeof schema.accounts.$inferSelec function buildCapabilitiesFromCredentialMode( credentialMode: AccountCredentialMode, hasSessionToken: boolean, + extraConfig?: string | null, ): AccountCapabilities { + if (isCodexPlatform(extraConfig)) { + return { + canCheckin: false, + canRefreshBalance: false, + proxyOnly: false, + }; + } const sessionCapable = credentialMode === 'session' ? hasSessionToken : (credentialMode === 'apikey' ? false : hasSessionToken); @@ -92,7 +101,7 @@ function buildCapabilitiesFromCredentialMode( function buildCapabilitiesForAccount(account: typeof schema.accounts.$inferSelect): AccountCapabilities { const credentialMode = resolveStoredCredentialMode(account); - return buildCapabilitiesFromCredentialMode(credentialMode, hasSessionTokenValue(account.accessToken)); + return buildCapabilitiesFromCredentialMode(credentialMode, hasSessionTokenValue(account.accessToken), account.extraConfig); } function normalizeBatchIds(input: unknown): number[] { @@ -410,6 +419,7 @@ export async function accountsRoutes(app: FastifyInstance) { capabilities: buildCapabilitiesFromCredentialMode( credentialMode, hasSessionTokenValue(r.accounts.accessToken), + r.accounts.extraConfig, ), todaySpend: Math.round((spendByAccount[r.accounts.id] || 0) * 1_000_000) / 1_000_000, todayReward: Math.round(estimateRewardWithTodayIncomeFallback({ @@ -426,6 +436,7 @@ export async function accountsRoutes(app: FastifyInstance) { sessionCapable: buildCapabilitiesFromCredentialMode( credentialMode, hasSessionTokenValue(r.accounts.accessToken), + r.accounts.extraConfig, ).canRefreshBalance, hasDiscoveredModels: (modelCountByAccount[r.accounts.id] || 0) > 0, }), @@ -968,7 +979,7 @@ export async function accountsRoutes(app: FastifyInstance) { account: latest, tokenType: 'session', credentialMode: 'session', - capabilities: latest ? buildCapabilitiesForAccount(latest) : buildCapabilitiesFromCredentialMode('session', true), + capabilities: latest ? buildCapabilitiesForAccount(latest) : buildCapabilitiesFromCredentialMode('session', true, null), apiTokenFound: !!nextApiToken, }; }, @@ -1138,7 +1149,7 @@ export async function accountsRoutes(app: FastifyInstance) { const finalCredentialMode = account ? resolveStoredCredentialMode(account) : resolvedCredentialMode; const capabilities = account ? buildCapabilitiesForAccount(account) - : buildCapabilitiesFromCredentialMode(finalCredentialMode, tokenType === 'session'); + : buildCapabilitiesFromCredentialMode(finalCredentialMode, tokenType === 'session', null); return { ...account, tokenType, diff --git a/src/server/routes/api/oauth.registration.test.ts b/src/server/routes/api/oauth.registration.test.ts new file mode 100644 index 0000000..9a0164d --- /dev/null +++ b/src/server/routes/api/oauth.registration.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +describe('oauth route registration', () => { + it('registers oauth routes in the main server entry', () => { + const source = readFileSync(resolve(process.cwd(), 'src/server/index.ts'), 'utf8'); + + expect(source).toContain("import { oauthRoutes } from './routes/api/oauth.js';"); + expect(source).toContain('await app.register(oauthRoutes);'); + }); + + it('treats oauth callback as a public API route', () => { + const source = readFileSync(resolve(process.cwd(), 'src/server/desktop.ts'), 'utf8'); + + expect(source).toContain("url.startsWith('/api/oauth/callback/')"); + }); +}); diff --git a/src/server/routes/api/oauth.test.ts b/src/server/routes/api/oauth.test.ts new file mode 100644 index 0000000..6476d35 --- /dev/null +++ b/src/server/routes/api/oauth.test.ts @@ -0,0 +1,369 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const fetchMock = vi.fn(); + +vi.mock('undici', () => ({ + fetch: (...args: unknown[]) => fetchMock(...args), +})); + +type DbModule = typeof import('../../db/index.js'); + +function buildJwt(payload: Record) { + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)) + .toString('base64url'); + return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.signature`; +} + +describe('oauth routes', { timeout: 15_000 }, () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-oauth-routes-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const routesModule = await import('./oauth.js'); + db = dbModule.db; + schema = dbModule.schema; + + app = Fastify(); + await app.register(routesModule.oauthRoutes); + }); + + beforeEach(async () => { + fetchMock.mockReset(); + await db.delete(schema.routeChannels).run(); + await db.delete(schema.tokenRoutes).run(); + await db.delete(schema.tokenModelAvailability).run(); + await db.delete(schema.modelAvailability).run(); + await db.delete(schema.accountTokens).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.sites).run(); + }); + + afterAll(async () => { + await app.close(); + delete process.env.DATA_DIR; + }); + + it('lists codex oauth provider metadata', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/oauth/providers', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + providers: [ + expect.objectContaining({ + provider: 'codex', + platform: 'codex', + enabled: true, + loginType: 'oauth', + }), + ], + }); + }); + + it('starts a codex oauth session and exposes pending status', async () => { + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/codex/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + + expect(startResponse.statusCode).toBe(200); + const startBody = startResponse.json() as { + provider: string; + state: string; + authorizationUrl: string; + }; + expect(startBody.provider).toBe('codex'); + 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(`state=${encodeURIComponent(startBody.state)}`); + expect(startBody.authorizationUrl).toContain('code_challenge='); + + const sessionResponse = await app.inject({ + method: 'GET', + url: `/api/oauth/sessions/${startBody.state}`, + }); + expect(sessionResponse.statusCode).toBe(200); + expect(sessionResponse.json()).toMatchObject({ + provider: 'codex', + state: startBody.state, + status: 'pending', + }); + }); + + it('handles codex callback, creates oauth-backed account, and discovers plan models', async () => { + const jwt = buildJwt({ + email: 'codex-user@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'chatgpt-account-123', + chatgpt_plan_type: 'plus', + }, + }); + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'oauth-access-token', + refresh_token: 'oauth-refresh-token', + id_token: jwt, + expires_in: 3600, + token_type: 'Bearer', + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + models: [ + { id: 'gpt-5.2-codex' }, + { id: 'gpt-5' }, + { id: 'gpt-5.4' }, + ], + }), + text: async () => JSON.stringify({ ok: true }), + }); + + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/codex/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + const startBody = startResponse.json() as { state: string }; + + const callbackResponse = await app.inject({ + method: 'GET', + url: `/api/oauth/callback/codex?state=${encodeURIComponent(startBody.state)}&code=oauth-code-123`, + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + expect(callbackResponse.statusCode).toBe(200); + expect(callbackResponse.body).toContain('window.close()'); + + const sessionResponse = await app.inject({ + method: 'GET', + url: `/api/oauth/sessions/${startBody.state}`, + }); + expect(sessionResponse.statusCode).toBe(200); + const sessionBody = sessionResponse.json() as { + provider: string; + status: string; + accountId?: number; + siteId?: number; + }; + expect(sessionBody).toMatchObject({ + provider: 'codex', + status: 'success', + }); + expect(sessionBody.accountId).toBeTypeOf('number'); + expect(sessionBody.siteId).toBeTypeOf('number'); + + const sites = await db.select().from(schema.sites).all(); + expect(sites).toHaveLength(1); + expect(sites[0]).toMatchObject({ + platform: 'codex', + url: 'https://chatgpt.com/backend-api/codex', + status: 'active', + }); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatchObject({ + siteId: sites[0]?.id, + username: 'codex-user@example.com', + accessToken: 'oauth-access-token', + checkinEnabled: false, + status: 'active', + }); + expect(JSON.parse(accounts[0]?.extraConfig || '{}')).toMatchObject({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'plus', + refreshToken: 'oauth-refresh-token', + idToken: jwt, + }, + }); + + const models = await db.select().from(schema.modelAvailability).all(); + const modelNames = models.map((row) => row.modelName); + expect(modelNames.sort()).toEqual(['gpt-5', 'gpt-5.2-codex', 'gpt-5.4']); + }); + + it('marks oauth session as error and avoids creating a connection when codex model discovery fails', async () => { + const jwt = buildJwt({ + email: 'codex-fail@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'chatgpt-account-fail', + chatgpt_plan_type: 'team', + }, + }); + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'oauth-access-token', + refresh_token: 'oauth-refresh-token', + id_token: jwt, + expires_in: 3600, + token_type: 'Bearer', + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 403, + json: async () => ({ error: 'forbidden' }), + text: async () => 'forbidden', + }); + + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/codex/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + const startBody = startResponse.json() as { state: string }; + + const callbackResponse = await app.inject({ + method: 'GET', + url: `/api/oauth/callback/codex?state=${encodeURIComponent(startBody.state)}&code=oauth-code-456`, + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + expect(callbackResponse.statusCode).toBe(200); + expect(callbackResponse.body).toContain('OAuth authorization failed'); + + const sessionResponse = await app.inject({ + method: 'GET', + url: `/api/oauth/sessions/${startBody.state}`, + }); + expect(sessionResponse.statusCode).toBe(200); + expect(sessionResponse.json()).toMatchObject({ + provider: 'codex', + status: 'error', + error: expect.stringContaining('HTTP 403'), + }); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toEqual([]); + + const connectionsResponse = await app.inject({ + method: 'GET', + url: '/api/oauth/connections', + }); + expect(connectionsResponse.statusCode).toBe(200); + expect(connectionsResponse.json()).toEqual({ items: [] }); + }); + + it('lists oauth connection health metadata and supports deleting the connection', async () => { + const site = await db.insert(schema.sites).values({ + name: 'ChatGPT Codex OAuth', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-user@example.com', + accessToken: 'oauth-access-token', + apiToken: null, + status: 'active', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'team', + modelDiscoveryStatus: 'abnormal', + lastModelSyncAt: '2026-03-17T08:00:00.000Z', + lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)', + }, + }), + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-5.2-codex', + available: true, + checkedAt: '2026-03-17T08:00:00.000Z', + }).run(); + + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-5.2-codex', + enabled: true, + }).returning().get(); + await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: account.id, + tokenId: null, + priority: 0, + weight: 10, + enabled: true, + manualOverride: false, + }).run(); + + const listResponse = await app.inject({ + method: 'GET', + url: '/api/oauth/connections', + }); + + expect(listResponse.statusCode).toBe(200); + expect(listResponse.json()).toEqual({ + items: [ + expect.objectContaining({ + accountId: account.id, + provider: 'codex', + email: 'codex-user@example.com', + status: 'abnormal', + routeChannelCount: 1, + lastModelSyncAt: '2026-03-17T08:00:00.000Z', + lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)', + }), + ], + }); + + const deleteResponse = await app.inject({ + method: 'DELETE', + url: `/api/oauth/connections/${account.id}`, + }); + expect(deleteResponse.statusCode).toBe(200); + expect(deleteResponse.json()).toEqual({ success: true }); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toEqual([]); + }); +}); diff --git a/src/server/routes/api/oauth.ts b/src/server/routes/api/oauth.ts new file mode 100644 index 0000000..d97fcad --- /dev/null +++ b/src/server/routes/api/oauth.ts @@ -0,0 +1,111 @@ +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { + deleteOauthConnection, + getOauthSessionStatus, + handleOauthCallback, + listOauthConnections, + listOauthProviders, + startOauthProviderFlow, + startOauthRebindFlow, +} from '../../services/oauth/service.js'; + +function resolveRequestOrigin(request: FastifyRequest): string { + const forwardedProto = typeof request.headers['x-forwarded-proto'] === 'string' + ? request.headers['x-forwarded-proto'].split(',')[0]?.trim() + : ''; + const protocol = forwardedProto || request.protocol || 'http'; + const forwardedHost = typeof request.headers['x-forwarded-host'] === 'string' + ? request.headers['x-forwarded-host'].split(',')[0]?.trim() + : ''; + const host = forwardedHost + || (typeof request.headers.host === 'string' ? request.headers.host.trim() : '') + || 'localhost'; + return `${protocol}://${host}`; +} + +export async function oauthRoutes(app: FastifyInstance) { + app.get('/api/oauth/providers', async () => ({ + providers: listOauthProviders(), + })); + + app.post<{ Params: { provider: string }; Body: { accountId?: number } }>( + '/api/oauth/providers/:provider/start', + async (request, reply) => { + try { + return startOauthProviderFlow({ + provider: request.params.provider, + redirectOrigin: resolveRequestOrigin(request), + rebindAccountId: request.body?.accountId, + }); + } catch (error: any) { + return reply.code(404).send({ message: error?.message || 'oauth provider not found' }); + } + }, + ); + + 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('/api/oauth/connections', async () => ({ + items: await listOauthConnections(), + })); + + app.post<{ Params: { accountId: string } }>( + '/api/oauth/connections/:accountId/rebind', + async (request, reply) => { + 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' }); + } + }, + ); + + app.delete<{ Params: { accountId: string } }>( + '/api/oauth/connections/:accountId', + async (request, reply) => { + 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' }); + } + }, + ); + + app.get<{ Params: { provider: string }; Querystring: { state?: string; code?: string; error?: string } }>( + '/api/oauth/callback/:provider', + async (request, reply) => { + let message = 'OAuth callback received.'; + try { + await handleOauthCallback({ + provider: request.params.provider, + state: String(request.query.state || ''), + code: request.query.code, + error: request.query.error, + }); + message = 'OAuth authorization succeeded. You can close this window.'; + } catch (error: any) { + message = `OAuth authorization failed: ${error?.message || 'unknown error'}`; + } + + reply.type('text/html; charset=utf-8'); + return ` + + + + OAuth Callback + + + + ${message} + +`; + }); +} diff --git a/src/server/routes/api/search.route.test.ts b/src/server/routes/api/search.route.test.ts index f467b0c..99afc2e 100644 --- a/src/server/routes/api/search.route.test.ts +++ b/src/server/routes/api/search.route.test.ts @@ -137,4 +137,57 @@ describe('search routes', () => { ], }); }); + + it('includes oauth direct-account model availability in model search results', async () => { + const site = await db.insert(schema.sites).values({ + name: 'codex site', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-user@example.com', + accessToken: 'oauth-access-token', + apiToken: null, + status: 'active', + extraConfig: mergeAccountExtraConfig(null, { + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'team', + }, + }), + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-5.2-codex', + available: true, + }).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/search', + payload: { + query: 'gpt-5.2', + limit: 20, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + models: [ + expect.objectContaining({ + name: 'gpt-5.2-codex', + accountCount: 1, + tokenCount: 0, + siteCount: 1, + }), + ], + }); + }); }); diff --git a/src/server/routes/api/search.ts b/src/server/routes/api/search.ts index de4f2a1..674bc9c 100644 --- a/src/server/routes/api/search.ts +++ b/src/server/routes/api/search.ts @@ -2,7 +2,7 @@ import { FastifyInstance } from 'fastify'; import { db, schema } from '../../db/index.js'; import { and, like, desc, eq, or } from 'drizzle-orm'; import { getProxyLogBaseSelectFields } from '../../services/proxyLogStore.js'; -import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js'; +import { getCredentialModeFromExtraConfig, supportsDirectAccountRoutingConnection } from '../../services/accountExtraConfig.js'; import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js'; function hasSessionTokenValue(value: string | null | undefined): boolean { @@ -141,6 +141,25 @@ export async function searchRoutes(app: FastifyInstance) { ) .limit(perCategory * 20) .all(); + const directAccountModelRows = await db.select({ + modelName: schema.modelAvailability.modelName, + accountId: schema.accounts.id, + siteId: schema.sites.id, + accounts: schema.accounts, + }) + .from(schema.modelAvailability) + .innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id)) + .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .where( + and( + like(schema.modelAvailability.modelName, q), + eq(schema.modelAvailability.available, true), + eq(schema.accounts.status, 'active'), + eq(schema.sites.status, 'active'), + ), + ) + .limit(perCategory * 20) + .all(); const modelAgg = new Map; accountIds: Set; siteIds: Set }>(); for (const row of modelRows) { @@ -153,6 +172,16 @@ export async function searchRoutes(app: FastifyInstance) { agg.accountIds.add(row.accountId); agg.siteIds.add(row.siteId); } + for (const row of directAccountModelRows) { + if (!supportsDirectAccountRoutingConnection(row.accounts)) continue; + const key = row.modelName; + if (!modelAgg.has(key)) { + modelAgg.set(key, { tokenIds: new Set(), accountIds: new Set(), siteIds: new Set() }); + } + const agg = modelAgg.get(key)!; + agg.accountIds.add(row.accountId); + agg.siteIds.add(row.siteId); + } const models = Array.from(modelAgg.entries()) .map(([name, agg]) => ({ diff --git a/src/server/routes/api/stats.token-candidates.test.ts b/src/server/routes/api/stats.token-candidates.test.ts index e64c9a1..7b21763 100644 --- a/src/server/routes/api/stats.token-candidates.test.ts +++ b/src/server/routes/api/stats.token-candidates.test.ts @@ -157,6 +157,52 @@ describe('/api/models/token-candidates', () => { expect(body.modelsMissingTokenGroups['gpt-5.2-codex']).toBeUndefined(); }); + it('does not report oauth direct connections as missing account tokens', async () => { + const site = await db.insert(schema.sites).values({ + name: 'codex-site', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-user@example.com', + accessToken: 'oauth-access-token', + apiToken: null, + status: 'active', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'team', + }, + }), + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-5.2-codex', + available: true, + }).run(); + + const response = await app.inject({ + method: 'GET', + url: '/api/models/token-candidates', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + modelsWithoutToken: Record>; + modelsMissingTokenGroups: Record>; + }; + + expect(body.modelsWithoutToken['gpt-5.2-codex']).toBeUndefined(); + expect(body.modelsMissingTokenGroups['gpt-5.2-codex']).toBeUndefined(); + }); + it('returns modelsMissingTokenGroups when account has partial group token coverage', async () => { const site = await db.insert(schema.sites).values({ name: 'site-b', diff --git a/src/server/routes/api/stats.ts b/src/server/routes/api/stats.ts index 0401c62..2e4dd6e 100644 --- a/src/server/routes/api/stats.ts +++ b/src/server/routes/api/stats.ts @@ -17,7 +17,7 @@ import { parseProxyLogBillingDetails, withProxyLogSelectFields, } from '../../services/proxyLogStore.js'; -import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js'; +import { requiresManagedAccountTokens } from '../../services/accountExtraConfig.js'; import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js'; import { formatLocalDateTime, @@ -34,12 +34,6 @@ function parseBooleanFlag(raw?: string): boolean { return normalized === '1' || normalized === 'true' || normalized === 'yes'; } -function isApiKeyConnection(account: { accessToken?: string | null; extraConfig?: string | null }): boolean { - const explicit = getCredentialModeFromExtraConfig(account.extraConfig); - if (explicit && explicit !== 'auto') return explicit === 'apikey'; - return !(account.accessToken || '').trim(); -} - const MODELS_MARKETPLACE_BASE_TTL_MS = 15_000; const MODELS_MARKETPLACE_PRICING_TTL_MS = 90_000; @@ -1012,6 +1006,7 @@ export async function statsRoutes(app: FastifyInstance) { siteId: schema.sites.id, siteName: schema.sites.name, accessToken: schema.accounts.accessToken, + apiToken: schema.accounts.apiToken, extraConfig: schema.accounts.extraConfig, }) .from(schema.modelAvailability) @@ -1090,7 +1085,7 @@ export async function statsRoutes(app: FastifyInstance) { } for (const row of availableModelRows) { - if (isApiKeyConnection(row)) continue; + if (!requiresManagedAccountTokens(row)) continue; const modelName = (row.modelName || '').trim(); if (!modelName) continue; const coverageKey = `${row.accountId}::${modelName.toLowerCase()}`; @@ -1107,7 +1102,7 @@ export async function statsRoutes(app: FastifyInstance) { const accountIdsForGroupHints = new Set( availableModelRows - .filter((row) => !isApiKeyConnection(row)) + .filter((row) => requiresManagedAccountTokens(row)) .map((row) => row.accountId), ); const requiredGroupsByAccountModel = new Map>(); @@ -1169,7 +1164,7 @@ export async function statsRoutes(app: FastifyInstance) { } for (const row of availableModelRows) { - if (isApiKeyConnection(row)) continue; + if (!requiresManagedAccountTokens(row)) continue; const modelName = (row.modelName || '').trim(); if (!modelName) continue; const accountModelKey = `${row.accountId}::${modelName.toLowerCase()}`; diff --git a/src/server/routes/proxy/chat.ts b/src/server/routes/proxy/chat.ts index 3ac16f6..90f4489 100644 --- a/src/server/routes/proxy/chat.ts +++ b/src/server/routes/proxy/chat.ts @@ -32,6 +32,10 @@ import { hasNonImageFileInputInOpenAiBody, resolveOpenAiBodyInputFiles, } from '../../services/proxyInputFileResolver.js'; +import { + buildCodexOauthProviderHeaders, + refreshCodexOauthAccessToken, +} from '../../services/oauth/service.js'; const MAX_RETRIES = 2; @@ -130,6 +134,14 @@ 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, + }) + : {} + ); const buildEndpointRequest = ( endpoint: 'chat' | 'messages' | 'responses', options: { forceNormalizeClaudeBody?: boolean } = {}, @@ -146,6 +158,7 @@ async function handleChatProxyRequest( claudeOriginalBody, forceNormalizeClaudeBody: options.forceNormalizeClaudeBody, downstreamHeaders: request.headers as Record, + providerHeaders: buildProviderHeaders(), }); return { endpoint, @@ -174,17 +187,48 @@ async function handleChatProxyRequest( }), ), }); + const tryRecover = async (ctx: Parameters>[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, + }; + } + ctx.request = refreshedRequest; + ctx.response = refreshedResponse; + ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error'); + } + return endpointStrategy.tryRecover(ctx); + }; let startTime = Date.now(); try { - const endpointResult = await executeEndpointFlow({ - siteUrl: selected.site.url, - proxyUrl: resolveProxyUrlForSite(selected.site), - endpointCandidates, - buildRequest: (endpoint) => buildEndpointRequest(endpoint), - tryRecover: endpointStrategy.tryRecover, - shouldDowngrade: endpointStrategy.shouldDowngrade, - onDowngrade: (ctx) => { + const endpointResult = await executeEndpointFlow({ + siteUrl: selected.site.url, + proxyUrl: resolveProxyUrlForSite(selected.site), + endpointCandidates, + buildRequest: (endpoint) => buildEndpointRequest(endpoint), + tryRecover, + shouldDowngrade: endpointStrategy.shouldDowngrade, + onDowngrade: (ctx) => { logProxy( selected, requestedModel, diff --git a/src/server/routes/proxy/responses.codex-oauth.test.ts b/src/server/routes/proxy/responses.codex-oauth.test.ts new file mode 100644 index 0000000..cae8dca --- /dev/null +++ b/src/server/routes/proxy/responses.codex-oauth.test.ts @@ -0,0 +1,216 @@ +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 refreshCodexAccessTokenMock = vi.fn(); +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('../../services/oauth/service.js', async () => { + const actual = await vi.importActual('../../services/oauth/service.js'); + return { + ...actual, + refreshCodexOauthAccessToken: (...args: unknown[]) => refreshCodexAccessTokenMock(...args), + }; +}); + +vi.mock('../../db/index.js', () => ({ + db: { + insert: (arg: any) => dbInsertMock(arg), + }, + hasProxyLogDownstreamApiKeyIdColumn: async () => false, + schema: { + proxyLogs: {}, + }, +})); + +describe('responses proxy codex oauth refresh', () => { + let app: FastifyInstance; + + beforeAll(async () => { + const { responsesProxyRoute } = await import('./responses.js'); + app = Fastify(); + await app.register(responsesProxyRoute); + }); + + beforeEach(() => { + fetchMock.mockReset(); + selectChannelMock.mockReset(); + selectNextChannelMock.mockReset(); + recordSuccessMock.mockReset(); + recordFailureMock.mockReset(); + refreshModelsAndRebuildRoutesMock.mockReset(); + reportProxyAllFailedMock.mockReset(); + reportTokenExpiredMock.mockReset(); + resolveProxyUsageWithSelfLogFallbackMock.mockClear(); + refreshCodexAccessTokenMock.mockReset(); + 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: 'plus', + }, + }), + }, + tokenName: 'default', + tokenValue: 'expired-access-token', + actualModel: 'gpt-5.2-codex', + }); + selectNextChannelMock.mockReturnValue(null); + refreshCodexAccessTokenMock.mockResolvedValue({ + accessToken: 'fresh-access-token', + accountId: 33, + accountKey: 'chatgpt-account-123', + }); + }); + + afterAll(async () => { + await app.close(); + }); + + it('refreshes codex oauth token and retries the same responses request on 401', async () => { + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { message: 'expired token', type: 'invalid_request_error' }, + }), { + status: 401, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'resp_codex_refreshed', + object: 'response', + model: 'gpt-5.2-codex', + status: 'completed', + output_text: 'ok after codex token refresh', + usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.2-codex', + input: 'hello codex', + }, + }); + + expect(response.statusCode).toBe(200); + expect(refreshCodexAccessTokenMock).toHaveBeenCalledWith(33); + expect(fetchMock).toHaveBeenCalledTimes(2); + + const [firstUrl, firstOptions] = fetchMock.mock.calls[0] as [string, any]; + const [secondUrl, secondOptions] = fetchMock.mock.calls[1] as [string, any]; + expect(firstUrl).toBe('https://chatgpt.com/backend-api/codex/responses'); + expect(secondUrl).toBe('https://chatgpt.com/backend-api/codex/responses'); + expect(firstOptions.headers.Authorization).toBe('Bearer expired-access-token'); + expect(secondOptions.headers.Authorization).toBe('Bearer fresh-access-token'); + expect(secondOptions.headers.Originator || secondOptions.headers.originator).toBe('codex_cli_rs'); + expect(secondOptions.headers['Chatgpt-Account-Id'] || secondOptions.headers['chatgpt-account-id']).toBe('chatgpt-account-123'); + expect(response.json()?.output_text).toContain('ok after codex token refresh'); + }); + + it('sends an explicit empty instructions field to codex responses when downstream body has no system prompt', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'resp_codex_no_system', + object: 'response', + model: 'gpt-5.2-codex', + status: 'completed', + output_text: 'ok without system prompt', + usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.2-codex', + input: 'hello codex', + }, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [, options] = fetchMock.mock.calls[0] as [string, any]; + const forwardedBody = JSON.parse(options.body); + expect(forwardedBody.instructions).toBe(''); + expect(forwardedBody.input).toEqual([ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello codex' }], + }, + ]); + }); +}); diff --git a/src/server/routes/proxy/responses.ts b/src/server/routes/proxy/responses.ts index 21c7d35..5acda61 100644 --- a/src/server/routes/proxy/responses.ts +++ b/src/server/routes/proxy/responses.ts @@ -27,6 +27,10 @@ import { hasNonImageFileInputInOpenAiBody, resolveResponsesBodyInputFiles, } from '../../services/proxyInputFileResolver.js'; +import { + buildCodexOauthProviderHeaders, + refreshCodexOauthAccessToken, +} from '../../services/oauth/service.js'; const MAX_RETRIES = 2; @@ -228,6 +232,14 @@ export async function responsesProxyRoute(app: FastifyInstance) { if (endpointCandidates.length === 0) { 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, + }) + : {} + ); const buildEndpointRequest = (endpoint: 'chat' | 'messages' | 'responses') => { const endpointRequest = buildUpstreamEndpointRequest({ endpoint, @@ -240,6 +252,7 @@ export async function responsesProxyRoute(app: FastifyInstance) { downstreamFormat: 'responses', responsesOriginalBody: normalizedResponsesBody, downstreamHeaders: request.headers as Record, + providerHeaders: buildProviderHeaders(), }); const upstreamPath = ( isCompactRequest && endpoint === 'responses' @@ -265,6 +278,37 @@ export async function responsesProxyRoute(app: FastifyInstance) { }), ), }); + const tryRecover = async (ctx: Parameters>[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, + }; + } + ctx.request = refreshedRequest; + ctx.response = refreshedResponse; + ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error'); + } + return endpointStrategy.tryRecover(ctx); + }; const startTime = Date.now(); @@ -274,7 +318,7 @@ export async function responsesProxyRoute(app: FastifyInstance) { proxyUrl: resolveProxyUrlForSite(selected.site), endpointCandidates, buildRequest: (endpoint) => buildEndpointRequest(endpoint), - tryRecover: endpointStrategy.tryRecover, + tryRecover, shouldDowngrade: endpointStrategy.shouldDowngrade, onDowngrade: (ctx) => { logProxy( diff --git a/src/server/routes/proxy/upstreamEndpoint.test.ts b/src/server/routes/proxy/upstreamEndpoint.test.ts index 00646c6..df41fe5 100644 --- a/src/server/routes/proxy/upstreamEndpoint.test.ts +++ b/src/server/routes/proxy/upstreamEndpoint.test.ts @@ -151,6 +151,16 @@ describe('resolveUpstreamEndpointCandidates', () => { 'claude', ); expect(claudeOrder).toEqual(['messages']); + + const codexOrder = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'codex', url: 'https://chatgpt.com/backend-api/codex' }, + }, + 'gpt-5.2-codex', + 'openai', + ); + expect(codexOrder).toEqual(['responses']); }); it('prefers document-capable endpoints when downstream content contains non-image files', async () => { @@ -319,6 +329,31 @@ describe('buildUpstreamEndpointRequest', () => { ]); }); + it('builds codex responses requests against backend-api path and preserves oauth provider headers', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'gpt-5.2-codex', + stream: false, + tokenValue: 'oauth-access-token', + sitePlatform: 'codex', + siteUrl: 'https://chatgpt.com/backend-api/codex', + openaiBody: { + model: 'gpt-5.2-codex', + messages: [{ role: 'user', content: 'hello codex' }], + }, + downstreamFormat: 'openai', + providerHeaders: { + Originator: 'codex_cli_rs', + 'Chatgpt-Account-Id': 'chatgpt-account-123', + }, + } as any); + + expect(request.path).toBe('/responses'); + expect(request.headers.Authorization).toBe('Bearer oauth-access-token'); + expect(request.headers.Originator).toBe('codex_cli_rs'); + expect(request.headers['Chatgpt-Account-Id']).toBe('chatgpt-account-123'); + }); + it('normalizes downstream responses input string before forwarding upstream', () => { const request = buildUpstreamEndpointRequest({ endpoint: 'responses', diff --git a/src/server/routes/proxy/upstreamEndpoint.ts b/src/server/routes/proxy/upstreamEndpoint.ts index 78baaa1..f5d7b5a 100644 --- a/src/server/routes/proxy/upstreamEndpoint.ts +++ b/src/server/routes/proxy/upstreamEndpoint.ts @@ -186,6 +186,18 @@ function toFiniteNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null; } +function ensureCodexResponsesInstructions( + body: Record, + sitePlatform: string, +): Record { + if (sitePlatform !== 'codex') return body; + if (typeof body.instructions === 'string') return body; + return { + ...body, + instructions: '', + }; +} + function normalizeEndpointTypes(value: unknown): UpstreamEndpoint[] { const raw = asTrimmedString(value).toLowerCase(); @@ -237,6 +249,10 @@ function preferredEndpointOrder( ): UpstreamEndpoint[] { const platform = normalizePlatformName(sitePlatform); + if (platform === 'codex') { + return ['responses']; + } + if (platform === 'gemini') { // Gemini upstream is routed through OpenAI-compatible chat endpoint. return ['chat']; @@ -430,6 +446,7 @@ export function buildUpstreamEndpointRequest(input: { forceNormalizeClaudeBody?: boolean; responsesOriginalBody?: Record; downstreamHeaders?: Record; + providerHeaders?: Record; }): { path: string; headers: Record; @@ -463,6 +480,10 @@ export function buildUpstreamEndpointRequest(input: { return '/v1/chat/completions'; } + if (sitePlatform === 'codex') { + return '/responses'; + } + if (sitePlatform === 'claude') { return '/v1/messages'; } @@ -476,6 +497,7 @@ export function buildUpstreamEndpointRequest(input: { const commonHeaders: Record = { ...passthroughHeaders, 'Content-Type': 'application/json', + ...(input.providerHeaders || {}), }; if (!isClaudeUpstream) { commonHeaders.Authorization = `Bearer ${input.tokenValue}`; @@ -564,7 +586,10 @@ export function buildUpstreamEndpointRequest(input: { } : convertOpenAiBodyToResponsesBodyViaTransformer(openaiBody, input.modelName, input.stream) ); - const body = sanitizeResponsesBodyForProxyViaTransformer(rawBody, input.modelName, input.stream); + const body = ensureCodexResponsesInstructions( + sanitizeResponsesBodyForProxyViaTransformer(rawBody, input.modelName, input.stream), + sitePlatform, + ); const headers = ensureStreamAcceptHeader({ ...commonHeaders, diff --git a/src/server/services/accountExtraConfig.ts b/src/server/services/accountExtraConfig.ts index 280ee69..233766d 100644 --- a/src/server/services/accountExtraConfig.ts +++ b/src/server/services/accountExtraConfig.ts @@ -20,6 +20,10 @@ const VALID_CREDENTIAL_MODES = new Set([ type AccountExtraConfig = { platformUserId?: unknown; credentialMode?: unknown; + oauth?: { + provider?: unknown; + [key: string]: unknown; + }; autoRelogin?: AutoReloginConfig; sub2apiAuth?: Sub2ApiAuthConfig; [key: string]: unknown; @@ -77,6 +81,43 @@ export function getCredentialModeFromExtraConfig(extraConfig?: string | null): A return normalizeCredentialMode(parsed.credentialMode); } +export function getOauthProviderFromExtraConfig(extraConfig?: string | null): string | undefined { + const parsed = parseExtraConfig(extraConfig); + return normalizeNonEmptyString(parsed.oauth?.provider); +} + +export function hasOauthProvider(extraConfig?: string | null): boolean { + return !!getOauthProviderFromExtraConfig(extraConfig); +} + +type DirectAccountRoutingInput = { + accessToken?: string | null; + apiToken?: string | null; + extraConfig?: string | null; +}; + +function hasCredentialValue(value: string | null | undefined): boolean { + return typeof value === 'string' && value.trim().length > 0; +} + +export function supportsDirectAccountRoutingConnection(account: DirectAccountRoutingInput): boolean { + const credentialMode = getCredentialModeFromExtraConfig(account.extraConfig); + if (credentialMode === 'apikey') { + return hasCredentialValue(account.apiToken); + } + if (hasOauthProvider(account.extraConfig)) { + return hasCredentialValue(account.accessToken); + } + return false; +} + +export function requiresManagedAccountTokens(account: DirectAccountRoutingInput): boolean { + const credentialMode = getCredentialModeFromExtraConfig(account.extraConfig); + if (credentialMode === 'apikey') return false; + if (hasOauthProvider(account.extraConfig)) return false; + return true; +} + export type ManagedSub2ApiAuth = { refreshToken: string; tokenExpiresAt?: number; diff --git a/src/server/services/modelService.discovery.test.ts b/src/server/services/modelService.discovery.test.ts index 58390f8..0a32805 100644 --- a/src/server/services/modelService.discovery.test.ts +++ b/src/server/services/modelService.discovery.test.ts @@ -6,6 +6,7 @@ import { eq } from 'drizzle-orm'; const getApiTokenMock = vi.fn(); const getModelsMock = vi.fn(); +const undiciFetchMock = vi.fn(); vi.mock('./platforms/index.js', () => ({ getAdapter: () => ({ @@ -14,6 +15,10 @@ vi.mock('./platforms/index.js', () => ({ }), })); +vi.mock('undici', () => ({ + fetch: (...args: unknown[]) => undiciFetchMock(...args), +})); + type DbModule = typeof import('../db/index.js'); type ModelServiceModule = typeof import('./modelService.js'); @@ -39,6 +44,7 @@ describe('refreshModelsForAccount credential discovery', () => { beforeEach(async () => { getApiTokenMock.mockReset(); getModelsMock.mockReset(); + undiciFetchMock.mockReset(); await db.delete(schema.routeChannels).run(); await db.delete(schema.tokenRoutes).run(); @@ -300,4 +306,179 @@ describe('refreshModelsForAccount credential discovery', () => { expect(placeholderModels).toEqual([]); expect(getModelsMock).not.toHaveBeenCalledWith(site.url, 'sk-mask***tail', account.username); }); + + it('discovers codex models from upstream cloud endpoint without adapter model fetch', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockRejectedValue(new Error('codex plan discovery should not call adapter.getModels')); + undiciFetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + models: [ + { id: 'gpt-5.4' }, + { id: 'gpt-5.3-codex' }, + { id: 'gpt-5.2-codex' }, + { id: 'gpt-5.2' }, + { id: 'gpt-5.1-codex-max' }, + { id: 'gpt-5.1-codex' }, + { id: 'gpt-5.1' }, + { id: 'gpt-5-codex' }, + { id: 'gpt-5' }, + { id: 'gpt-5.1-codex-mini' }, + { id: 'gpt-5-codex-mini' }, + ], + }), + text: async () => JSON.stringify({ ok: true }), + }); + + const site = await db.insert(schema.sites).values({ + name: 'codex-site', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-user@example.com', + accessToken: 'oauth-access-token', + apiToken: null, + status: 'active', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'plus', + }, + }), + }).returning().get(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'success', + errorCode: null, + tokenScanned: 0, + discoveredByCredential: true, + modelCount: 11, + }); + expect(result.modelsPreview).toEqual([ + 'gpt-5.4', + 'gpt-5.3-codex', + 'gpt-5.2-codex', + 'gpt-5.2', + 'gpt-5.1-codex-max', + 'gpt-5.1-codex', + 'gpt-5.1', + 'gpt-5-codex', + 'gpt-5', + 'gpt-5.1-codex-mini', + ]); + expect(getModelsMock).not.toHaveBeenCalled(); + expect(undiciFetchMock).toHaveBeenCalledTimes(1); + expect(String(undiciFetchMock.mock.calls[0]?.[0] || '')).toBe('https://chatgpt.com/backend-api/codex/models?client_version=1.0.0'); + expect(undiciFetchMock.mock.calls[0]?.[1]).toMatchObject({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: 'Bearer oauth-access-token', + 'Chatgpt-Account-Id': 'chatgpt-account-123', + Originator: 'codex_cli_rs', + }), + }); + + const rows = await db.select().from(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, account.id)) + .all(); + const modelNames = rows.map((row) => row.modelName); + expect(modelNames.sort()).toEqual([ + 'gpt-5', + 'gpt-5-codex', + 'gpt-5-codex-mini', + 'gpt-5.1', + 'gpt-5.1-codex', + 'gpt-5.1-codex-max', + 'gpt-5.1-codex-mini', + 'gpt-5.2', + 'gpt-5.2-codex', + 'gpt-5.3-codex', + 'gpt-5.4', + ]); + }); + + it('marks codex oauth account abnormal when upstream cloud discovery fails', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockRejectedValue(new Error('codex plan discovery should not call adapter.getModels')); + undiciFetchMock.mockResolvedValue({ + ok: false, + status: 403, + json: async () => ({ error: 'forbidden' }), + text: async () => 'forbidden', + }); + + const site = await db.insert(schema.sites).values({ + name: 'codex-team-site', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'team-user@example.com', + accessToken: 'oauth-access-token', + apiToken: null, + status: 'active', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-team', + email: 'team-user@example.com', + planType: 'team', + }, + }), + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-5.2-codex', + available: true, + checkedAt: '2026-03-16T12:00:00.000Z', + }).run(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'failed', + errorCode: 'unauthorized', + tokenScanned: 0, + discoveredByCredential: false, + modelCount: 0, + }); + expect(getModelsMock).not.toHaveBeenCalled(); + expect(undiciFetchMock).toHaveBeenCalledTimes(1); + + const rows = await db.select().from(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, account.id)) + .all(); + expect(rows).toEqual([]); + + const latest = await db.select().from(schema.accounts) + .where(eq(schema.accounts.id, account.id)) + .get(); + const parsed = JSON.parse(latest!.extraConfig || '{}'); + expect(parsed.oauth).toMatchObject({ + provider: 'codex', + modelDiscoveryStatus: 'abnormal', + }); + expect(parsed.oauth.lastModelSyncError).toContain('HTTP 403'); + expect(parsed.oauth.lastModelSyncAt).toMatch(/\d{4}-\d{2}-\d{2}T/); + expect(parsed.runtimeHealth?.state).toBe('unhealthy'); + }); }); diff --git a/src/server/services/modelService.test.ts b/src/server/services/modelService.test.ts index 61d00c0..00b6e7d 100644 --- a/src/server/services/modelService.test.ts +++ b/src/server/services/modelService.test.ts @@ -85,6 +85,59 @@ describe('rebuildTokenRoutesFromAvailability', () => { expect(channels[0]?.manualOverride).toBe(false); }); + it('creates an exact route with an account-direct channel for oauth model availability', async () => { + const site = await db.insert(schema.sites).values({ + name: 'codex-site', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-user@example.com', + accessToken: 'oauth-access-token', + apiToken: null, + status: 'active', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'team', + }, + }), + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-5.2-codex', + available: true, + latencyMs: 320, + checkedAt: '2026-03-17T00:00:00.000Z', + }).run(); + + const rebuild = await rebuildTokenRoutesFromAvailability(); + + expect(rebuild.models).toBe(1); + + const route = await db.select().from(schema.tokenRoutes) + .where(eq(schema.tokenRoutes.modelPattern, 'gpt-5.2-codex')) + .get(); + expect(route).toBeDefined(); + + const channels = await db.select().from(schema.routeChannels) + .where(and( + eq(schema.routeChannels.routeId, route!.id), + eq(schema.routeChannels.accountId, account.id), + )) + .all(); + + expect(channels).toHaveLength(1); + expect(channels[0]?.tokenId ?? null).toBeNull(); + expect(channels[0]?.manualOverride).toBe(false); + }); + it('removes stale exact routes and keeps wildcard routes on rebuild', async () => { const site = await db.insert(schema.sites).values({ name: 'site-1', diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index f3751fe..9a871bf 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -1,4 +1,5 @@ import { and, eq } from 'drizzle-orm'; +import { fetch } from 'undici'; import { db, schema } from '../db/index.js'; import { getAdapter } from './platforms/index.js'; import { @@ -8,14 +9,22 @@ import { isMaskedTokenValue, isUsableAccountToken, } from './accountTokenService.js'; -import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from './accountExtraConfig.js'; +import { + getCredentialModeFromExtraConfig, + mergeAccountExtraConfig, + resolvePlatformUserId, + supportsDirectAccountRoutingConnection, +} from './accountExtraConfig.js'; 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'; 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'; type ModelRefreshErrorCode = 'timeout' | 'unauthorized' | 'empty_models' | 'unknown'; type ModelRefreshSkipCode = 'site_disabled' | 'adapter_or_status'; @@ -120,6 +129,86 @@ function normalizeModels(models: string[]): string[] { return normalizedModels; } +function buildCodexModelsEndpoint(baseUrl: string): string { + const normalized = (baseUrl || '').replace(/\/+$/, ''); + return `${normalized}/models?client_version=${encodeURIComponent(CODEX_MODELS_CLIENT_VERSION)}`; +} + +function extractCodexModelIds(payload: unknown): string[] { + const collection = (() => { + if (Array.isArray(payload)) return payload; + if (!payload || typeof payload !== 'object') return []; + const record = payload as Record; + if (Array.isArray(record.models)) return record.models; + if (Array.isArray(record.data)) return record.data; + if (Array.isArray(record.items)) return record.items; + return []; + })(); + + return collection.flatMap((item) => { + if (typeof item === 'string') return [item]; + if (!item || typeof item !== 'object' || Array.isArray(item)) return []; + const record = item as Record; + const id = typeof record.id === 'string' + ? record.id + : (typeof record.slug === 'string' ? record.slug : (typeof record.model === 'string' ? record.model : '')); + return id ? [id] : []; + }); +} + +async function updateCodexOauthModelDiscoveryState(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 extraConfig = mergeAccountExtraConfig(input.account.extraConfig, { + oauth: buildCodexOauthInfo(input.account.extraConfig, { + modelDiscoveryStatus: input.status, + lastModelSyncAt: input.checkedAt, + lastModelSyncError: input.lastModelSyncError, + lastDiscoveredModels: input.lastDiscoveredModels ?? [], + }), + }); + await db.update(schema.accounts).set({ + extraConfig, + updatedAt: input.checkedAt, + }).where(eq(schema.accounts.id, input.account.id)).run(); + input.account.extraConfig = extraConfig; + return extraConfig; +} + +async function discoverCodexModelsFromCloud(input: { + site: typeof schema.sites.$inferSelect; + account: typeof schema.accounts.$inferSelect; +}): Promise { + const accessToken = (input.account.accessToken || '').trim(); + if (!accessToken) { + throw new Error('codex oauth access token missing'); + } + const oauth = getCodexOauthInfoFromExtraConfig(input.account.extraConfig); + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + Originator: 'codex_cli_rs', + }; + if (oauth?.accountId) { + headers['Chatgpt-Account-Id'] = oauth.accountId; + } + + const response = await fetch( + buildCodexModelsEndpoint(input.site.url), + withSiteRecordProxyRequestInit(input.site, { method: 'GET', headers }), + ); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`HTTP ${response.status}: ${text || 'codex model discovery failed'}`); + } + return normalizeModels(extractCodexModelIds(await response.json())); +} + function isExactModelPattern(modelPattern: string): boolean { const normalized = modelPattern.trim(); if (!normalized) return false; @@ -252,6 +341,76 @@ export async function refreshModelsForAccount(accountId: number): Promise discoverCodexModelsFromCloud({ site, account }), + MODEL_DISCOVERY_TIMEOUT_MS, + `codex model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, + ); + if (codexModels.length === 0) { + throw new Error('未获取到可用模型'); + } + + await db.insert(schema.modelAvailability).values( + codexModels.map((modelName) => ({ + accountId, + modelName, + available: true, + latencyMs: Date.now() - startedAt, + checkedAt, + })), + ).run(); + await updateCodexOauthModelDiscoveryState({ + account, + checkedAt, + status: 'healthy', + lastDiscoveredModels: codexModels, + }); + await setAccountRuntimeHealth(accountId, { + state: 'healthy', + reason: 'Codex 云端模型探测成功', + source: 'model-discovery', + checkedAt, + }); + return buildSuccessfulRefreshResult({ + accountId, + modelCount: codexModels.length, + modelsPreview: codexModels.slice(0, 10), + tokenScanned: 0, + discoveredByCredential: true, + discoveredApiToken: false, + }); + } catch (err) { + const rawMessage = (err as { message?: string })?.message || 'codex model discovery failed'; + const errorCode = classifyModelDiscoveryError(rawMessage); + const errorMessage = `Codex 模型获取失败(${rawMessage})`; + await updateCodexOauthModelDiscoveryState({ + 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, + }); + } + } + const platformUserId = resolvePlatformUserId(account.extraConfig, account.username); let discoveredApiToken: string | null = null; @@ -513,8 +672,7 @@ export async function rebuildTokenRoutesFromAvailability() { } for (const row of accountRows) { - if (!isApiKeyConnection(row.accounts)) continue; - if (!(row.accounts.apiToken || '').trim()) continue; + if (!supportsDirectAccountRoutingConnection(row.accounts)) continue; addModelCandidate(row.model_availability.modelName, row.accounts.id, null, row.accounts.siteId); } diff --git a/src/server/services/oauth/codexAccount.ts b/src/server/services/oauth/codexAccount.ts new file mode 100644 index 0000000..5c2395d --- /dev/null +++ b/src/server/services/oauth/codexAccount.ts @@ -0,0 +1,124 @@ +import { schema } from '../../db/index.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 = { + 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), + }; +} + +export function isCodexPlatform(account: Pick | string | null | undefined): boolean { + const extraConfig = typeof account === 'string' || account == null + ? account + : account.extraConfig; + return getCodexOauthInfoFromExtraConfig(extraConfig)?.provider === 'codex'; +} + +export function buildCodexOauthInfo( + extraConfig?: string | null, + patch: Partial = {}, +): CodexOauthInfo { + const current = getCodexOauthInfoFromExtraConfig(extraConfig); + return { + provider: 'codex', + ...(current || {}), + ...patch, + }; +} diff --git a/src/server/services/oauth/codexProvider.ts b/src/server/services/oauth/codexProvider.ts new file mode 100644 index 0000000..b38f8ed --- /dev/null +++ b/src/server/services/oauth/codexProvider.ts @@ -0,0 +1,133 @@ +import { fetch } from 'undici'; +import { createPkceChallenge } from './sessionStore.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_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'; + +type CodexJwtClaims = { + email?: unknown; + 'https://api.openai.com/auth'?: { + chatgpt_account_id?: unknown; + chatgpt_plan_type?: unknown; + }; +}; + +export type CodexTokenExchangeResult = { + accessToken: string; + refreshToken: string; + idToken: string; + accountId?: string; + email?: string; + planType?: string; + tokenExpiresAt: number; +}; + +function asTrimmedString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +} + +function parseJwtClaims(token: string): CodexJwtClaims | null { + const parts = token.split('.'); + if (parts.length !== 3) return null; + try { + const payload = JSON.parse(Buffer.from(parts[1] || '', 'base64url').toString('utf8')) as unknown; + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null; + return payload as CodexJwtClaims; + } catch { + return null; + } +} + +export function buildCodexAuthorizationUrl(input: { + state: string; + redirectUri: string; + codeVerifier: string; +}): string { + const params = new URLSearchParams({ + client_id: CODEX_CLIENT_ID, + response_type: 'code', + redirect_uri: input.redirectUri, + scope: 'openid email profile offline_access', + state: input.state, + code_challenge: createPkceChallenge(input.codeVerifier), + code_challenge_method: 'S256', + prompt: 'login', + id_token_add_organizations: 'true', + codex_cli_simplified_flow: 'true', + }); + return `${CODEX_AUTH_URL}?${params.toString()}`; +} + +function parseTokenResponsePayload(payload: unknown): CodexTokenExchangeResult { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('codex token exchange returned invalid payload'); + } + const body = payload as Record; + const accessToken = asTrimmedString(body.access_token); + const refreshToken = asTrimmedString(body.refresh_token); + const idToken = asTrimmedString(body.id_token); + const expiresIn = typeof body.expires_in === 'number' && Number.isFinite(body.expires_in) + ? Math.trunc(body.expires_in) + : (typeof body.expires_in === 'string' ? Number.parseInt(body.expires_in.trim(), 10) : NaN); + if (!accessToken || !refreshToken || !idToken || !Number.isFinite(expiresIn) || expiresIn <= 0) { + throw new Error('codex token exchange response missing required fields'); + } + const claims = parseJwtClaims(idToken); + return { + accessToken, + refreshToken, + idToken, + accountId: asTrimmedString(claims?.['https://api.openai.com/auth']?.chatgpt_account_id), + email: asTrimmedString(claims?.email), + planType: asTrimmedString(claims?.['https://api.openai.com/auth']?.chatgpt_plan_type), + tokenExpiresAt: Date.now() + expiresIn * 1000, + }; +} + +async function exchangeCodexToken(form: URLSearchParams): Promise { + const response = await fetch(CODEX_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: form.toString(), + }); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(text || `codex token exchange failed with status ${response.status}`); + } + return parseTokenResponsePayload(await response.json()); +} + +export async function exchangeCodexAuthorizationCode(input: { + code: string; + codeVerifier: string; + redirectUri: string; +}): Promise { + return exchangeCodexToken(new URLSearchParams({ + grant_type: 'authorization_code', + client_id: CODEX_CLIENT_ID, + code: input.code, + redirect_uri: input.redirectUri, + code_verifier: input.codeVerifier, + })); +} + +export async function refreshCodexTokens(refreshToken: string): Promise { + return exchangeCodexToken(new URLSearchParams({ + client_id: CODEX_CLIENT_ID, + grant_type: 'refresh_token', + refresh_token: refreshToken, + scope: 'openid profile email', + })); +} diff --git a/src/server/services/oauth/localCallbackServer.test.ts b/src/server/services/oauth/localCallbackServer.test.ts new file mode 100644 index 0000000..5fef4de --- /dev/null +++ b/src/server/services/oauth/localCallbackServer.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + startCodexLoopbackCallbackServer, + stopCodexLoopbackCallbackServer, +} from './localCallbackServer.js'; + +describe('codex loopback callback server', () => { + afterEach(async () => { + await stopCodexLoopbackCallbackServer(); + }); + + it('accepts 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, + callbackHandler, + }); + + const response = await fetch(`${started.origin}/auth/callback?state=test-state&code=test-code`); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(callbackHandler).toHaveBeenCalledWith({ + provider: 'codex', + state: 'test-state', + code: 'test-code', + error: undefined, + }); + expect(body).toContain('window.close()'); + }); + + it('renders an 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, + callbackHandler, + }); + + const response = await fetch(`${started.origin}/auth/callback?state=test-state&code=test-code`); + const body = await response.text(); + + expect(response.status).toBe(500); + expect(body).toContain('oauth failed'); + }); +}); diff --git a/src/server/services/oauth/localCallbackServer.ts b/src/server/services/oauth/localCallbackServer.ts new file mode 100644 index 0000000..341beb7 --- /dev/null +++ b/src/server/services/oauth/localCallbackServer.ts @@ -0,0 +1,183 @@ +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 { handleOauthCallback } from './service.js'; + +type CallbackHandler = typeof handleOauthCallback; + +type StartCodexLoopbackCallbackServerOptions = { + port?: number; + host?: string; + callbackHandler?: CallbackHandler; +}; + +export type CodexLoopbackCallbackServerState = { + attempted: boolean; + ready: boolean; + host?: string; + port: number; + origin: 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 | null = null; + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function renderCompletionPage(message: string): string { + const safeMessage = escapeHtml(message); + return ` + + + + Codex OAuth Callback + + + + ${safeMessage} + +`; +} + +function respondHtml(response: ServerResponse, statusCode: number, message: string) { + response.writeHead(statusCode, { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + }); + response.end(renderCompletionPage(message)); +} + +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}`; + } + if (host.includes(':') && !host.startsWith('[')) { + return `http://[${host}]:${port}`; + } + return `http://${host}:${port}`; +} + +export function getCodexLoopbackCallbackServerState(): CodexLoopbackCallbackServerState { + return { ...callbackServerState }; +} + +export async function startCodexLoopbackCallbackServer( + options: StartCodexLoopbackCallbackServerOptions = {}, +): Promise { + if (callbackServer) { + return getCodexLoopbackCallbackServerState(); + } + if (startPromise) { + return startPromise; + } + + const callbackHandler = options.callbackHandler || handleOauthCallback; + const requestedPort = options.port ?? CODEX_LOOPBACK_CALLBACK_PORT; + const requestedHost = options.host; + + startPromise = new Promise((resolve, reject) => { + const server = createServer((request, response) => { + void handleCallbackRequest(request, response, callbackHandler); + }); + + const finalizeFailure = (error: Error) => { + callbackServerState = { + attempted: true, + ready: false, + host: requestedHost, + port: requestedPort, + origin: normalizeOrigin(requestedHost, requestedPort), + error: error.message || 'failed to start codex oauth callback server', + }; + callbackServer = null; + reject(error); + }; + + server.once('error', (error) => { + finalizeFailure(error as Error); + }); + + server.listen(requestedPort, requestedHost, () => { + server.removeAllListeners('error'); + callbackServer = server; + const address = server.address() as AddressInfo | null; + const port = address?.port || requestedPort; + const host = address?.address || requestedHost; + callbackServerState = { + attempted: true, + ready: true, + host, + port, + origin: normalizeOrigin(host, port), + }; + resolve(getCodexLoopbackCallbackServerState()); + }); + }).finally(() => { + startPromise = null; + }); + + return startPromise; +} + +export async function stopCodexLoopbackCallbackServer(): Promise { + const server = callbackServer; + callbackServer = null; + callbackServerState = { ...DEFAULT_STATE }; + + if (!server) return; + + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} diff --git a/src/server/services/oauth/service.ts b/src/server/services/oauth/service.ts new file mode 100644 index 0000000..7406258 --- /dev/null +++ b/src/server/services/oauth/service.ts @@ -0,0 +1,427 @@ +import { eq } 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'; + +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'; + +function asNonEmptyString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +} + +function getHeaderValue(headers: Record | 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'; +} + +async function getNextAccountSortOrder(): Promise { + 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; +} + +async function getNextSiteSortOrder(): Promise { + 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; +} + +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 + )); + if (existing) return existing; + + return db.insert(schema.sites).values({ + name: CODEX_SITE_NAME, + url: CODEX_UPSTREAM_BASE_URL, + platform: 'codex', + status: 'active', + useSystemProxy: false, + isPinned: false, + globalWeight: 1, + sortOrder: await getNextSiteSortOrder(), + }).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) { + return db.select().from(schema.accounts) + .where(eq(schema.accounts.id, rebindAccountId)) + .get(); + } + + const accounts = await db.select().from(schema.accounts).all(); + return accounts.find((account) => matchesCodexIdentity(account, exchange)) || null; +} + +async function upsertCodexAccount(exchange: CodexTokenExchangeResult, rebindAccountId?: number) { + const site = await ensureCodexSite(); + const existing = await findExistingCodexAccount(exchange, rebindAccountId); + const username = buildUsername(exchange); + 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, + }, + }); + + if (existing) { + await db.update(schema.accounts).set({ + siteId: site.id, + username, + accessToken: exchange.accessToken, + apiToken: null, + checkinEnabled: false, + status: 'active', + extraConfig, + updatedAt: new Date().toISOString(), + }).where(eq(schema.accounts.id, existing.id)).run(); + return { + account: await db.select().from(schema.accounts).where(eq(schema.accounts.id, existing.id)).get(), + site, + created: false, + }; + } + + const created = await db.insert(schema.accounts).values({ + siteId: site.id, + username, + accessToken: exchange.accessToken, + apiToken: null, + checkinEnabled: false, + status: 'active', + extraConfig, + isPinned: false, + sortOrder: await getNextAccountSortOrder(), + }).returning().get(); + return { account: created, site, created: true }; +} + +export function listOauthProviders() { + return OAUTH_PROVIDER_LIST; +} + +export function startOauthProviderFlow(input: { + provider: string; + redirectOrigin: string; + rebindAccountId?: number; +}) { + if (input.provider !== CODEX_OAUTH_PROVIDER) { + 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 = CODEX_LOOPBACK_REDIRECT_URI; + const session = createOauthSession({ + provider: input.provider, + redirectUri, + rebindAccountId: input.rebindAccountId, + }); + return { + provider: input.provider, + state: session.state, + authorizationUrl: buildCodexAuthorizationUrl({ + state: session.state, + redirectUri, + codeVerifier: session.codeVerifier, + }), + }; +} + +export function getOauthSessionStatus(state: string) { + const session = getOauthSession(state); + if (!session) return null; + return { + provider: session.provider, + state: session.state, + status: session.status, + accountId: session.accountId, + siteId: session.siteId, + error: session.error, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + }; +} + +export async function handleOauthCallback(input: { + provider: string; + state: string; + code?: string; + error?: string; +}) { + const session = getOauthSession(input.state); + if (!session || session.provider !== input.provider) { + throw new Error('oauth session not found or provider mismatch'); + } + if (input.error) { + markOauthSessionError(input.state, input.error); + throw new Error(input.error); + } + const code = asNonEmptyString(input.code); + if (!code) { + markOauthSessionError(input.state, 'missing oauth code'); + throw new Error('missing oauth code'); + } + + const exchange = await exchangeCodexAuthorizationCode({ + code, + codeVerifier: session.codeVerifier, + redirectUri: session.redirectUri, + }); + const { account, site, created } = await upsertCodexAccount(exchange, session.rebindAccountId) as Awaited> & { created: boolean }; + if (!account) { + markOauthSessionError(input.state, 'failed to persist oauth account'); + throw new Error('failed to persist oauth account'); + } + + const refreshResult = await refreshModelsForAccount(account.id); + if (refreshResult.status !== 'success') { + if (created) { + await db.delete(schema.accounts).where(eq(schema.accounts.id, account.id)).run(); + } + await rebuildTokenRoutesFromAvailability(); + const errorMessage = refreshResult.errorMessage || 'codex model discovery failed'; + markOauthSessionError(input.state, errorMessage); + throw new Error(errorMessage); + } + + await rebuildTokenRoutesFromAvailability(); + markOauthSessionSuccess(input.state, { + accountId: account.id, + siteId: site.id, + }); + return { accountId: account.id, siteId: site.id }; +} + +export async function listOauthConnections() { + const rows = await db.select().from(schema.accounts) + .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .all(); + const modelRows = await db.select({ + accountId: schema.modelAvailability.accountId, + modelName: schema.modelAvailability.modelName, + }).from(schema.modelAvailability) + .where(eq(schema.modelAvailability.available, true)) + .all(); + const modelMap = new Map(); + for (const row of modelRows) { + if (typeof row.accountId !== 'number') continue; + const list = modelMap.get(row.accountId) || []; + list.push(row.modelName); + modelMap.set(row.accountId, list); + } + const routeChannelRows = await db.select({ + accountId: schema.routeChannels.accountId, + }).from(schema.routeChannels).all(); + const routeChannelCountByAccount = new Map(); + for (const row of routeChannelRows) { + const current = routeChannelCountByAccount.get(row.accountId) || 0; + routeChannelCountByAccount.set(row.accountId, current + 1); + } + + 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, + }, + }; + }); +} + +export async function deleteOauthConnection(accountId: number) { + 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); + if (!oauth) { + throw new Error('account is not managed by oauth'); + } + await db.delete(schema.accounts).where(eq(schema.accounts.id, accountId)).run(); + await rebuildTokenRoutesFromAvailability(); + return { success: true }; +} + +export async function startOauthRebindFlow(accountId: number, redirectOrigin: 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); + if (!oauth) { + throw new Error('account is not managed by oauth'); + } + return startOauthProviderFlow({ + provider: oauth.provider, + redirectOrigin, + rebindAccountId: accountId, + }); +} + +export function buildCodexOauthProviderHeaders(input: { + extraConfig?: string | null; + downstreamHeaders?: Record; +}) { + 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 = { + Originator: originator, + }; + if (accountId) { + headers['Chatgpt-Account-Id'] = accountId; + } + return headers; +} + +export async function refreshCodexOauthAccessToken(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'); + } + const oauth = getCodexOauthInfoFromExtraConfig(account.extraConfig); + if (!oauth?.refreshToken) { + throw new Error('codex oauth refresh token missing'); + } + + const refreshed = await refreshCodexTokens(oauth.refreshToken); + 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, + }, + }); + + await db.update(schema.accounts).set({ + accessToken: refreshed.accessToken, + extraConfig, + status: 'active', + updatedAt: new Date().toISOString(), + }).where(eq(schema.accounts.id, accountId)).run(); + + return { + accountId, + accessToken: refreshed.accessToken, + accountKey: refreshed.accountId || oauth.accountId, + extraConfig, + }; +} diff --git a/src/server/services/oauth/sessionStore.ts b/src/server/services/oauth/sessionStore.ts new file mode 100644 index 0000000..52d4f01 --- /dev/null +++ b/src/server/services/oauth/sessionStore.ts @@ -0,0 +1,106 @@ +import { createHash, randomBytes } from 'node:crypto'; + +export type OAuthSessionStatus = 'pending' | 'success' | 'error'; + +export type OAuthSessionRecord = { + provider: string; + state: string; + status: OAuthSessionStatus; + codeVerifier: string; + redirectUri: string; + createdAt: string; + updatedAt: string; + expiresAt: string; + accountId?: number; + siteId?: number; + error?: string; + rebindAccountId?: number; +}; + +const SESSION_TTL_MS = 10 * 60 * 1000; +const oauthSessions = new Map(); + +function nowIso(): string { + return new Date().toISOString(); +} + +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 function createOauthSession(input: { + provider: string; + redirectUri: string; + rebindAccountId?: number; +}): 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; +} + +export function getOauthSession(state: string): OAuthSessionRecord | null { + pruneExpiredSessions(); + return oauthSessions.get(state) || null; +} + +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; +} + +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; +} diff --git a/src/server/services/platforms/codex.ts b/src/server/services/platforms/codex.ts new file mode 100644 index 0000000..f44b473 --- /dev/null +++ b/src/server/services/platforms/codex.ts @@ -0,0 +1,34 @@ +import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js'; + +function normalizeBaseUrl(baseUrl: string): string { + return (baseUrl || '').replace(/\/+$/, ''); +} + +export class CodexAdapter extends BasePlatformAdapter { + readonly platformName = 'codex'; + + async detect(url: string): Promise { + const normalized = normalizeBaseUrl(url).toLowerCase(); + return normalized.includes('chatgpt.com/backend-api/codex'); + } + + override async login(_baseUrl: string, _username: string, _password: string) { + return { success: false as const, message: 'codex oauth login is managed via OAuth flow' }; + } + + override async getUserInfo(_baseUrl: string, _accessToken: string): Promise { + return null; + } + + async checkin(_baseUrl: string, _accessToken: string): Promise { + return { success: false, message: 'codex oauth connections do not support checkin' }; + } + + async getBalance(_baseUrl: string, _accessToken: string): Promise { + return { balance: 0, used: 0, quota: 0 }; + } + + async getModels(_baseUrl: string, _token: string): Promise { + return []; + } +} diff --git a/src/server/services/platforms/index.test.ts b/src/server/services/platforms/index.test.ts index 7ca94e3..15c11c4 100644 --- a/src/server/services/platforms/index.test.ts +++ b/src/server/services/platforms/index.test.ts @@ -58,6 +58,11 @@ describe('getAdapter platform aliases', () => { expect(getAdapter('gemini')?.platformName).toBe('gemini'); }); + it('supports dedicated codex adapter and aliases', () => { + expect(getAdapter('codex')?.platformName).toBe('codex'); + expect(getAdapter('chatgpt-codex')?.platformName).toBe('codex'); + }); + it('detects anyrouter URL before generic new-api adapter', async () => { const adapter = await detectPlatform('https://anyrouter.top'); expect(adapter?.platformName).toBe('anyrouter'); diff --git a/src/server/services/platforms/index.ts b/src/server/services/platforms/index.ts index 4829dac..ae1f15c 100644 --- a/src/server/services/platforms/index.ts +++ b/src/server/services/platforms/index.ts @@ -7,6 +7,7 @@ import { OneHubAdapter } from './oneHub.js'; import { DoneHubAdapter } from './doneHub.js'; import { Sub2ApiAdapter } from './sub2api.js'; import { OpenAiAdapter } from './openai.js'; +import { CodexAdapter } from './codex.js'; import { ClaudeAdapter } from './claude.js'; import { GeminiAdapter } from './gemini.js'; import { CliProxyApiAdapter } from './cliproxyapi.js'; @@ -15,6 +16,7 @@ import { detectPlatformByTitle } from './titleHint.js'; const adapters: PlatformAdapter[] = [ // Specific forks before generic adapters for better auto-detection. new OpenAiAdapter(), + new CodexAdapter(), new ClaudeAdapter(), new GeminiAdapter(), new CliProxyApiAdapter(), @@ -49,6 +51,9 @@ const platformAliases: Record = { sub2api: 'sub2api', // Official upstream APIs openai: 'openai', + codex: 'codex', + 'chatgpt-codex': 'codex', + 'chatgpt codex': 'codex', anthropic: 'claude', claude: 'claude', gemini: 'gemini', @@ -83,6 +88,7 @@ function detectPlatformByUrlHint(url: string): string | undefined { // Official upstream endpoints. if (normalized.includes('api.openai.com')) return 'openai'; + if (normalized.includes('chatgpt.com/backend-api/codex')) return 'codex'; if (normalized.includes('api.anthropic.com') || normalized.includes('anthropic.com/v1')) return 'claude'; if ( normalized.includes('generativelanguage.googleapis.com') diff --git a/src/server/services/tokenRouter.siteStatus.test.ts b/src/server/services/tokenRouter.siteStatus.test.ts index 3b07506..0784b3b 100644 --- a/src/server/services/tokenRouter.siteStatus.test.ts +++ b/src/server/services/tokenRouter.siteStatus.test.ts @@ -119,4 +119,49 @@ describe('TokenRouter site status guard', () => { const selected = await new TokenRouter().selectChannel('gpt-4.1-mini'); expect(selected).toBeNull(); }); + + it('uses codex oauth access token when no api token is present', async () => { + const site = await db.insert(schema.sites).values({ + name: 'codex-site', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-user@example.com', + accessToken: 'oauth-access-token', + apiToken: null, + status: 'active', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'plus', + }, + }), + }).returning().get(); + + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-5.2-codex', + enabled: true, + }).returning().get(); + + await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: account.id, + tokenId: null, + priority: 0, + weight: 10, + enabled: true, + manualOverride: false, + }).run(); + + const selected = await new TokenRouter().selectChannel('gpt-5.2-codex'); + expect(selected).not.toBeNull(); + expect(selected?.tokenValue).toBe('oauth-access-token'); + }); }); diff --git a/src/server/services/tokenRouter.ts b/src/server/services/tokenRouter.ts index 441da94..564bd2f 100644 --- a/src/server/services/tokenRouter.ts +++ b/src/server/services/tokenRouter.ts @@ -10,6 +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'; interface RouteMatch { route: typeof schema.tokenRoutes.$inferSelect; @@ -1042,7 +1043,14 @@ export class TokenRouter { } const fallback = candidate.account.apiToken?.trim(); - return fallback || null; + if (fallback) return fallback; + + if ((candidate.site?.platform || '').trim().toLowerCase() === 'codex' || isCodexPlatform(candidate.account)) { + const accessToken = candidate.account.accessToken?.trim(); + return accessToken || null; + } + + return null; } private getCandidateEligibilityReasons( diff --git a/src/web/App.sidebar.test.ts b/src/web/App.sidebar.test.ts index 1e8ec9b..70500fa 100644 --- a/src/web/App.sidebar.test.ts +++ b/src/web/App.sidebar.test.ts @@ -21,4 +21,12 @@ describe('App sidebar config', () => { expect(downstreamIndex).toBeGreaterThan(consoleGroupIndex); expect(systemGroupIndex).toBeGreaterThan(downstreamIndex); }); + + it('adds standalone OAuth 管理 navigation entry', () => { + const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8'); + + expect(source).toContain("{ to: '/oauth', label: 'OAuth 管理'"); + expect(source).toContain("const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));"); + expect(source).toContain('} />'); + }); }); diff --git a/src/web/App.tsx b/src/web/App.tsx index af517c6..cc68962 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -34,6 +34,7 @@ const Models = lazy(() => import('./pages/Models.js')); const About = lazy(() => import('./pages/About.js')); const ModelTester = lazy(() => import('./pages/ModelTester.js')); const Monitors = lazy(() => import('./pages/Monitors.js')); +const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js')); type ThemeMode = 'system' | 'light' | 'dark'; @@ -328,6 +329,7 @@ const sidebarGroups = [ { to: '/', label: '仪表盘', icon: }, { to: '/sites', label: '站点', icon: }, { to: '/accounts', label: '连接管理', icon: }, + { to: '/oauth', label: 'OAuth 管理', icon: }, { to: '/downstream-keys', label: '下游密钥', icon: }, { to: '/checkin', label: '签到记录', icon: }, { to: '/routes', label: '路由', icon: }, @@ -763,6 +765,7 @@ function AppShell() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/web/api.ts b/src/web/api.ts index 70a6978..5558145 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -246,6 +246,40 @@ export type ProxyLogsResponse = { summary: ProxyLogsSummary; }; +export type OAuthProviderInfo = { + provider: string; + label: string; + platform: string; + enabled: boolean; + loginType: 'oauth'; +}; + +export type OAuthSessionInfo = { + provider: string; + state: string; + status: 'pending' | 'success' | 'error'; + accountId?: number; + siteId?: number; + error?: string; +}; + +export type OAuthConnectionInfo = { + accountId: number; + siteId: number; + provider: string; + username?: string | null; + email?: string | null; + accountKey?: string | null; + planType?: string | null; + modelCount: number; + modelsPreview: string[]; + status: 'healthy' | 'abnormal'; + routeChannelCount?: number; + lastModelSyncAt?: string | null; + lastModelSyncError?: string | null; + site?: { id: number; name: string; url: string; platform: string } | null; +}; + export const api = { // Sites getSites: () => request('/api/sites'), @@ -357,6 +391,22 @@ export const api = { // Search search: (query: string) => request('/api/search', { method: 'POST', body: JSON.stringify({ query, limit: 20 }) }), + // OAuth + getOAuthProviders: () => request('/api/oauth/providers') as Promise<{ providers: OAuthProviderInfo[] }>, + startOAuthProvider: (provider: string, data?: { accountId?: number }) => 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, + getOAuthConnections: () => request('/api/oauth/connections') as Promise<{ items: OAuthConnectionInfo[] }>, + rebindOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}/rebind`, { + method: 'POST', + body: JSON.stringify({}), + }) as Promise<{ provider: string; state: string; authorizationUrl: string }>, + deleteOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}`, { + method: 'DELETE', + }) as Promise<{ success: true }>, + // Events getEvents: (params?: string) => request(`/api/events${params ? '?' + params : ''}`), getEventCount: () => request('/api/events/count'), diff --git a/src/web/pages/OAuthManagement.test.tsx b/src/web/pages/OAuthManagement.test.tsx new file mode 100644 index 0000000..2274027 --- /dev/null +++ b/src/web/pages/OAuthManagement.test.tsx @@ -0,0 +1,256 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, create } from 'react-test-renderer'; +import { MemoryRouter } from 'react-router-dom'; +import { ToastProvider } from '../components/Toast.js'; +import OAuthManagement from './OAuthManagement.js'; + +const { apiMock, openMock, focusMock, confirmMock } = vi.hoisted(() => ({ + apiMock: { + getOAuthProviders: vi.fn(), + getOAuthConnections: vi.fn(), + startOAuthProvider: vi.fn(), + getOAuthSession: vi.fn(), + rebindOAuthConnection: vi.fn(), + deleteOAuthConnection: vi.fn(), + }, + openMock: vi.fn(), + focusMock: vi.fn(), + confirmMock: vi.fn(), +})); + +vi.mock('../api.js', () => ({ + api: apiMock, +})); + +function collectText(node: any): string { + const children = node?.children || []; + return children.map((child: any) => { + if (typeof child === 'string') return child; + return collectText(child); + }).join(''); +} + +async function flushMicrotasks() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe('OAuthManagement page', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + openMock.mockReturnValue({ focus: focusMock }); + confirmMock.mockReturnValue(true); + vi.stubGlobal('window', { + open: openMock, + confirm: confirmMock, + setTimeout, + clearTimeout, + } as unknown as Window & typeof globalThis); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('renders available oauth providers and existing oauth connections', async () => { + apiMock.getOAuthProviders.mockResolvedValue({ + providers: [ + { provider: 'codex', label: 'Codex', platform: 'codex', enabled: true, loginType: 'oauth' }, + ], + }); + apiMock.getOAuthConnections.mockResolvedValue({ + items: [ + { + accountId: 7, + provider: 'codex', + email: 'codex-user@example.com', + planType: 'plus', + modelCount: 3, + modelsPreview: ['gpt-5', 'gpt-5-mini', 'gpt-5.2-codex'], + status: 'active', + }, + ], + }); + + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + 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 个模型'); + } finally { + root?.unmount(); + } + }); + + 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' }, + ], + }); + apiMock.getOAuthConnections + .mockResolvedValueOnce({ items: [] }) + .mockResolvedValueOnce({ + items: [ + { + accountId: 7, + provider: 'codex', + email: 'codex-user@example.com', + planType: 'plus', + modelCount: 3, + modelsPreview: ['gpt-5', 'gpt-5-mini', 'gpt-5.2-codex'], + status: 'active', + }, + ], + }); + apiMock.startOAuthProvider.mockResolvedValue({ + provider: 'codex', + state: 'oauth-state-123', + authorizationUrl: 'https://auth.openai.com/oauth/authorize?state=oauth-state-123', + }); + apiMock.getOAuthSession + .mockResolvedValueOnce({ + provider: 'codex', + state: 'oauth-state-123', + status: 'pending', + }) + .mockResolvedValueOnce({ + provider: 'codex', + state: 'oauth-state-123', + status: 'success', + accountId: 7, + }); + + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const startButton = root!.root.find((node) => ( + node.type === 'button' + && typeof node.props.onClick === 'function' + && collectText(node).includes('连接 Codex') + )); + + await act(async () => { + await startButton.props.onClick(); + }); + await flushMicrotasks(); + + expect(apiMock.startOAuthProvider).toHaveBeenCalledWith('codex'); + expect(openMock).toHaveBeenCalledWith( + 'https://auth.openai.com/oauth/authorize?state=oauth-state-123', + 'oauth-codex', + expect.stringContaining('width=540'), + ); + + await act(async () => { + vi.advanceTimersByTime(1600); + }); + await flushMicrotasks(); + + await act(async () => { + vi.advanceTimersByTime(1600); + }); + await flushMicrotasks(); + + expect(apiMock.getOAuthSession).toHaveBeenCalledWith('oauth-state-123'); + expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(2); + const text = collectText(root!.root); + expect(text).toContain('授权成功'); + expect(text).toContain('codex-user@example.com'); + } finally { + root?.unmount(); + } + }); + + 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' }, + ], + }); + apiMock.getOAuthConnections + .mockResolvedValueOnce({ + items: [ + { + accountId: 7, + provider: 'codex', + email: 'codex-user@example.com', + planType: 'team', + modelCount: 11, + modelsPreview: ['gpt-5.4', 'gpt-5.3-codex', 'gpt-5.2-codex'], + status: 'abnormal', + routeChannelCount: 1, + lastModelSyncAt: '2026-03-17T08:00:00.000Z', + lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)', + }, + ], + }) + .mockResolvedValueOnce({ items: [] }); + apiMock.deleteOAuthConnection.mockResolvedValue({ success: true }); + + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + 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' + && typeof node.props.onClick === 'function' + && collectText(node).includes('删除连接') + )); + + await act(async () => { + await deleteButton.props.onClick(); + }); + await flushMicrotasks(); + + expect(confirmMock).toHaveBeenCalled(); + expect(apiMock.deleteOAuthConnection).toHaveBeenCalledWith(7); + expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(2); + } finally { + root?.unmount(); + } + }); +}); diff --git a/src/web/pages/OAuthManagement.tsx b/src/web/pages/OAuthManagement.tsx new file mode 100644 index 0000000..6d0ed57 --- /dev/null +++ b/src/web/pages/OAuthManagement.tsx @@ -0,0 +1,239 @@ +import { useEffect, useState } from 'react'; +import { api, type OAuthConnectionInfo, type OAuthProviderInfo } from '../api.js'; + +const POLL_INTERVAL_MS = 1500; + +type ActiveSession = { + provider: string; + state: string; +}; + +function openOAuthPopup(provider: string, authorizationUrl: string) { + if (typeof window === 'undefined' || typeof window.open !== 'function') return; + const popup = window.open( + authorizationUrl, + `oauth-${provider}`, + 'popup=yes,width=540,height=760,resizable=yes,scrollbars=yes', + ); + if (popup && typeof popup.focus === 'function') { + popup.focus(); + } +} + +function resolveConnectionStatusLabel(status?: string): string { + return status === 'abnormal' ? '异常' : '正常'; +} + +export default function OAuthManagement() { + const [providers, setProviders] = useState([]); + const [connections, setConnections] = useState([]); + const [loaded, setLoaded] = useState(false); + const [activeSession, setActiveSession] = useState(null); + const [sessionMessage, setSessionMessage] = useState(''); + const [actionLoadingKey, setActionLoadingKey] = useState(''); + + const loadConnections = async () => { + const response = await api.getOAuthConnections(); + setConnections(Array.isArray(response?.items) ? response.items : []); + }; + + const load = async () => { + try { + const [providersResponse] = await Promise.all([ + api.getOAuthProviders(), + loadConnections(), + ]); + setProviders(Array.isArray(providersResponse?.providers) ? providersResponse.providers : []); + } finally { + setLoaded(true); + } + }; + + useEffect(() => { + load(); + }, []); + + useEffect(() => { + if (!activeSession) return; + + let cancelled = false; + let timer: ReturnType | null = null; + + const poll = async () => { + try { + const session = await api.getOAuthSession(activeSession.state); + if (cancelled) return; + + if (session.status === 'pending') { + setSessionMessage('等待授权完成'); + timer = setTimeout(poll, POLL_INTERVAL_MS); + return; + } + + if (session.status === 'success') { + setSessionMessage('授权成功'); + await loadConnections(); + setActiveSession(null); + return; + } + + setSessionMessage(`授权失败:${session.error || '未知错误'}`); + setActiveSession(null); + } catch (error: any) { + if (cancelled) return; + setSessionMessage(error?.message || 'OAuth 会话状态查询失败'); + setActiveSession(null); + } + }; + + poll(); + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; + }, [activeSession]); + + const handleStart = async (provider: string, accountId?: number) => { + const actionKey = `start:${provider}:${accountId || 0}`; + setActionLoadingKey(actionKey); + try { + const started = accountId + ? await api.rebindOAuthConnection(accountId) + : await api.startOAuthProvider(provider); + setSessionMessage('等待授权完成'); + setActiveSession({ + provider: started.provider, + state: started.state, + }); + openOAuthPopup(provider, started.authorizationUrl); + } catch (error: any) { + setSessionMessage(error?.message || '无法启动 OAuth 授权'); + } finally { + setActionLoadingKey(''); + } + }; + + const handleDelete = async (accountId: number) => { + if (typeof window !== 'undefined' && typeof window.confirm === 'function') { + const confirmed = window.confirm('确定要删除这个 OAuth 连接吗?'); + if (!confirmed) return; + } + const actionKey = `delete:${accountId}`; + setActionLoadingKey(actionKey); + try { + await api.deleteOAuthConnection(accountId); + setSessionMessage('连接已删除'); + await loadConnections(); + } catch (error: any) { + setSessionMessage(error?.message || '删除 OAuth 连接失败'); + } finally { + setActionLoadingKey(''); + } + }; + + return ( +
+
+
+
OAuth 管理
+
统一管理需要浏览器授权的上游连接。首版支持 Codex。
+
+
+ + {sessionMessage && ( +
+
{sessionMessage}
+
+ )} + +
+
授权入口
+
+ {providers.map((provider) => { + const actionKey = `start:${provider.provider}:0`; + return ( +
+
+
{provider.label}
+
{provider.platform}
+
+ +
+ ); + })} + {loaded && providers.length === 0 && ( +
当前没有可用的 OAuth Provider。
+ )} +
+
+ +
+
已连接账号
+
+ {connections.map((connection) => { + const rebindActionKey = `start:${connection.provider}:${connection.accountId}`; + const deleteActionKey = `delete:${connection.accountId}`; + return ( +
+
+
+
{connection.email || connection.username || connection.provider}
+
+ {connection.planType || 'unknown'} · {connection.modelCount} 个模型 +
+
+ {resolveConnectionStatusLabel(connection.status)} · {connection.routeChannelCount || 0} 条路由 +
+ {connection.lastModelSyncAt && ( +
+ 最近同步: {connection.lastModelSyncAt} +
+ )} + {connection.lastModelSyncError && ( +
+ {connection.lastModelSyncError} +
+ )} + {connection.modelsPreview.length > 0 && ( +
+ {connection.modelsPreview.join(', ')} +
+ )} +
+
+ + +
+
+
+ ); + })} + {loaded && connections.length === 0 && ( +
还没有 OAuth 连接。
+ )} +
+
+
+ ); +} diff --git a/src/web/pages/Sites.tsx b/src/web/pages/Sites.tsx index b586a2a..03820dc 100644 --- a/src/web/pages/Sites.tsx +++ b/src/web/pages/Sites.tsx @@ -52,6 +52,7 @@ const platformColors: Record = { 'done-hub': 'badge-muted', sub2api: 'badge-muted', openai: 'badge-success', + codex: 'badge-success', claude: 'badge-warning', gemini: 'badge-info', cliproxyapi: 'badge-info', @@ -67,6 +68,7 @@ const SITE_PLATFORM_OPTIONS = [ { value: 'done-hub', label: 'done-hub' }, { value: 'sub2api', label: 'sub2api' }, { value: 'openai', label: 'openai' }, + { value: 'codex', label: 'codex' }, { value: 'claude', label: 'claude' }, { value: 'gemini', label: 'gemini' }, { value: 'cliproxyapi', label: 'cliproxyapi' }, @@ -290,11 +292,16 @@ export default function Sites() { const createdPlatform = typeof created?.platform === 'string' && created.platform.trim() ? created.platform.trim() : payload.platform; - const initialSegment = resolveInitialConnectionSegment(createdPlatform); const params = new URLSearchParams({ create: '1', siteId: String(createdSiteId), }); + if (String(createdPlatform || '').trim().toLowerCase() === 'codex') { + params.set('provider', 'codex'); + navigate(`/oauth?${params.toString()}`); + return; + } + const initialSegment = resolveInitialConnectionSegment(createdPlatform); if (initialSegment === 'apikey') { params.set('segment', 'apikey'); } diff --git a/src/web/pages/sites.create-redirect.test.tsx b/src/web/pages/sites.create-redirect.test.tsx index ae3d8c8..a8f488d 100644 --- a/src/web/pages/sites.create-redirect.test.tsx +++ b/src/web/pages/sites.create-redirect.test.tsx @@ -50,6 +50,7 @@ async function createSiteAndCollectLocation(createdSite: { id: number; platform? } /> } /> + } /> , @@ -124,4 +125,13 @@ describe('Sites create redirect', () => { expect(rendered).toContain('create=1'); expect(rendered).toContain('siteId=22'); }); + + it('redirects a codex site to oauth management instead of legacy account import', async () => { + const rendered = await createSiteAndCollectLocation({ id: 23, platform: 'codex' }); + + expect(rendered).toContain('/oauth?'); + expect(rendered).toContain('provider=codex'); + expect(rendered).toContain('create=1'); + expect(rendered).toContain('siteId=23'); + }); }); diff --git a/src/web/pages/token-routes/RouteCard.tsx b/src/web/pages/token-routes/RouteCard.tsx index a20609a..663f6d3 100644 --- a/src/web/pages/token-routes/RouteCard.tsx +++ b/src/web/pages/token-routes/RouteCard.tsx @@ -473,7 +473,7 @@ function RouteCardInner({ ) : (
- {readOnlyRoute ? tr('暂无通道,先补齐令牌后再重建路由。') : tr('暂无通道')} + {readOnlyRoute ? tr('暂无通道,先补齐连接配置后再重建路由。') : tr('暂无通道')}
)} diff --git a/src/web/pages/tokenRoutes.group-collapse.test.tsx b/src/web/pages/tokenRoutes.group-collapse.test.tsx index 8e0c919..d14c55b 100644 --- a/src/web/pages/tokenRoutes.group-collapse.test.tsx +++ b/src/web/pages/tokenRoutes.group-collapse.test.tsx @@ -346,7 +346,7 @@ describe('TokenRoutes grouped source models', () => { const expandedText = collectText(root.root); expect(expandedText).toContain('待注册站点'); expect(expandedText).toContain('Wong'); - expect(expandedText).toContain('暂无通道,先补齐令牌后再重建路由。'); + expect(expandedText).toContain('暂无通道,先补齐连接配置后再重建路由。'); expect(expandedText).not.toContain('添加通道'); expect(expandedText).not.toContain('删除路由'); expect(expandedText).not.toContain('编辑群组');