From f0f5e8431dfcc4125bcd153445c82bd4d90ab51a Mon Sep 17 00:00:00 2001 From: hanxuanliang Date: Thu, 2 Apr 2026 00:45:17 +0800 Subject: [PATCH] Add Codex provider config bridge --- package.json | 3 +- src/services/api/client.ts | 27 +- src/services/api/codex-fetch-adapter.ts | 64 ++++- .../api/codex-provider-bridge.test.ts | 138 ++++++++++ src/services/api/codex-provider-bridge.ts | 247 ++++++++++++++++++ src/utils/auth.ts | 9 +- 6 files changed, 463 insertions(+), 25 deletions(-) create mode 100644 src/services/api/codex-provider-bridge.test.ts create mode 100644 src/services/api/codex-provider-bridge.ts diff --git a/package.json b/package.json index 5e89b5ae..eb4aa579 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "build:dev": "bun run ./scripts/build.ts --dev", "build:dev:full": "bun run ./scripts/build.ts --dev --feature-set=dev-full", "compile": "bun run ./scripts/build.ts --compile", - "dev": "bun run ./src/entrypoints/cli.tsx" + "dev": "bun run ./src/entrypoints/cli.tsx", + "test": "bun test" }, "dependencies": { "@alcalzone/ansi-tokenize": "^0.3.0", diff --git a/src/services/api/client.ts b/src/services/api/client.ts index bf4b262b..e1376933 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -13,7 +13,6 @@ import { getClaudeAIOAuthTokens, getCodexOAuthTokens, isClaudeAISubscriber, - isCodexSubscriber, refreshAndGetAwsCredentials, refreshGcpCredentialsIfNeeded, } from 'src/utils/auth.js' @@ -35,7 +34,13 @@ import { getVertexRegionForModel, isEnvTruthy, } from '../../utils/envUtils.js' -import { createCodexFetch } from './codex-fetch-adapter.js' +import { + resolveCodexProviderBridge, +} from './codex-provider-bridge.js' +import { + createCodexFetch, + createResponsesFetch, +} from './codex-fetch-adapter.js' /** * Environment variables for different client types: @@ -305,15 +310,21 @@ export async function getAnthropicClient({ return new AnthropicVertex(vertexArgs) as unknown as Anthropic } - // ── Codex (OpenAI) provider via fetch adapter ───────────────────── - if (isCodexSubscriber()) { + if (getAPIProvider() === 'openai') { const codexTokens = getCodexOAuthTokens() - if (codexTokens?.accessToken) { - const codexFetch = createCodexFetch(codexTokens.accessToken) + const bridge = resolveCodexProviderBridge({ + codexOAuthAccessToken: codexTokens?.accessToken ?? null, + }) + + if (bridge) { + const bridgeFetch = + bridge.kind === 'chatgpt' + ? createCodexFetch(bridge.accessToken) + : createResponsesFetch(bridge) const clientConfig: ConstructorParameters[0] = { - apiKey: 'codex-placeholder', // SDK requires a key but the fetch adapter handles auth + apiKey: 'codex-placeholder', ...ARGS, - fetch: codexFetch as unknown as typeof globalThis.fetch, + fetch: bridgeFetch as unknown as typeof globalThis.fetch, ...(isDebugToStdErr() && { logger: createStderrLogger() }), } return new Anthropic(clientConfig) diff --git a/src/services/api/codex-fetch-adapter.ts b/src/services/api/codex-fetch-adapter.ts index d883f2b6..2fdf296a 100644 --- a/src/services/api/codex-fetch-adapter.ts +++ b/src/services/api/codex-fetch-adapter.ts @@ -16,6 +16,7 @@ */ import { getCodexOAuthTokens } from '../../utils/auth.js' +import type { CodexResponsesBridgeConfig } from './codex-provider-bridge.js' // ── Available Codex models ────────────────────────────────────────── export const CODEX_MODELS = [ @@ -738,15 +739,28 @@ async function translateCodexStreamToAnthropic( const CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex/responses' +type ResponsesBridgeFetchOptions = { + accessToken: string + endpoint: string + extraHeaders?: Record + getAccessToken?: () => string +} + /** * Creates a fetch function that intercepts Anthropic API calls and routes them to Codex. * @param accessToken - The Codex access token for authentication * @returns A fetch function that translates Anthropic requests to Codex format */ -export function createCodexFetch( - accessToken: string, -): (input: RequestInfo | URL, init?: RequestInit) => Promise { - const accountId = extractAccountId(accessToken) +function createResponsesBridgeFetch({ + accessToken, + endpoint, + extraHeaders, + getAccessToken, +}: ResponsesBridgeFetchOptions): ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise { + const resolveAccessToken = getAccessToken ?? (() => accessToken) return async (input: RequestInfo | URL, init?: RequestInit): Promise => { const url = input instanceof Request ? input.url : String(input) @@ -770,23 +784,17 @@ export function createCodexFetch( anthropicBody = {} } - // Get current token (may have been refreshed) - const tokens = getCodexOAuthTokens() - const currentToken = tokens?.accessToken || accessToken - // Translate to Codex format const { codexBody, codexModel } = translateToCodexBody(anthropicBody) // Call Codex API - const codexResponse = await globalThis.fetch(CODEX_BASE_URL, { + const codexResponse = await globalThis.fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream', - Authorization: `Bearer ${currentToken}`, - 'chatgpt-account-id': accountId, - originator: 'pi', - 'OpenAI-Beta': 'responses=experimental', + Authorization: `Bearer ${resolveAccessToken()}`, + ...(extraHeaders ?? {}), }, body: JSON.stringify(codexBody), }) @@ -810,3 +818,33 @@ export function createCodexFetch( return translateCodexStreamToAnthropic(codexResponse, codexModel) } } + +export function createCodexFetch( + accessToken: string, +): (input: RequestInfo | URL, init?: RequestInit) => Promise { + const accountId = extractAccountId(accessToken) + + return createResponsesBridgeFetch({ + accessToken, + endpoint: CODEX_BASE_URL, + getAccessToken: () => { + const tokens = getCodexOAuthTokens() + return tokens?.accessToken || accessToken + }, + extraHeaders: { + 'chatgpt-account-id': accountId, + originator: 'pi', + 'OpenAI-Beta': 'responses=experimental', + }, + }) +} + +export function createResponsesFetch( + config: CodexResponsesBridgeConfig, +): (input: RequestInfo | URL, init?: RequestInit) => Promise { + return createResponsesBridgeFetch({ + accessToken: config.apiKey, + endpoint: config.endpoint, + extraHeaders: config.headers, + }) +} diff --git a/src/services/api/codex-provider-bridge.test.ts b/src/services/api/codex-provider-bridge.test.ts new file mode 100644 index 00000000..dedd55ec --- /dev/null +++ b/src/services/api/codex-provider-bridge.test.ts @@ -0,0 +1,138 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { resolveCodexProviderBridge } from './codex-provider-bridge.js' + +const tempDirs: string[] = [] + +function createCodexHome(): string { + const dir = mkdtempSync(join(tmpdir(), 'free-code-codex-')) + tempDirs.push(dir) + return dir +} + +afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop()!, { force: true, recursive: true }) + } +}) + +describe('resolveCodexProviderBridge', () => { + it('resolves a custom Responses provider from config.toml', () => { + const codexHomeDir = createCodexHome() + writeFileSync( + join(codexHomeDir, 'config.toml'), + ` +model_provider = "openai-custom" + +[model_providers.openai-custom] +name = "OpenAI Custom" +base_url = "https://example.com/v1" +env_key = "OPENAI_CUSTOM_API_KEY" +wire_api = "responses" + +[model_providers.openai-custom.http_headers] +x-static = "static-value" + +[model_providers.openai-custom.env_http_headers] +x-workspace = "OPENAI_WORKSPACE_ID" + +[model_providers.openai-custom.query_params] +api-version = "2025-04-01" +`, + ) + + const bridge = resolveCodexProviderBridge({ + codexHomeDir, + env: { + OPENAI_CUSTOM_API_KEY: 'sk-custom', + OPENAI_WORKSPACE_ID: 'ws_123', + }, + }) + + expect(bridge).toEqual({ + kind: 'responses', + providerId: 'openai-custom', + providerName: 'OpenAI Custom', + endpoint: 'https://example.com/v1/responses?api-version=2025-04-01', + apiKey: 'sk-custom', + headers: { + 'x-static': 'static-value', + 'x-workspace': 'ws_123', + }, + }) + }) + + it('uses auth.json OPENAI_API_KEY for the default openai provider', () => { + const codexHomeDir = createCodexHome() + writeFileSync( + join(codexHomeDir, 'auth.json'), + JSON.stringify({ + OPENAI_API_KEY: 'sk-from-auth-file', + }), + ) + + const bridge = resolveCodexProviderBridge({ + codexHomeDir, + env: {}, + }) + + expect(bridge).toEqual({ + kind: 'responses', + providerId: 'openai', + providerName: 'OpenAI', + endpoint: 'https://api.openai.com/v1/responses', + apiKey: 'sk-from-auth-file', + headers: {}, + }) + }) + + it('falls back to ChatGPT auth.json tokens when no API key is available', () => { + const codexHomeDir = createCodexHome() + writeFileSync( + join(codexHomeDir, 'auth.json'), + JSON.stringify({ + tokens: { + access_token: 'chatgpt-token', + }, + }), + ) + + const bridge = resolveCodexProviderBridge({ + codexHomeDir, + env: {}, + }) + + expect(bridge).toEqual({ + kind: 'chatgpt', + providerId: 'openai', + accessToken: 'chatgpt-token', + }) + }) + + it('rejects providers that do not speak the Responses API', () => { + const codexHomeDir = createCodexHome() + writeFileSync( + join(codexHomeDir, 'config.toml'), + ` +model_provider = "legacy" + +[model_providers.legacy] +name = "Legacy" +base_url = "https://example.com/v1" +env_key = "OPENAI_API_KEY" +wire_api = "chat" +`, + ) + + expect(() => + resolveCodexProviderBridge({ + codexHomeDir, + env: { + OPENAI_API_KEY: 'sk-test', + }, + }), + ).toThrow('wire_api = "responses"') + }) +}) diff --git a/src/services/api/codex-provider-bridge.ts b/src/services/api/codex-provider-bridge.ts new file mode 100644 index 00000000..835cc834 --- /dev/null +++ b/src/services/api/codex-provider-bridge.ts @@ -0,0 +1,247 @@ +import { readFileSync } from 'fs' +import { homedir } from 'os' +import { join } from 'path' + +const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1' +const DEFAULT_OPENAI_PROVIDER_ID = 'openai' + +type CodexWireApi = 'responses' + +type CodexProviderDefinition = { + name?: string + base_url?: string + env_key?: string + experimental_bearer_token?: string + wire_api?: CodexWireApi | string + query_params?: Record + http_headers?: Record + env_http_headers?: Record + requires_openai_auth?: boolean +} + +type CodexConfigToml = { + model_provider?: string + openai_base_url?: string + model_providers?: Record +} + +type CodexAuthFile = { + OPENAI_API_KEY?: string + tokens?: { + access_token?: string + } +} + +export type CodexResponsesBridgeConfig = { + kind: 'responses' + providerId: string + providerName: string + endpoint: string + apiKey: string + headers: Record +} + +export type CodexChatGptBridgeConfig = { + kind: 'chatgpt' + providerId: string + accessToken: string +} + +export type CodexProviderBridgeConfig = + | CodexResponsesBridgeConfig + | CodexChatGptBridgeConfig + +type ResolveCodexProviderBridgeOptions = { + codexHomeDir?: string + codexOAuthAccessToken?: null | string + env?: NodeJS.ProcessEnv +} + +function getCodexHomeDir(env: NodeJS.ProcessEnv): string { + return (env.CODEX_HOME ?? join(homedir(), '.codex')).normalize('NFC') +} + +function getNonEmptyString(value: unknown): null | string { + return typeof value === 'string' && value.trim().length > 0 ? value : null +} + +function readCodexConfig(codexHomeDir: string): CodexConfigToml { + try { + const raw = readFileSync(join(codexHomeDir, 'config.toml'), 'utf8') + return (Bun.TOML.parse(raw) as CodexConfigToml) ?? {} + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return {} + } + throw error + } +} + +function readCodexAuthFile(codexHomeDir: string): CodexAuthFile { + try { + return JSON.parse( + readFileSync(join(codexHomeDir, 'auth.json'), 'utf8'), + ) as CodexAuthFile + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return {} + } + throw error + } +} + +function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/+$/, '') +} + +function buildResponsesEndpoint( + baseUrl: string, + queryParams?: Record, +): string { + const url = new URL(`${normalizeBaseUrl(baseUrl)}/responses`) + for (const [key, value] of Object.entries(queryParams ?? {})) { + if (value.trim()) { + url.searchParams.set(key, value) + } + } + return url.toString() +} + +function resolveProviderHeaders( + provider: CodexProviderDefinition, + env: NodeJS.ProcessEnv, +): Record { + const headers: Record = { + ...(provider.http_headers ?? {}), + } + + for (const [headerName, envKey] of Object.entries(provider.env_http_headers ?? {})) { + const envValue = getNonEmptyString(env[envKey]) + if (envValue) { + headers[headerName] = envValue + } + } + + return headers +} + +function createBuiltInProviders( + openaiBaseUrl: null | string, +): Record { + return { + [DEFAULT_OPENAI_PROVIDER_ID]: { + name: 'OpenAI', + ...(openaiBaseUrl ? { base_url: openaiBaseUrl } : {}), + wire_api: 'responses', + requires_openai_auth: true, + }, + } +} + +function resolveApiKey( + providerId: string, + provider: CodexProviderDefinition, + env: NodeJS.ProcessEnv, + authFile: CodexAuthFile, +): null | string { + const explicitBearerToken = getNonEmptyString(provider.experimental_bearer_token) + if (explicitBearerToken) { + return explicitBearerToken + } + + const providerEnvKey = getNonEmptyString(provider.env_key) + if (providerEnvKey) { + const envApiKey = getNonEmptyString(env[providerEnvKey]) + if (envApiKey) { + return envApiKey + } + if (providerEnvKey === 'OPENAI_API_KEY') { + return getNonEmptyString(authFile.OPENAI_API_KEY) + } + return null + } + + if (providerId === DEFAULT_OPENAI_PROVIDER_ID || provider.requires_openai_auth) { + return ( + getNonEmptyString(env.OPENAI_API_KEY) ?? + getNonEmptyString(env.CODEX_API_KEY) ?? + getNonEmptyString(authFile.OPENAI_API_KEY) + ) + } + + return null +} + +export function resolveCodexProviderBridge( + options: ResolveCodexProviderBridgeOptions = {}, +): CodexProviderBridgeConfig | null { + const env = options.env ?? process.env + const codexHomeDir = options.codexHomeDir ?? getCodexHomeDir(env) + const config = readCodexConfig(codexHomeDir) + const authFile = readCodexAuthFile(codexHomeDir) + const openaiBaseUrl = + getNonEmptyString(config.openai_base_url) ?? + getNonEmptyString(env.OPENAI_BASE_URL) + const providerId = + getNonEmptyString(env.CODEX_MODEL_PROVIDER) ?? + getNonEmptyString(config.model_provider) ?? + DEFAULT_OPENAI_PROVIDER_ID + const providers = { + ...createBuiltInProviders(openaiBaseUrl), + ...(config.model_providers ?? {}), + } + const provider = providers[providerId] + + if (!provider) { + throw new Error(`Codex model provider "${providerId}" was not found`) + } + + const wireApi = provider.wire_api ?? 'responses' + if (wireApi !== 'responses') { + throw new Error( + `Codex model provider "${providerId}" must use wire_api = "responses"`, + ) + } + + const apiKey = resolveApiKey(providerId, provider, env, authFile) + if (apiKey) { + const baseUrl = normalizeBaseUrl( + provider.base_url ?? openaiBaseUrl ?? DEFAULT_OPENAI_BASE_URL, + ) + return { + kind: 'responses', + providerId, + providerName: provider.name ?? providerId, + endpoint: buildResponsesEndpoint(baseUrl, provider.query_params), + apiKey, + headers: resolveProviderHeaders(provider, env), + } + } + + const canUseChatGptBackend = + providerId === DEFAULT_OPENAI_PROVIDER_ID && !openaiBaseUrl && !provider.base_url + if (canUseChatGptBackend) { + const accessToken = + getNonEmptyString(options.codexOAuthAccessToken) ?? + getNonEmptyString(authFile.tokens?.access_token) + if (accessToken) { + return { + kind: 'chatgpt', + providerId, + accessToken, + } + } + } + + return null +} + +export function tryResolveCodexProviderBridge( + options: ResolveCodexProviderBridgeOptions = {}, +): CodexProviderBridgeConfig | null { + try { + return resolveCodexProviderBridge(options) + } catch { + return null + } +} diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 50f7ac65..5f68b29b 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -60,6 +60,7 @@ import { execSyncWithDefaults_DEPRECATED } from './execFileNoThrow.js' import * as lockfile from './lockfile.js' import { logError } from './log.js' import { memoizeWithTTLAsync } from './memoize.js' +import { tryResolveCodexProviderBridge } from '../services/api/codex-provider-bridge.js' import { getSecureStorage } from './secureStorage/index.js' import { clearLegacyApiKeyPrefetch, @@ -1632,9 +1633,11 @@ export function isCodexSubscriber(): boolean { return false } - // Verify we actually have valid Codex tokens - const tokens = getCodexOAuthTokens() - return !!tokens?.accessToken + return ( + tryResolveCodexProviderBridge({ + codexOAuthAccessToken: getCodexOAuthTokens()?.accessToken ?? null, + }) !== null + ) } /**