From de81e159a41e2a5efdac0588552461f08a4a3599 Mon Sep 17 00:00:00 2001 From: Nur Ad-Duja Date: Mon, 27 Jul 2026 17:05:24 +0700 Subject: [PATCH] fix(codex): route native auth correctly The local base URL override sent ChatGPT tokens to the Platform API. Select the native upstream from the active login method and disable unsupported WebSocket transport on the process-scoped router provider. --- README.md | 3 ++ package.json | 2 +- src/cli.js | 8 +++- src/runtime/codex.js | 28 +++++++++++- test/v2-runtime-supervisor.test.js | 73 +++++++++++++++++++++++++++++- 5 files changed, 107 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 948ae1b..3571ea3 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,9 @@ Every role can stay `native` or select a fixed `kenari/`. Codex main also supports `picker`. Codex review and subagents also support `inherit`. Native is always the default. +For Codex, Kenari detects whether `codex login` uses ChatGPT or an API key and +keeps native requests on the matching OpenAI upstream. + Automation must set every role: ```bash diff --git a/package.json b/package.json index d4a9ad9..bcb41da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kenarihq/cli", - "version": "0.3.0", + "version": "0.3.1", "description": "Official kenari CLI. Route Claude Code and Codex models per process.", "license": "MIT", "type": "module", diff --git a/src/cli.js b/src/cli.js index a123d2a..30f72b2 100644 --- a/src/cli.js +++ b/src/cli.js @@ -32,7 +32,11 @@ import { gatewayBase, modelCachePath, runtimeDir } from './paths.js'; import { genPkce, genState, buildLoopbackUrl, buildPasteUrl, startCallbackServer } from './oauth.js'; import { resolveBinary, runWrappedTool } from './supervisor.js'; import { buildClaudeLaunch } from './runtime/claude.js'; -import { buildCodexLaunch, loadCodexNativeModels } from './runtime/codex.js'; +import { + buildCodexLaunch, + loadCodexNativeModels, + resolveCodexNativeBase, +} from './runtime/codex.js'; import { startRouter } from './router.js'; import { detectOrphanedV1Signatures, detectV1State, migrateV1 } from './migrate.js'; @@ -497,7 +501,7 @@ async function runTool(tool, args) { } const kenariBase = tool === 'codex' ? `${gatewayBase()}/v1` : gatewayBase(); const nativeBase = tool === 'codex' - ? (process.env.KENARI_CODEX_NATIVE_BASE_URL || 'https://api.openai.com/v1') + ? resolveCodexNativeBase(binary, process.env) : (process.env.KENARI_CLAUDE_NATIVE_BASE_URL || 'https://api.anthropic.com'); const runtimeBuilder = tool === 'codex' ? buildCodexLaunch : buildClaudeLaunch; try { diff --git a/src/runtime/codex.js b/src/runtime/codex.js index b6aa97e..a30ade1 100644 --- a/src/runtime/codex.js +++ b/src/runtime/codex.js @@ -1,10 +1,29 @@ import { spawnSync } from 'node:child_process'; import { KenariError } from '../store.js'; +export const CODEX_CHATGPT_BASE_URL = 'https://chatgpt.com/backend-api/codex'; +export const CODEX_API_BASE_URL = 'https://api.openai.com/v1'; +const CODEX_ROUTER_PROVIDER = 'kenari_router'; + function tomlString(value) { return JSON.stringify(value); } +export function resolveCodexNativeBase(binary, env = process.env, run = spawnSync) { + if (env.KENARI_CODEX_NATIVE_BASE_URL) return env.KENARI_CODEX_NATIVE_BASE_URL; + const result = run(binary, ['login', 'status'], { + encoding: 'utf8', + env, + timeout: 5000, + windowsHide: true, + }); + const output = `${result.stdout || ''}\n${result.stderr || ''}`; + if (/logged in using chatgpt/i.test(output)) return CODEX_CHATGPT_BASE_URL; + if (/logged in using (?:an )?api key/i.test(output)) return CODEX_API_BASE_URL; + if (env.OPENAI_API_KEY?.trim()) return CODEX_API_BASE_URL; + throw new KenariError('cannot determine Codex login method. Run: codex login'); +} + export function loadCodexNativeModels(binary, env = process.env) { for (const args of [['debug', 'models'], ['debug', 'models', '--bundled']]) { const result = spawnSync(binary, args, { @@ -73,8 +92,13 @@ export function buildCodexLaunch(options) { if (inline === null) index += 1; } const overrides = [ - 'model_provider="openai"', - `openai_base_url=${tomlString(options.routerUrl)}`, + `model_provider="${CODEX_ROUTER_PROVIDER}"`, + `model_providers.${CODEX_ROUTER_PROVIDER}.name="OpenAI"`, + `model_providers.${CODEX_ROUTER_PROVIDER}.base_url=${tomlString(options.routerUrl)}`, + `model_providers.${CODEX_ROUTER_PROVIDER}.wire_api="responses"`, + `model_providers.${CODEX_ROUTER_PROVIDER}.requires_openai_auth=true`, + `model_providers.${CODEX_ROUTER_PROVIDER}.supports_websockets=false`, + `model_providers.${CODEX_ROUTER_PROVIDER}.supports_standalone_web_search=true`, ]; if (options.catalogPath) { overrides.push(`model_catalog_json=${tomlString(options.catalogPath)}`); diff --git a/test/v2-runtime-supervisor.test.js b/test/v2-runtime-supervisor.test.js index a9fe807..62837ef 100644 --- a/test/v2-runtime-supervisor.test.js +++ b/test/v2-runtime-supervisor.test.js @@ -5,7 +5,13 @@ import os from 'node:os'; import path from 'node:path'; import http from 'node:http'; import { buildClaudeLaunch, findClaudeEnvConflicts } from '../src/runtime/claude.js'; -import { buildCodexLaunch, codexKenariModels } from '../src/runtime/codex.js'; +import { + CODEX_API_BASE_URL, + CODEX_CHATGPT_BASE_URL, + buildCodexLaunch, + codexKenariModels, + resolveCodexNativeBase, +} from '../src/runtime/codex.js'; import { resolveBinary, runWrappedTool } from '../src/supervisor.js'; const originalAllowHttp = process.env.KENARI_ALLOW_HTTP; @@ -121,7 +127,12 @@ test('Codex launch injects temporary controls before original args', () => { }); assert.deepEqual(built.args.slice(-4), ['exec', '-c', 'review_model="native-review"', 'hello']); assert.ok(built.args.indexOf('review_model="kenari/reviewer"') < built.args.indexOf('exec')); - assert.ok(built.args.includes('openai_base_url="http://127.0.0.1:2"')); + assert.ok(built.args.includes('model_provider="kenari_router"')); + assert.ok(built.args.includes( + 'model_providers.kenari_router.base_url="http://127.0.0.1:2"', + )); + assert.ok(built.args.includes('model_providers.kenari_router.requires_openai_auth=true')); + assert.ok(built.args.includes('model_providers.kenari_router.supports_websockets=false')); assert.equal(built.env.OPENAI_API_KEY, 'secret'); assert.equal(built.env.KEEP, 'yes'); const native = [{ @@ -140,6 +151,64 @@ test('Codex launch injects temporary controls before original args', () => { }), /unsafe Codex routing override/); }); +test('Codex native upstream follows the active login method', () => { + const cases = [ + { + name: 'ChatGPT stdout', + result: { status: 0, stdout: 'Logged in using ChatGPT\n', stderr: '' }, + expected: CODEX_CHATGPT_BASE_URL, + }, + { + name: 'ChatGPT after warning', + result: { + status: 0, + stdout: 'Logged in using ChatGPT\n', + stderr: 'WARNING: could not create PATH aliases\n', + }, + expected: CODEX_CHATGPT_BASE_URL, + }, + { + name: 'API key', + result: { status: 0, stdout: 'Logged in using an API key\n', stderr: '' }, + expected: CODEX_API_BASE_URL, + }, + ]; + for (const entry of cases) { + const calls = []; + const base = resolveCodexNativeBase('/bin/codex', {}, (...args) => { + calls.push(args); + return entry.result; + }); + assert.equal(base, entry.expected, entry.name); + assert.deepEqual(calls[0].slice(0, 2), ['/bin/codex', ['login', 'status']], entry.name); + } +}); + +test('Codex native upstream override and API key fallback are deterministic', () => { + let calls = 0; + const override = resolveCodexNativeBase('/bin/codex', { + KENARI_CODEX_NATIVE_BASE_URL: 'https://proxy.example/v1', + }, () => { + calls += 1; + return {}; + }); + assert.equal(override, 'https://proxy.example/v1'); + assert.equal(calls, 0); + + const fallback = resolveCodexNativeBase('/bin/codex', { + OPENAI_API_KEY: 'sk-test', + }, () => ({ status: 1, stdout: '', stderr: 'Not logged in' })); + assert.equal(fallback, CODEX_API_BASE_URL); +}); + +test('Codex native upstream fails closed when login method is unknown', () => { + assert.throws(() => resolveCodexNativeBase( + '/bin/codex', + {}, + () => ({ status: 1, stdout: '', stderr: 'Not logged in' }), + ), /cannot determine Codex login method/); +}); + test('resolveBinary skips excluded wrapper and supervisor returns child exit code', async (t) => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kenari-bin-')); t.after(() => fs.rmSync(dir, { recursive: true, force: true }));