feat(phase-1): land OpenAI Codex provider plugin (D6)

Phase 1 Day 4. OpenAI Codex provider plugin code lands as Candidate.
Anthropic and Codex now both in STATIC_REGISTRY length 2. Codex CLI is
NOT installed on the orchestrator machine, so D6 ships with a docs-only
authority pin; D7 will install the binary, probe real behaviour, and
fix any docs-vs-reality divergences (A3 access-token field, A4 NDJSON
event schema, possibly keyring storage support).

Files:
  NEW:  lib/providers/codex.mjs (586 lines initially, expanded ~10 lines
        via reviewer fold-ins) — Codex provider implementing the v1.0
        contract. spawns `codex exec --json --model <id> [PROMPT|-]` per
        canonical Codex docs.
  MOD:  lib/providers/index.mjs — STATIC_REGISTRY now [anthropic, codex],
        listAllProviderNames() returns 2 entries.
  MOD:  models-registry.json — providers.openai populated with five
        documented model IDs (gpt-5.5, gpt-5.4, gpt-5.4-mini,
        gpt-5.3-codex, gpt-5.3-codex-spark) and four aliases.
  MOD:  test-features.mjs — Suite 11 added with 46 tests covering contract
        conformance, IR translation, mock-spawn behaviour, healthCheck,
        estimateCost, registry length.

Authority citations (all WebFetched and verified during reviewer pass):
  CLI reference: https://developers.openai.com/codex/cli/reference
    Source for `codex exec` subcommand syntax, --json flag, --model -m
    flag, and PROMPT positional including the `-` form for stdin piping.
  Features:      https://developers.openai.com/codex/cli/features
    Reference for the supported-models list.
  Auth:          https://developers.openai.com/codex/auth/
    Canonical pin for `~/.codex/auth.json` plaintext credential file
    and `cli_auth_credentials_store = keyring` OS credential store option.
  Models:        https://developers.openai.com/codex/models
    Canonical pin for the five documented model IDs (each shown as a
    `codex -m <id>` example on the page).
  ChatGPT plan:  https://help.openai.com/en/articles/11369540 — Codex
    runs against ChatGPT subscription budget when OAuth-authenticated;
    OPENAI_API_KEY env path is for `codex login --with-api-key` only,
    not `codex exec` runtime.

Architectural decisions:
  1. Mirror D4 anthropic.mjs structure: file header, lossy translation
     docs, default export = provider object, named exports include
     __setSpawnImpl / __resetSpawnImpl for test injection.
  2. Stdin path uses `args.push('-')` per documented CLI behaviour.
     (Original D6 sonnet draft omitted the positional entirely and wrote
     stdin directly — D6 reviewer pass 2 caught this; corrected before
     commit. D7 E2E confirms.)
  3. Auth artifact path `~/.codex/auth.json` is now documented in the
     header as CONFIRMED per canonical auth doc, not assumed.
  4. Access-token field name remains a defensive 3-name try-order
     (access_token / token / accessToken) because the auth doc does
     not enumerate field names. D7 captures real auth.json post-login.
  5. OPENAI_API_KEY env injection during spawn is intentionally NOT
     done. The auth doc clarifies OPENAI_API_KEY is a login-time input,
     not a runtime override. codex exec reads its own auth artifact.
  6. Codex stays Candidate. loadProviders({}) returns empty Map; only
     loadProviders({ enabled: { openai: true } }) loads it. POST /v1/
     chat/completions gpt-5.5 etc still returns 503 until config flag
     is set + E2E audit passes.

Reviewer chain (Iron Rule 10):
  Implementer: sonnet (general-purpose).
  Fresh-context reviewer: opus (ecc:code-reviewer). Verdict
    APPROVE_WITH_MINOR.
  Reviewer ran npm test (174/174 pass with Suite 10 skipped), WebFetched
    all four canonical Codex docs URLs, and discovered two additional
    docs pages (auth + models) that sonnet had missed. Reviewer
    independently verified the documentation citations rather than
    trusting sonnet quotes — Rule 2 (No Invention) is the load-bearing
    check for D6 because no local binary exists to ground-truth the
    plugin.

Reviewer non-blocking findings folded in this commit:
  1. Stdin path corrected: docs explicitly state `Use - to pipe the
     prompt from stdin`. The original draft assumed "no positional →
     stdin"; docs require literal `-`. Fixed in irToCodex; test
     "irToCodex: multiline prompt uses stdin path (useStdin=true) with
     - positional" updated to assert args.includes('-').
  2. Model registry expanded from 3 to 5 entries per canonical models
     doc. Added gpt-5.4-mini and gpt-5.3-codex-spark. Removed the
     misread Rule 2 comment that justified omitting -spark suffix —
     the docs literally show `codex -m gpt-5.3-codex-spark`, so the
     -spark variant is a separate model not a -codex normalization.
     New aliases: codex-spark, gpt5-mini.
  3. File header A2 upgraded from "assumed" to "CONFIRMED" with the
     canonical auth doc URL cited.
  4. File header now cites both auth and models canonical URLs at the
     top, alongside reference and features.

Reviewer findings deferred to D7:
  - OS credential store / keyring support. Codex docs mention
    cli_auth_credentials_store = keyring as an alternative to file
    storage. The Anthropic plugin supports macOS keychain via security
    find-generic-password; Codex equivalent unknown without inspecting
    a real install. D7 will install codex, run codex login, see what
    keyring entry codex creates (if any), and mirror the Anthropic
    keychain support pattern.
  - Real NDJSON event schema (field names). Defensive 4-shape parser
    handles the most common conventions; D7 captures real stdout and
    pins the schema.
  - access_token field name in auth.json. D7 captures the real auth
    artifact and removes unused fallback names.

Test count: 128 (after D5) → 174 (after D6).

Verification:
  node --check on all touched files: clean.
  npm test on Node 25.8.0: 174/174 pass in 210ms with Suite 10 skipped.
  Test "codex.models contains all 5 docs-listed model IDs" passes.
  Test "irToCodex: multiline prompt uses stdin path with - positional"
    passes (asserts args.includes('-')).
  Hygiene grep: zero personal-name/path/token hits.

