Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ Every role can stay `native` or select a fixed `kenari/<model-id>`. 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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
8 changes: 6 additions & 2 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 {
Expand Down
28 changes: 26 additions & 2 deletions src/runtime/codex.js
Original file line number Diff line number Diff line change
@@ -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, {
Expand Down Expand Up @@ -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)}`);
Expand Down
73 changes: 71 additions & 2 deletions test/v2-runtime-supervisor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = [{
Expand All @@ -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 }));
Expand Down
Loading