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 }));