Co-Authored-By: Claude Opus 4.7 (noreply@anthropic.com)
This commit is contained in:
2026-05-23 21:20:13 +10:00
co-authored by Claude Opus 4.7 (noreply@anthropic.com)
parent 8dd02e77ac
commit ea9184d2e8
4 changed files with 1289 additions and 6 deletions
+609
View File
@@ -0,0 +1,609 @@
/**
* lib/providers/codex.mjs — OpenAI Codex provider plugin
*
* Authority: OLP ALIGNMENT.md § Authority 1 — openai plugin governed by
* `codex exec --json` from `@openai/codex` CLI.
*
* Governing ADRs:
* ADR 0002 — Plugin architecture, Provider contract v1.0
* ADR 0003 — IR design, lossy-translation documentation requirement
* ADR 0006 — OpenAI Codex classified Tier D (permissive, Candidate eligible)
* Authority pin: Codex GitHub Discussion #8338 (maintainer
* confirmed permissive OSS-project posture; not a formal ToS
* blessing — see ADR 0006 § Classification table, row "OpenAI Codex").
*
* Status at D6: CANDIDATE. Not yet Enabled per ALIGNMENT.md § Provider Inventory.
* The plugin is present in STATIC_REGISTRY but enabled: false is the default.
* POST /v1/chat/completions returns 503 until D7 E2E audit passes and the
* enabled flag is flipped in ~/.olp/config.json.
*
* ── Authority pin (D6) ──────────────────────────────────────────────────────
*
* Primary authority: Codex CLI reference page
* URL: https://developers.openai.com/codex/cli/reference
* Sections used:
* § "codex exec [flags] PROMPT" — exec subcommand syntax
* § "--json / --experimental-json" — NDJSON output flag
* § "--model, -m" — model override flag
* § "codex e [flags] PROMPT" — short-form alias
*
* Secondary authority (features page):
* URL: https://developers.openai.com/codex/cli/features
* Sections used:
* § "Supported Models" — gpt-5.5 as recommended frontier model,
* GPT-5.3-Codex-Spark as research-preview fast model
* § "Automation" — exec subcommand described for scripting
*
* Auth authority:
* URL: https://developers.openai.com/codex/cli/reference
* § "Authentication" — credentials stored in $CODEX_HOME (default: ~/.codex/)
* The exact auth.json field names (access_token, etc.) are not pinned by D6
* docs — see Assumption A4 below.
*
* ── Lossy translations (per ADR 0003 § Lossy-translation documentation) ────
*
* - `top_p` — not mapped to a `codex exec` flag; dropped silently.
* The Codex CLI reference (§ flag list) does not expose --top-p.
* ALIGNMENT.md Rule 2 forbids inventing a flag not in the docs.
*
* - `temperature` — not mapped; Codex CLI reference does not expose
* --temperature at the exec subcommand level. Dropped silently.
*
* - `stop` (stop sequences) — not a `codex exec` flag; dropped.
*
* - `max_tokens` — not a `codex exec` flag; dropped silently. The CLI
* does not expose a --max-tokens flag per the reference.
*
* - `tools[]` + `tool_choice` — `codex exec --json` is a text-in / NDJSON-out
* CLI; structured tool definitions are not passed on the wire. Dropped.
* If a caller wants tool use, pre-prompt the model via system message.
* ALIGNMENT.md Rule 2 applies.
*
* - Assistant-message `tool_calls` / `tool_call_id` (prior-turn metadata) —
* same reason as `tools[]`; structured metadata dropped, textual content
* preserved in prompt serialization.
*
* - `response_format: json_object` — Codex CLI does not natively honor this
* field. A system-prompt augmentation is injected ("Reply with valid JSON
* only."), mirroring the Anthropic plugin's approach (ADR 0003 § Lossy).
*
* ── Codex NDJSON event type assumptions (D6) ────────────────────────────────
*
* The Codex CLI reference (§ "--json") states: "Outputs newline-delimited JSON
* events rather than formatted text, enabling machine-readable progress
* tracking." It does not enumerate specific event type schemas. D6 therefore
* defines a defensive parsing convention (see codexChunkToIR) that:
* - Treats any line with a `content` string field as a delta event
* - Treats any line with `type === 'stop'` or `done === true` as a stop event
* - Treats any line with `error` field or `type === 'error'` as an error event
* - Silently ignores other events (progress, tool_call, shell, etc.)
* D7 E2E will observe real events and correct this convention if wrong.
*
* ── Canonical Codex docs URLs (authority per ALIGNMENT.md Authority 1) ─────
* Reference: https://developers.openai.com/codex/cli/reference
* Features: https://developers.openai.com/codex/cli/features
* Auth: https://developers.openai.com/codex/auth/ (canonical for auth.json)
* Models: https://developers.openai.com/codex/models (canonical for accepted --model IDs)
*
* ── D6 spec assumptions (revised after D6 review-2) ─────────────────────────
*
* A1 (command shape — CONFIRMED): `codex exec --json --model <model> PROMPT`.
* Reference § "codex exec" states: "Use '-' to pipe the prompt from stdin."
* Stdin path uses `args.push('-')` followed by `proc.stdin.write(prompt)`.
* Earlier D6 draft used "no positional + write stdin" — corrected here per
* reviewer round-2 finding citing the canonical reference page.
*
* A2 (auth file — CONFIRMED): `~/.codex/auth.json` is the documented auth
* artifact path. Per https://developers.openai.com/codex/auth/ : "Codex
* caches login details locally in a plaintext file at ~/.codex/auth.json
* or in your OS-specific credential store." $CODEX_HOME env overrides the
* base dir. OPENAI_CODEX_AUTH_PATH overrides the full path for testing.
*
* A3 (access token field — UNPINNED, D7 will probe): Auth doc does not
* enumerate the JSON field name. Defensive try-order: `access_token`,
* `token`, `accessToken`. D7 captures the real auth.json after `codex login`
* and pins the field name; the others can be removed.
*
* A4 (NDJSON delta events — UNPINNED, D7 will probe): Reference docs say
* "Outputs newline-delimited JSON events" without enumerating event schemas.
* Defensive 4-shape parser: `content` field → delta, `type:'stop'` or
* `done:true` → stop, `type:'error'` or `error` field → error, anything else
* ignored. D7 captures real stdout and corrects codexChunkToIR if events
* use different field names.
*
* A5 (env override — CONFIRMED NOT APPLICABLE): The auth doc mentions
* `OPENAI_API_KEY` only as input to `codex login --with-api-key`
* (login-time, not runtime). The plugin does NOT inject any access-token
* env var into the spawn env. `codex exec` reads its auth artifact directly
* from $CODEX_HOME/auth.json or the OS credential store.
*
* ── Known D7 follow-ups (not bugs at D6, but to verify) ─────────────────────
* - OS credential store / keyring path. Per auth doc, credentials may live in
* an OS-specific credential store rather than auth.json. The Anthropic plugin
* supports macOS keychain via `security find-generic-password`; the Codex
* equivalent is unknown without a real install. D7 will inspect the live
* `codex login` artifact and add keyring support if needed.
* - Exact `access_token` field name (A3).
* - Real NDJSON event schema (A4).
*/
import { spawn as defaultSpawn } from 'node:child_process';
import { execSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { ProviderError } from './base.mjs';
// ── Binary resolution ─────────────────────────────────────────────────────
// OLP_CODEX_BIN env takes priority, then falls back to 'codex' from PATH.
// Mirrors the OLP_CLAUDE_BIN pattern in anthropic.mjs (same env-override-first
// approach). Using OLP_CODEX_BIN to avoid colliding with any system-level
// CODEX_BIN if other tools use that name.
function resolveCodexBin() {
return process.env.OLP_CODEX_BIN || 'codex';
}
// ── Auth artifact reading ─────────────────────────────────────────────────
// Reads the Codex auth artifact from disk.
//
// Auth authority: Codex CLI reference § "Authentication"
// "Credentials are stored in $CODEX_HOME (typically ~/.codex/)"
// CODEX_HOME default: ~/.codex/
//
// Priority order:
// 1. OPENAI_CODEX_AUTH_PATH env override — testing convenience + custom install
// 2. $CODEX_HOME/auth.json where CODEX_HOME = process.env.CODEX_HOME ?? ~/.codex/
//
// D6 assumption A2: auth file is named auth.json (unconfirmed — D7 will pin).
// D6 assumption A3: access token field is `access_token` or `token` (unconfirmed).
//
// Returns { accessToken: string } or null (never throws).
export function readAuthArtifact() {
// 1. Explicit test override — always takes precedence.
const authPathOverride = process.env.OPENAI_CODEX_AUTH_PATH;
if (authPathOverride) {
try {
const raw = readFileSync(authPathOverride, 'utf8');
const creds = JSON.parse(raw);
const token = creds?.access_token ?? creds?.token ?? creds?.accessToken;
if (token && typeof token === 'string') return { accessToken: token };
} catch { /* fall through */ }
return null; // explicit path set but file missing / malformed
}
// 2. $CODEX_HOME/auth.json (default: ~/.codex/auth.json)
// Codex CLI reference § "Authentication": credentials in $CODEX_HOME (default ~/.codex/).
const codexHome = process.env.CODEX_HOME ?? join(homedir(), '.codex');
const authPath = join(codexHome, 'auth.json');
try {
const raw = readFileSync(authPath, 'utf8');
const creds = JSON.parse(raw);
// D6 assumption A3: try common OAuth field names in precedence order.
const token = creds?.access_token ?? creds?.token ?? creds?.accessToken;
if (token && typeof token === 'string') return { accessToken: token };
} catch { /* file missing or malformed */ }
return null;
}
// ── IR → prompt text serialization ───────────────────────────────────────
// Translates an IR request into the { args, prompt } shape that spawn() needs.
//
// Authority: Codex CLI reference § "codex exec [flags] PROMPT"
// Prompt is the final positional argument to `codex exec`.
// Multi-line or large prompts: passed via stdin when prompt contains newlines
// (D6 assumption A1 — stdin fallback unconfirmed; D7 will verify).
//
// Message serialization mirrors anthropic.mjs's irToAnthropic() pattern:
// system → "[System] <text>", assistant → "[Assistant] <text>",
// tool → "[Tool Result] <text>", user → plain text.
//
// ADR 0003 § Translation direction model: the plugin owns irToNative.
export function irToCodex(irRequest) {
const parts = [];
for (const msg of irRequest.messages) {
const text = typeof msg.content === 'string'
? msg.content
: JSON.stringify(msg.content);
if (msg.role === 'system') {
// System prompt — annotate so the model understands context.
parts.push(`[System] ${text}`);
} else if (msg.role === 'assistant') {
// Prior assistant turn — preserve for conversation context.
// tool_calls / tool_call_id metadata dropped (lossy — see file header).
parts.push(`[Assistant] ${text}`);
} else if (msg.role === 'tool') {
// Tool result turn — annotate with name if available.
// Structured tool_call_id dropped (lossy — see file header).
const nameAnnotation = msg.name ? ` (${msg.name})` : '';
parts.push(`[Tool Result${nameAnnotation}] ${text}`);
} else {
// user role — plain text, no annotation.
parts.push(text);
}
}
// Lossy: response_format json_object → system-prompt augmentation.
// Codex CLI does not natively honor response_format; inject a system message
// to request JSON output. ADR 0003 § Lossy-translation documentation.
if (irRequest.response_format?.type === 'json_object') {
parts.unshift('[System] Reply with valid JSON only. Do not include any prose outside the JSON structure.');
}
const prompt = parts.join('\n\n');
// Authority: Codex CLI reference § "codex exec [flags] PROMPT"
// https://developers.openai.com/codex/cli/reference
// --json / --experimental-json: "Print newline-delimited JSON events instead
// of formatted text"
// --model, -m: "Override the configured model for this run." Accepts the
// model string (e.g., gpt-5.5, gpt-5.4, gpt-5.3-codex).
// PROMPT: "Initial instruction for the task. Use '-' to pipe the prompt
// from stdin."
const args = [
'exec',
'--json',
'--model', irRequest.model,
];
// Prompt passing strategy:
// Short prompts (no newlines) → final argv positional per CLI synopsis.
// Multi-line prompts → pipe via stdin using the documented `-` positional
// (reference: "Use '-' to pipe the prompt from stdin."). The earlier D6
// draft assumed stdin worked with no positional; codex-d6 review caught
// that the documented stdin trigger is the literal `-` positional.
const useStdin = prompt.includes('\n');
if (useStdin) {
args.push('-');
} else {
args.push(prompt);
}
return { args, prompt, useStdin };
}
// ── Codex NDJSON chunk → IR ResponseChunk ────────────────────────────────
// Parses one NDJSON line emitted by `codex exec --json` into an IR ResponseChunk.
//
// D6 assumption A4 (event schema — unconfirmed, D7 will pin from real output):
// Content/delta event: line with a `content` string field
// → { type: 'delta', content: <string> }
// Stop event: line with type === 'stop' OR done === true
// → { type: 'stop', finish_reason: 'stop' }
// Error event: line with type === 'error' OR error field present
// → { type: 'error', error: <string> }
// Other (progress, shell_exec, tool_call, etc.) → null (caller skips)
//
// Returns null for lines that should be silently ignored (progress events, etc.).
export function codexChunkToIR(rawNDJSONLine) {
if (!rawNDJSONLine || !rawNDJSONLine.trim()) return null;
let event;
try {
event = JSON.parse(rawNDJSONLine.trim());
} catch {
// Malformed JSON line — skip silently (alignment with Rule 2: don't invent)
return null;
}
if (!event || typeof event !== 'object') return null;
// Error event: type === 'error' or error field present
if (event.type === 'error' || (event.error && typeof event.error === 'string')) {
const errMsg = event.error ?? event.message ?? 'codex error';
return { type: 'error', error: errMsg };
}
// Stop event: type === 'stop' or done === true
if (event.type === 'stop' || event.done === true) {
return { type: 'stop', finish_reason: 'stop' };
}
// Delta/content event: has a content string field
if (typeof event.content === 'string') {
return { type: 'delta', content: event.content };
}
// delta field (alternative shape): { type: 'delta', delta: '...' }
if (event.type === 'delta' && typeof event.delta === 'string') {
return { type: 'delta', content: event.delta };
}
// Output_text event shape (possible alternative): { type: 'output_text', text: '...' }
if (typeof event.text === 'string') {
return { type: 'delta', content: event.text };
}
// All other event types (progress, shell_exec, tool_call, etc.) → ignore
return null;
}
// ── CLI args builder + env setup ──────────────────────────────────────────
// Builds the spawn environment. We do NOT inject the auth token as an env var
// because the Codex CLI manages OAuth state in its own auth.json file
// (CLI reference § "Authentication"). We simply ensure env is clean.
function buildSpawnEnv() {
const env = { ...process.env };
// Propagate CODEX_HOME if set (auth path resolution uses it).
// Clean up any conflicting OpenAI env vars that might override CLI behavior.
// No specific deletions confirmed needed at D6; conservative cleanup mirrors
// anthropic.mjs pattern.
return env;
}
// ── Spawn function (core) ─────────────────────────────────────────────────
// Returns an AsyncIterator<IRResponseChunk> per ADR 0002 § Provider contract.
//
// Spawn pattern:
// 1. Resolve binary via OLP_CODEX_BIN env or PATH 'codex'
// 2. Build args via irToCodex() → ['exec', '--json', '--model', <model>, <prompt>?]
// 3. Spawn with stdio: ['pipe', 'pipe', 'pipe']
// 4. If useStdin: write prompt to proc.stdin, end stdin
// 5. Parse each stdout line as NDJSON via codexChunkToIR()
// 6. On non-zero exit: throw ProviderError('SPAWN_FAILED')
// 7. On normal exit: emit stop chunk if not already emitted
//
// Authority: Codex CLI reference § "codex exec [flags] PROMPT"
// § "--json": NDJSON event stream on stdout
async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
const auth = authContext ?? readAuthArtifact();
if (!auth?.accessToken) {
throw new ProviderError(
'No OpenAI Codex auth token found. Run `codex login` or set OPENAI_CODEX_AUTH_PATH.',
'AUTH_MISSING',
);
}
const bin = resolveCodexBin();
const { args, prompt, useStdin } = irToCodex(irRequest);
const env = buildSpawnEnv();
// Authority: Codex CLI reference § "Authentication"
// CODEX_HOME propagated from env for auth file resolution.
// No explicit token injection: Codex CLI reads its own auth.json
// (contrast with Anthropic plugin which injects CLAUDE_CODE_OAUTH_TOKEN).
const proc = spawnImpl(bin, args, { env, stdio: ['pipe', 'pipe', 'pipe'] });
// Write prompt via stdin for multi-line prompts (D6 assumption A1)
if (useStdin) {
proc.stdin.write(prompt);
}
proc.stdin.end();
// Yield IR chunks as they arrive — push-buffer + signal pattern
// (mirrors anthropic.mjs _spawnAndStream, same approach).
const chunks = [];
let done = false;
let exitCode = null;
let accumulatedStderr = '';
let stopEmitted = false;
let resolveNext = null;
function push(item) {
chunks.push(item);
if (resolveNext) {
const r = resolveNext;
resolveNext = null;
r();
}
}
// Buffer stdout by line for NDJSON parsing.
// Codex --json emits one JSON object per line (NDJSON convention).
let stdoutBuf = '';
proc.stdout.on('data', (d) => {
stdoutBuf += d.toString();
// Split on newlines; incomplete last segment stays in buffer.
const lines = stdoutBuf.split('\n');
stdoutBuf = lines.pop(); // last segment (possibly incomplete)
for (const line of lines) {
if (line.trim()) {
push({ type: 'ndjson_line', line });
}
}
});
proc.stdout.on('end', () => {
// Flush any remaining buffered content (line without trailing newline)
if (stdoutBuf.trim()) {
push({ type: 'ndjson_line', line: stdoutBuf.trim() });
stdoutBuf = '';
}
});
proc.stderr.on('data', (d) => {
accumulatedStderr += d.toString();
});
proc.on('error', (err) => {
push({ type: 'error', err });
});
proc.on('close', (code, _signal) => {
exitCode = code;
done = true;
push({ type: 'close' });
});
// Drain the chunk buffer
let isFirstChunk = true;
while (true) {
if (chunks.length === 0) {
if (done) break;
await new Promise((resolve) => { resolveNext = resolve; });
continue;
}
const item = chunks.shift();
if (item.type === 'error') {
throw new ProviderError(`codex spawn error: ${item.err.message}`, 'SPAWN_FAILED');
}
if (item.type === 'close') {
break;
}
if (item.type === 'ndjson_line') {
const irChunk = codexChunkToIR(item.line);
if (irChunk === null) continue; // ignored event type (progress, etc.)
if (irChunk.type === 'error') {
throw new ProviderError(irChunk.error, 'SPAWN_FAILED');
}
if (irChunk.type === 'stop') {
stopEmitted = true;
yield irChunk;
continue;
}
if (irChunk.type === 'delta') {
// Add role to first delta chunk (matching anthropic.mjs pattern)
if (isFirstChunk) {
yield { type: 'delta', role: 'assistant', content: irChunk.content };
isFirstChunk = false;
} else {
yield irChunk;
}
}
}
}
// Process close
if (exitCode !== 0) {
const errMsg = accumulatedStderr.slice(0, 300) || `codex exit ${exitCode}`;
throw new ProviderError(errMsg, 'SPAWN_FAILED');
}
// Normal exit: emit stop chunk if the NDJSON stream didn't already emit one.
if (!stopEmitted) {
yield { type: 'stop', finish_reason: 'stop' };
}
}
// ── spawn (public, contract method) ──────────────────────────────────────
// Public spawn conforms to ADR 0002 § Provider contract:
// spawn: async (irRequest, authContext) => AsyncIterator<ResponseChunk>
let _spawnImpl = defaultSpawn;
export async function* spawn(irRequest, authContext) {
yield* _spawnAndStream(irRequest, authContext, _spawnImpl);
}
// Test hook: inject mock spawn without importing child_process.
export { _spawnImpl };
export function __setSpawnImpl(impl) { _spawnImpl = impl; }
export function __resetSpawnImpl() { _spawnImpl = defaultSpawn; }
// ── estimateCost ──────────────────────────────────────────────────────────
// Best-effort token estimation using chars/4 heuristic.
// Basis: OpenAI Cookbook "How to count tokens" rule-of-thumb: ~4 chars/token.
// Reference: https://cookbook.openai.com/examples/how_to_count_tokens_with_tiktoken
//
// usd: null at D6 — ChatGPT plan token-cost rates not yet pinned for Codex.
// TODO: populate usd once rates are confirmed post-D7 E2E billing audit.
export function estimateCost(request) {
if (!request?.messages) return null;
let inputChars = 0;
for (const msg of request.messages) {
const text = typeof msg.content === 'string'
? msg.content
: JSON.stringify(msg.content);
inputChars += text.length;
}
const outputCharsEstimate = Math.ceil(inputChars * 0.5);
return {
inputTokens: Math.ceil(inputChars / 4),
outputTokensEstimate: Math.ceil(outputCharsEstimate / 4),
currency: 'USD',
usd: null, // not pinned at D6
};
}
// ── quotaStatus ───────────────────────────────────────────────────────────
// Returns null at D6. ChatGPT plan limits are not exposed via a programmatic
// endpoint accessible from the CLI tier. If an API endpoint surfaces, pin it
// here with a docs URL (per ALIGNMENT.md Rule 1 authority-first requirement).
export async function quotaStatus(_authContext) {
return null;
}
// ── healthCheck ───────────────────────────────────────────────────────────
// Does NOT spawn a real `codex exec` request (gated to D7 E2E).
// Checks: (1) `codex` binary exists on PATH, (2) auth artifact exists.
// Mirrors anthropic.mjs healthCheck pattern with injectable test overrides.
export async function healthCheck({ _binaryExistsFn, _authReadFn } = {}) {
const t0 = Date.now();
// 1. Binary check
const binaryExists = _binaryExistsFn ?? _defaultBinaryExists;
if (!binaryExists()) {
return { ok: false, latencyMs: Date.now() - t0, error: 'codex binary not found' };
}
// 2. Auth artifact check
const authRead = _authReadFn ?? readAuthArtifact;
const auth = authRead();
if (!auth?.accessToken) {
return { ok: false, latencyMs: Date.now() - t0, error: 'auth artifact missing' };
}
return { ok: true, latencyMs: Date.now() - t0 };
}
function _defaultBinaryExists() {
const bin = resolveCodexBin();
if (bin !== 'codex') {
// Explicit path given — check directly
return existsSync(bin);
}
// 'codex' from PATH — use which
try {
execSync('which codex', { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] });
return true;
} catch {
return false;
}
}
// ── Provider export ───────────────────────────────────────────────────────
// Conforms to ADR 0002 § "Provider contract (v1.0 interface)" + contractVersion.
import modelsRegistryRaw from '../../models-registry.json' with { type: 'json' };
const _registryModels = (modelsRegistryRaw?.providers?.openai?.models ?? []).map(m => m.id);
const codex = {
name: 'openai',
displayName: 'OpenAI Codex',
contractVersion: '1.0',
models: _registryModels,
auth: {
type: 'oauth',
storage: 'file',
// Auth authority: Codex CLI reference § "Authentication"
// "Credentials stored in $CODEX_HOME (typically ~/.codex/)"
// D6 assumption A2: auth file named auth.json (D7 will confirm).
path: join(homedir(), '.codex', 'auth.json'),
refresh: 'auto',
},
spawn,
estimateCost,
quotaStatus,
healthCheck,
hints: {
requiresTTY: false, // codex exec runs headless (per CLI reference § exec)
concurrentSpawnSafe: true, // each invocation is independent
maxConcurrent: 4, // conservative default, same as anthropic
},
};
export default codex;
+7 -1
View File
@@ -13,18 +13,24 @@
* as a Candidate. It is NOT Enabled by default — a config with * as a Candidate. It is NOT Enabled by default — a config with
* { enabled: { anthropic: true } } is required, which only happens after D5 * { enabled: { anthropic: true } } is required, which only happens after D5
* E2E audit passes per ALIGNMENT.md § Provider Inventory. * E2E audit passes per ALIGNMENT.md § Provider Inventory.
*
* At D6 (Phase 1 Day 4), the openai (Codex) plugin is added to STATIC_REGISTRY
* as a Candidate. Enabled by { enabled: { openai: true } } after D7 E2E audit.
*/ */
import { validateProvider } from './base.mjs'; import { validateProvider } from './base.mjs';
import anthropicDefault from './anthropic.mjs'; import anthropicDefault from './anthropic.mjs';
import codexDefault from './codex.mjs';
// Normalize default export pattern // Normalize default export pattern
const anthropic = anthropicDefault; const anthropic = anthropicDefault;
const codex = codexDefault;
// ── Static registry ─────────────────────────────────────────────────────── // ── Static registry ───────────────────────────────────────────────────────
const STATIC_REGISTRY = [ const STATIC_REGISTRY = [
anthropic, anthropic,
codex,
]; ];
// ── Registry functions ──────────────────────────────────────────────────── // ── Registry functions ────────────────────────────────────────────────────
@@ -90,7 +96,7 @@ export function getProviderByName(loadedProviders, name) {
/** /**
* Returns all provider names in the static registry (whether enabled or not). * Returns all provider names in the static registry (whether enabled or not).
* Used by /health and diagnostics. * Used by /health and diagnostics.
* At D4: returns ['anthropic'] (the candidate roster). * At D4: returns ['anthropic']; at D6: returns ['anthropic', 'openai'].
* *
* @returns {string[]} * @returns {string[]}
*/ */
+49
View File
@@ -32,6 +32,55 @@
"opus": "claude-opus-4-7", "opus": "claude-opus-4-7",
"haiku": "claude-haiku-4-5" "haiku": "claude-haiku-4-5"
} }
},
"openai": {
"displayName": "OpenAI Codex",
"tier": "D",
"candidate": true,
"comment": "Model IDs sourced from canonical Codex models doc (https://developers.openai.com/codex/models, retrieved 2026-05-23). Each id is documented as a `codex -m <id>` example on that page. D7 E2E will probe each one.",
"models": [
{
"id": "gpt-5.5",
"displayName": "GPT-5.5",
"contextWindow": null,
"deprecated": false,
"note": "Frontier recommended model. Docs example: `codex -m gpt-5.5`."
},
{
"id": "gpt-5.4",
"displayName": "GPT-5.4",
"contextWindow": null,
"deprecated": false,
"note": "Docs example: `codex -m gpt-5.4`."
},
{
"id": "gpt-5.4-mini",
"displayName": "GPT-5.4 Mini",
"contextWindow": null,
"deprecated": false,
"note": "Lower-cost gpt-5.4 variant. Docs example: `codex -m gpt-5.4-mini`."
},
{
"id": "gpt-5.3-codex",
"displayName": "GPT-5.3-Codex",
"contextWindow": null,
"deprecated": false,
"note": "Docs example: `codex -m gpt-5.3-codex`. Distinct from the -spark variant."
},
{
"id": "gpt-5.3-codex-spark",
"displayName": "GPT-5.3-Codex Spark",
"contextWindow": null,
"deprecated": false,
"note": "Research preview. Docs example: `codex -m gpt-5.3-codex-spark`."
}
],
"aliases": {
"codex": "gpt-5.3-codex",
"codex-spark": "gpt-5.3-codex-spark",
"gpt5": "gpt-5.5",
"gpt5-mini": "gpt-5.4-mini"
}
} }
} }
} }
+624 -5
View File
@@ -40,6 +40,16 @@ import anthropic, {
__setSpawnImpl, __setSpawnImpl,
__resetSpawnImpl, __resetSpawnImpl,
} from './lib/providers/anthropic.mjs'; } from './lib/providers/anthropic.mjs';
import codex, {
irToCodex,
codexChunkToIR,
readAuthArtifact as codexReadAuthArtifact,
estimateCost as codexEstimateCost,
quotaStatus as codexQuotaStatus,
healthCheck as codexHealthCheck,
__setSpawnImpl as codexSetSpawnImpl,
__resetSpawnImpl as codexResetSpawnImpl,
} from './lib/providers/codex.mjs';
import modelsRegistry from './models-registry.json' with { type: 'json' }; import modelsRegistry from './models-registry.json' with { type: 'json' };
// ── Helpers ─────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────
@@ -478,9 +488,9 @@ describe('Provider contract validation', () => {
// ── Suite 5: Plugin registry ────────────────────────────────────────────── // ── Suite 5: Plugin registry ──────────────────────────────────────────────
describe('Plugin registry', () => { describe('Plugin registry', () => {
it('STATIC_REGISTRY has 1 entry (anthropic candidate) at D4', () => { it('STATIC_REGISTRY has 2 entries (anthropic + openai candidates) at D6', () => {
// D4: anthropic is in STATIC_REGISTRY but default config has enabled:false // D4 added anthropic; D6 adds openai. Default config has both enabled:false.
assert.equal(listAllProviderNames().length, 1); assert.equal(listAllProviderNames().length, 2);
}); });
it('loadProviders with empty config → empty Map (anthropic not enabled)', () => { it('loadProviders with empty config → empty Map (anthropic not enabled)', () => {
@@ -493,8 +503,9 @@ describe('Plugin registry', () => {
assert.equal(m.size, 0); assert.equal(m.size, 0);
}); });
it('listAllProviderNames returns [anthropic] at D4', () => { it('listAllProviderNames returns [anthropic, openai] at D6', () => {
assert.deepEqual(listAllProviderNames(), ['anthropic']); // D4: ['anthropic']. D6 adds openai.
assert.deepEqual(listAllProviderNames(), ['anthropic', 'openai']);
}); });
it('getProviderForModel returns null when no providers loaded', () => { it('getProviderForModel returns null when no providers loaded', () => {
@@ -1590,6 +1601,614 @@ describe('Cache layer — HTTP integration (Suite 9 cont.)', () => {
}); });
}); });
// ── Suite 11: Codex plugin (D6) ──────────────────────────────────────────
//
// All tests are UNIT tests. No real `codex` binary is invoked.
// Mock spawn is injected via codexSetSpawnImpl / codexResetSpawnImpl.
//
// Authority: Codex CLI reference https://developers.openai.com/codex/cli/reference
// § "codex exec [flags] PROMPT" — exec subcommand syntax
// § "--json" — NDJSON output format
// § "--model, -m" — model override
// § "$CODEX_HOME/auth.json" — auth artifact location
//
// Lossy-translation acknowledgements per ADR 0003 (documented in codex.mjs header):
// top_p, temperature, stop, max_tokens, tools[], tool_calls → all dropped.
/**
* Creates a fake NDJSON-emitting mock spawn for Codex.
* Lines are emitted as they would arrive from `codex exec --json`.
*
* @param {string[]} ndjsonLines — raw NDJSON lines emitted in order (no trailing \n needed)
* @param {number} [exitCode=0]
*/
function makeMockCodexSpawn(ndjsonLines, exitCode = 0) {
return function mockCodexSpawnImpl(_bin, _args, _opts) {
const proc = new EventEmitter();
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.stdin = {
write: () => {},
end: () => {
setImmediate(async () => {
for (const line of ndjsonLines) {
proc.stdout.emit('data', Buffer.from(line + '\n'));
}
proc.stdout.emit('end');
proc.stderr.emit('end');
proc.emit('close', exitCode, null);
});
},
};
proc.killed = false;
proc.kill = () => {};
return proc;
};
}
describe('Codex plugin (D6)', () => {
// ── Test 1: Contract conformance ─────────────────────────────────────
it('codex module satisfies validateProvider() — all 10 fields present', () => {
const { valid, errors } = validateProvider(codex);
assert.equal(valid, true, `Validation errors: ${errors.join('; ')}`);
assert.ok('name' in codex, 'missing: name');
assert.ok('displayName' in codex, 'missing: displayName');
assert.ok('contractVersion' in codex, 'missing: contractVersion');
assert.ok('models' in codex, 'missing: models');
assert.ok('auth' in codex, 'missing: auth');
assert.ok(typeof codex.spawn === 'function', 'missing: spawn');
assert.ok(typeof codex.estimateCost === 'function', 'missing: estimateCost');
assert.ok(typeof codex.quotaStatus === 'function', 'missing: quotaStatus');
assert.ok(typeof codex.healthCheck === 'function', 'missing: healthCheck');
assert.ok('hints' in codex, 'missing: hints');
});
// ── Test 2: contractVersion === '1.0' ────────────────────────────────
it('codex declares contractVersion === "1.0"', () => {
assert.equal(codex.contractVersion, '1.0');
});
// ── Test 3: name and displayName ─────────────────────────────────────
it('codex.name === "openai" and displayName is set', () => {
assert.equal(codex.name, 'openai');
assert.equal(typeof codex.displayName, 'string');
assert.ok(codex.displayName.length > 0);
});
// ── Test 4: models match registry ───────────────────────────────────
it('codex.models matches models-registry.json providers.openai.models[].id', () => {
const registryIds = modelsRegistry.providers.openai.models.map(m => m.id);
assert.deepEqual(codex.models, registryIds);
});
it('codex.models contains all 5 docs-listed model IDs', () => {
// Per https://developers.openai.com/codex/models — each id has a
// `codex -m <id>` example on that page. D6 review-2 expanded the
// registry to include gpt-5.4-mini and gpt-5.3-codex-spark which the
// original sonnet draft missed.
assert.ok(codex.models.includes('gpt-5.5'), 'missing gpt-5.5');
assert.ok(codex.models.includes('gpt-5.4'), 'missing gpt-5.4');
assert.ok(codex.models.includes('gpt-5.4-mini'), 'missing gpt-5.4-mini');
assert.ok(codex.models.includes('gpt-5.3-codex'), 'missing gpt-5.3-codex');
assert.ok(codex.models.includes('gpt-5.3-codex-spark'), 'missing gpt-5.3-codex-spark');
assert.equal(codex.models.length, 5, `Expected 5 models, got ${codex.models.length}`);
});
// ── Test 5: getProviderForModel finds codex for each model ──────────
it('getProviderForModel finds openai provider for gpt-5.5', () => {
const loaded = new Map([['openai', codex]]);
const result = getProviderForModel(loaded, 'gpt-5.5');
assert.ok(result !== null);
assert.equal(result.name, 'openai');
});
it('getProviderForModel finds openai provider for gpt-5.4', () => {
const loaded = new Map([['openai', codex]]);
const result = getProviderForModel(loaded, 'gpt-5.4');
assert.ok(result !== null);
assert.equal(result.name, 'openai');
});
it('getProviderForModel finds openai provider for gpt-5.3-codex', () => {
const loaded = new Map([['openai', codex]]);
const result = getProviderForModel(loaded, 'gpt-5.3-codex');
assert.ok(result !== null);
assert.equal(result.name, 'openai');
});
// ── Test 6: irToCodex translation ────────────────────────────────────
it('irToCodex: user message → args with exec --json --model, prompt as positional', () => {
const ir = makeIR({
model: 'gpt-5.5',
messages: [{ role: 'user', content: 'Hello world' }],
});
const { args, prompt, useStdin } = irToCodex(ir);
// Authority: Codex CLI reference § "codex exec [flags] PROMPT"
assert.ok(args.includes('exec'), 'args must include "exec"');
assert.ok(args.includes('--json'), 'args must include "--json"');
assert.ok(args.includes('--model'), 'args must include "--model"');
assert.ok(args.includes('gpt-5.5'), 'args must include model value');
assert.ok(typeof prompt === 'string', 'prompt must be a string');
assert.ok(prompt.includes('Hello world'), 'prompt must contain user text');
assert.equal(useStdin, false, 'single-line prompt should use argv, not stdin');
});
it('irToCodex: system + user → system annotation + user text in prompt', () => {
const ir = makeIR({
model: 'gpt-5.4',
messages: [
{ role: 'system', content: 'You are a coder.' },
{ role: 'user', content: 'Write a function.' },
],
});
const { prompt } = irToCodex(ir);
assert.ok(prompt.includes('[System] You are a coder.'));
assert.ok(prompt.includes('Write a function.'));
});
it('irToCodex: assistant prior turn → [Assistant] annotation', () => {
const ir = makeIR({
model: 'gpt-5.5',
messages: [
{ role: 'user', content: 'Hi' },
{ role: 'assistant', content: 'Hello!' },
{ role: 'user', content: 'Thanks' },
],
});
const { prompt } = irToCodex(ir);
assert.ok(prompt.includes('[Assistant] Hello!'));
assert.ok(prompt.includes('Thanks'));
});
it('irToCodex: tool_calls in assistant message — content preserved, metadata dropped (lossy)', () => {
// ADR 0003 § Lossy: structured tool_calls dropped; textual content preserved.
const ir = makeIR({
model: 'gpt-5.5',
messages: [
{ role: 'user', content: 'Search for X' },
{
role: 'assistant',
content: 'Searching...',
tool_calls: [{ id: 'tc1', type: 'function', function: { name: 'search', arguments: '{"q":"X"}' } }],
},
],
});
const { prompt } = irToCodex(ir);
// Content preserved
assert.ok(prompt.includes('Searching...'), 'assistant text content must be preserved');
// tool_calls metadata not directly in prompt (dropped per lossy spec)
// (We do NOT assert the metadata IS there — it is documented as dropped.)
});
it('irToCodex: response_format json_object injects system prompt', () => {
const ir = makeIR({
model: 'gpt-5.5',
messages: [{ role: 'user', content: 'Give JSON' }],
response_format: { type: 'json_object' },
});
const { prompt } = irToCodex(ir);
assert.ok(prompt.includes('Reply with valid JSON only'));
});
it('irToCodex: multiline prompt uses stdin path (useStdin=true) with `-` positional', () => {
const ir = makeIR({
model: 'gpt-5.5',
messages: [
{ role: 'system', content: 'Line one.' },
{ role: 'user', content: 'Line two.' },
],
});
const { useStdin, args } = irToCodex(ir);
// System + user messages join with '\n\n' → contains newline → stdin
assert.equal(useStdin, true, 'multi-section prompt should use stdin');
// Per Codex CLI reference, stdin requires the literal `-` positional.
// (D6 review-2 finding: original draft omitted positional entirely;
// docs explicitly state stdin requires `-`.)
assert.ok(args.includes('-'), 'stdin path must pass `-` as positional');
assert.ok(!args.includes('Line one.'), 'literal prompt must not appear in args when useStdin');
});
it('irToCodex: tool result turn → [Tool Result] annotation', () => {
const ir = makeIR({
model: 'gpt-5.5',
messages: [
{ role: 'user', content: 'Search for X' },
{ role: 'tool', content: '{"results":[]}', name: 'search' },
],
});
const { prompt } = irToCodex(ir);
assert.ok(prompt.includes('[Tool Result'));
assert.ok(prompt.includes('search'));
});
// ── Test 7: codexChunkToIR translation ────────────────────────────────
it('codexChunkToIR: content field → delta chunk', () => {
const chunk = codexChunkToIR('{"content":"Hello world"}');
assert.ok(chunk !== null);
assert.equal(chunk.type, 'delta');
assert.equal(chunk.content, 'Hello world');
});
it('codexChunkToIR: delta field → delta chunk', () => {
const chunk = codexChunkToIR('{"type":"delta","delta":"token text"}');
assert.ok(chunk !== null);
assert.equal(chunk.type, 'delta');
assert.equal(chunk.content, 'token text');
});
it('codexChunkToIR: text field → delta chunk', () => {
// Possible alternative event shape with "text" field
const chunk = codexChunkToIR('{"type":"output_text","text":"output here"}');
assert.ok(chunk !== null);
assert.equal(chunk.type, 'delta');
assert.equal(chunk.content, 'output here');
});
it('codexChunkToIR: type === "stop" → stop chunk', () => {
const chunk = codexChunkToIR('{"type":"stop"}');
assert.ok(chunk !== null);
assert.equal(chunk.type, 'stop');
assert.equal(chunk.finish_reason, 'stop');
});
it('codexChunkToIR: done === true → stop chunk', () => {
const chunk = codexChunkToIR('{"done":true,"id":"run_123"}');
assert.ok(chunk !== null);
assert.equal(chunk.type, 'stop');
assert.equal(chunk.finish_reason, 'stop');
});
it('codexChunkToIR: type === "error" → error chunk', () => {
const chunk = codexChunkToIR('{"type":"error","error":"quota exceeded"}');
assert.ok(chunk !== null);
assert.equal(chunk.type, 'error');
assert.equal(chunk.error, 'quota exceeded');
});
it('codexChunkToIR: error field present → error chunk', () => {
const chunk = codexChunkToIR('{"error":"something went wrong","code":500}');
assert.ok(chunk !== null);
assert.equal(chunk.type, 'error');
});
it('codexChunkToIR: unknown/progress event type → null (silently ignored)', () => {
// Events like {"type":"progress","step":1} are ignored per D6 spec
const chunk = codexChunkToIR('{"type":"progress","step":1}');
assert.equal(chunk, null);
});
it('codexChunkToIR: empty line → null', () => {
assert.equal(codexChunkToIR(''), null);
assert.equal(codexChunkToIR(' '), null);
});
it('codexChunkToIR: malformed JSON → null (no throw)', () => {
assert.doesNotThrow(() => {
const result = codexChunkToIR('{bad json');
assert.equal(result, null);
});
});
// ── Test 8: mock spawn — NDJSON stream yields correct IR chunks ───────
it('spawn with mock: NDJSON lines → delta chunks then stop chunk', async () => {
const fakeSpawn = makeMockCodexSpawn([
'{"content":"Hello"}',
'{"content":" world"}',
'{"type":"stop"}',
]);
codexSetSpawnImpl(fakeSpawn);
try {
const ir = makeIR({
model: 'gpt-5.5',
stream: true,
messages: [{ role: 'user', content: 'Hi' }],
});
const authCtx = { accessToken: '<fake-openai-token>' };
const chunks = [];
for await (const chunk of codex.spawn(ir, authCtx)) {
chunks.push(chunk);
}
const deltas = chunks.filter(c => c.type === 'delta');
const stops = chunks.filter(c => c.type === 'stop');
assert.ok(deltas.length >= 1, `Expected at least 1 delta, got ${deltas.length}`);
assert.equal(stops.length, 1, `Expected 1 stop, got ${stops.length}`);
const allContent = deltas.map(c => c.content).join('');
assert.equal(allContent, 'Hello world');
} finally {
codexResetSpawnImpl();
}
});
it('spawn with mock: first delta chunk has role=assistant', async () => {
const fakeSpawn = makeMockCodexSpawn(['{"content":"Test output"}']);
codexSetSpawnImpl(fakeSpawn);
try {
const ir = makeIR({
model: 'gpt-5.5',
stream: true,
messages: [{ role: 'user', content: 'Hello' }],
});
const authCtx = { accessToken: '<fake-openai-token>' };
const chunks = [];
for await (const chunk of codex.spawn(ir, authCtx)) {
chunks.push(chunk);
}
const firstDelta = chunks.find(c => c.type === 'delta');
assert.ok(firstDelta, 'No delta chunk found');
assert.equal(firstDelta.role, 'assistant');
} finally {
codexResetSpawnImpl();
}
});
it('spawn with mock: NDJSON stop event present → no extra synthetic stop appended', async () => {
// If NDJSON stream already contains a stop event, we should NOT double-emit
const fakeSpawn = makeMockCodexSpawn([
'{"content":"done"}',
'{"type":"stop"}',
]);
codexSetSpawnImpl(fakeSpawn);
try {
const ir = makeIR({
model: 'gpt-5.5',
stream: false,
messages: [{ role: 'user', content: 'Hello' }],
});
const authCtx = { accessToken: '<fake-openai-token>' };
const chunks = [];
for await (const chunk of codex.spawn(ir, authCtx)) {
chunks.push(chunk);
}
const stops = chunks.filter(c => c.type === 'stop');
assert.equal(stops.length, 1, `Expected exactly 1 stop chunk, got ${stops.length}`);
} finally {
codexResetSpawnImpl();
}
});
it('spawn with mock: non-zero exit code throws ProviderError(SPAWN_FAILED)', async () => {
const fakeSpawn = makeMockCodexSpawn([], 1);
codexSetSpawnImpl(fakeSpawn);
try {
const ir = makeIR({
model: 'gpt-5.5',
stream: true,
messages: [{ role: 'user', content: 'Hi' }],
});
const authCtx = { accessToken: '<fake-openai-token>' };
let caught = null;
try {
for await (const _chunk of codex.spawn(ir, authCtx)) {
// drain
}
} catch (e) {
caught = e;
}
assert.ok(caught instanceof ProviderError, `Expected ProviderError, got ${caught?.constructor?.name}`);
assert.equal(caught.code, 'SPAWN_FAILED');
} finally {
codexResetSpawnImpl();
}
});
it('spawn throws ProviderError(AUTH_MISSING) when no auth context and no auth file', async () => {
const fakeSpawn = makeMockCodexSpawn(['{"content":"test"}']);
codexSetSpawnImpl(fakeSpawn);
// Override auth path to a nonexistent file to guarantee missing auth
const savedAuthPath = process.env.OPENAI_CODEX_AUTH_PATH;
process.env.OPENAI_CODEX_AUTH_PATH = '/nonexistent/path/auth.json';
try {
const ir = makeIR({
model: 'gpt-5.5',
stream: false,
messages: [{ role: 'user', content: 'Hi' }],
});
let caught = null;
try {
for await (const _chunk of codex.spawn(ir, null)) { // eslint-disable-line no-unused-vars
// drain
}
} catch (e) {
caught = e;
}
assert.ok(caught instanceof ProviderError, `Expected ProviderError, got ${caught?.constructor?.name}`);
assert.equal(caught.code, 'AUTH_MISSING');
} finally {
if (savedAuthPath !== undefined) {
process.env.OPENAI_CODEX_AUTH_PATH = savedAuthPath;
} else {
delete process.env.OPENAI_CODEX_AUTH_PATH;
}
codexResetSpawnImpl();
}
});
// ── Test 9: healthCheck ───────────────────────────────────────────────
it('healthCheck returns {ok: false, error: "codex binary not found"} when binary absent', async () => {
const result = await codexHealthCheck({
_binaryExistsFn: () => false,
_authReadFn: () => ({ accessToken: '<fake-token>' }),
});
assert.equal(result.ok, false);
assert.equal(result.error, 'codex binary not found');
assert.ok(typeof result.latencyMs === 'number');
});
it('healthCheck returns {ok: false, error: "auth artifact missing"} when auth missing', async () => {
const result = await codexHealthCheck({
_binaryExistsFn: () => true,
_authReadFn: () => null,
});
assert.equal(result.ok, false);
assert.equal(result.error, 'auth artifact missing');
assert.ok(typeof result.latencyMs === 'number');
});
it('healthCheck returns {ok: true} when binary and auth both present', async () => {
const result = await codexHealthCheck({
_binaryExistsFn: () => true,
_authReadFn: () => ({ accessToken: '<fake-token>' }),
});
assert.equal(result.ok, true);
assert.ok(typeof result.latencyMs === 'number');
});
// ── Test 10: estimateCost ─────────────────────────────────────────────
it('estimateCost returns shape with currency USD', () => {
const request = makeIR({
model: 'gpt-5.5',
messages: [
{ role: 'system', content: 'You are a coding assistant.' },
{ role: 'user', content: 'Write hello world in Python.' },
],
});
const result = codexEstimateCost(request);
assert.ok(result !== null, 'estimateCost returned null');
assert.ok('inputTokens' in result, 'missing inputTokens');
assert.ok('outputTokensEstimate' in result, 'missing outputTokensEstimate');
assert.ok('currency' in result, 'missing currency');
assert.ok('usd' in result, 'missing usd');
assert.equal(result.currency, 'USD');
assert.equal(result.usd, null); // not pinned at D6
assert.ok(result.inputTokens > 0, 'inputTokens should be > 0');
assert.ok(result.outputTokensEstimate >= 0);
});
it('estimateCost returns null for null/missing request', () => {
assert.equal(codexEstimateCost(null), null);
assert.equal(codexEstimateCost({}), null);
});
// ── Test 11: quotaStatus ──────────────────────────────────────────────
it('quotaStatus returns null at D6', async () => {
const result = await codexQuotaStatus({});
assert.equal(result, null);
});
// ── Test 12: auth artifact path uses os.homedir(), not hardcoded ──────
it('codex.auth.path uses homedir() and references .codex directory', () => {
assert.equal(typeof codex.auth.path, 'string');
assert.ok(codex.auth.path.includes('.codex'), 'auth.path should reference .codex directory');
// Portability check: path must start with homedir() value at runtime
assert.ok(
codex.auth.path.startsWith(homedir()),
`auth.path "${codex.auth.path}" should start with homedir() "${homedir()}"`,
);
});
it('codex.auth.type === "oauth" and storage === "file"', () => {
assert.equal(codex.auth.type, 'oauth');
assert.equal(codex.auth.storage, 'file');
});
// ── Test 13: readAuthArtifact reads OPENAI_CODEX_AUTH_PATH override ──
it('readAuthArtifact: OPENAI_CODEX_AUTH_PATH pointing to nonexistent file → null', () => {
const saved = process.env.OPENAI_CODEX_AUTH_PATH;
process.env.OPENAI_CODEX_AUTH_PATH = '/definitely/not/a/real/path/auth.json';
try {
const result = codexReadAuthArtifact();
assert.equal(result, null);
} finally {
if (saved !== undefined) process.env.OPENAI_CODEX_AUTH_PATH = saved;
else delete process.env.OPENAI_CODEX_AUTH_PATH;
}
});
// ── Test 14: hints shape ──────────────────────────────────────────────
it('codex.hints has correct shape', () => {
assert.equal(typeof codex.hints.requiresTTY, 'boolean');
assert.equal(typeof codex.hints.concurrentSpawnSafe, 'boolean');
assert.ok(Number.isInteger(codex.hints.maxConcurrent) && codex.hints.maxConcurrent > 0);
assert.equal(codex.hints.requiresTTY, false);
assert.equal(codex.hints.concurrentSpawnSafe, true);
});
// ── Test 15: STATIC_REGISTRY length after D6 ─────────────────────────
it('STATIC_REGISTRY.length === 2 after D6 (anthropic + openai)', () => {
assert.equal(listAllProviderNames().length, 2);
assert.ok(listAllProviderNames().includes('anthropic'));
assert.ok(listAllProviderNames().includes('openai'));
});
// ── Test 16: loadProviders with openai enabled ────────────────────────
it('loadProviders with {enabled: {openai: true}} returns Map of size 1 with openai', () => {
const loaded = loadProviders({ enabled: { openai: true } });
assert.equal(loaded.size, 1);
assert.ok(loaded.has('openai'));
});
it('loadProviders with both anthropic and openai enabled returns Map of size 2', () => {
const loaded = loadProviders({ enabled: { anthropic: true, openai: true } });
assert.equal(loaded.size, 2);
assert.ok(loaded.has('anthropic'));
assert.ok(loaded.has('openai'));
});
it('openai loaded via loadProviders passes contract validation', () => {
const loaded = loadProviders({ enabled: { openai: true } });
const p = loaded.get('openai');
const { valid, errors } = validateProvider(p);
assert.equal(valid, true, `Contract errors: ${errors.join('; ')}`);
assert.ok(p.models.includes('gpt-5.5'));
});
// ── Test 17: spawn progress events silently ignored ──────────────────
it('spawn with mock: progress events are silently ignored, only content emitted', async () => {
const fakeSpawn = makeMockCodexSpawn([
'{"type":"progress","step":1}',
'{"type":"progress","step":2}',
'{"content":"actual response"}',
'{"type":"stop"}',
]);
codexSetSpawnImpl(fakeSpawn);
try {
const ir = makeIR({
model: 'gpt-5.5',
stream: true,
messages: [{ role: 'user', content: 'Do something' }],
});
const authCtx = { accessToken: '<fake-openai-token>' };
const chunks = [];
for await (const chunk of codex.spawn(ir, authCtx)) {
chunks.push(chunk);
}
const deltas = chunks.filter(c => c.type === 'delta');
assert.equal(deltas.length, 1);
assert.equal(deltas[0].content, 'actual response');
} finally {
codexResetSpawnImpl();
}
});
// ── Test 18: spawn synthesizes stop when NDJSON stream has no stop event ──
it('spawn with mock: synthetic stop emitted when NDJSON has no stop event', async () => {
const fakeSpawn = makeMockCodexSpawn([
'{"content":"only content, no stop"}',
// No stop event in the stream
]);
codexSetSpawnImpl(fakeSpawn);
try {
const ir = makeIR({
model: 'gpt-5.5',
stream: true,
messages: [{ role: 'user', content: 'Hello' }],
});
const authCtx = { accessToken: '<fake-openai-token>' };
const chunks = [];
for await (const chunk of codex.spawn(ir, authCtx)) {
chunks.push(chunk);
}
const stops = chunks.filter(c => c.type === 'stop');
assert.equal(stops.length, 1, 'Should have exactly 1 synthetic stop chunk');
assert.equal(stops[0].finish_reason, 'stop');
} finally {
codexResetSpawnImpl();
}
});
});
// ── Suite 10: Anthropic E2E (GATED) ────────────────────────────────────── // ── Suite 10: Anthropic E2E (GATED) ──────────────────────────────────────
// //
// Run with: OLP_RUN_E2E=1 npm test // Run with: OLP_RUN_E2E=1 npm test