diff --git a/lib/providers/codex.mjs b/lib/providers/codex.mjs new file mode 100644 index 0000000..e6857d4 --- /dev/null +++ b/lib/providers/codex.mjs @@ -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 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] ", assistant → "[Assistant] ", +// tool → "[Tool Result] ", 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: } +// 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: } +// 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 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', , ?] +// 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 +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; diff --git a/lib/providers/index.mjs b/lib/providers/index.mjs index 35bbdd9..81be43a 100644 --- a/lib/providers/index.mjs +++ b/lib/providers/index.mjs @@ -13,18 +13,24 @@ * as a Candidate. It is NOT Enabled by default — a config with * { enabled: { anthropic: true } } is required, which only happens after D5 * 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 anthropicDefault from './anthropic.mjs'; +import codexDefault from './codex.mjs'; // Normalize default export pattern const anthropic = anthropicDefault; +const codex = codexDefault; // ── Static registry ─────────────────────────────────────────────────────── const STATIC_REGISTRY = [ anthropic, + codex, ]; // ── Registry functions ──────────────────────────────────────────────────── @@ -90,7 +96,7 @@ export function getProviderByName(loadedProviders, name) { /** * Returns all provider names in the static registry (whether enabled or not). * Used by /health and diagnostics. - * At D4: returns ['anthropic'] (the candidate roster). + * At D4: returns ['anthropic']; at D6: returns ['anthropic', 'openai']. * * @returns {string[]} */ diff --git a/models-registry.json b/models-registry.json index de0e24d..1ea43ef 100644 --- a/models-registry.json +++ b/models-registry.json @@ -32,6 +32,55 @@ "opus": "claude-opus-4-7", "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 ` 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" + } } } } diff --git a/test-features.mjs b/test-features.mjs index 1096556..99fb47c 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -40,6 +40,16 @@ import anthropic, { __setSpawnImpl, __resetSpawnImpl, } 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' }; // ── Helpers ─────────────────────────────────────────────────────────────── @@ -478,9 +488,9 @@ describe('Provider contract validation', () => { // ── Suite 5: Plugin registry ────────────────────────────────────────────── describe('Plugin registry', () => { - it('STATIC_REGISTRY has 1 entry (anthropic candidate) at D4', () => { - // D4: anthropic is in STATIC_REGISTRY but default config has enabled:false - assert.equal(listAllProviderNames().length, 1); + it('STATIC_REGISTRY has 2 entries (anthropic + openai candidates) at D6', () => { + // D4 added anthropic; D6 adds openai. Default config has both enabled:false. + assert.equal(listAllProviderNames().length, 2); }); it('loadProviders with empty config → empty Map (anthropic not enabled)', () => { @@ -493,8 +503,9 @@ describe('Plugin registry', () => { assert.equal(m.size, 0); }); - it('listAllProviderNames returns [anthropic] at D4', () => { - assert.deepEqual(listAllProviderNames(), ['anthropic']); + it('listAllProviderNames returns [anthropic, openai] at D6', () => { + // D4: ['anthropic']. D6 adds openai. + assert.deepEqual(listAllProviderNames(), ['anthropic', 'openai']); }); 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 ` 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: '' }; + 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: '' }; + 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: '' }; + 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: '' }; + 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: '' }), + }); + 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: '' }), + }); + 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: '' }; + 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: '' }; + 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) ────────────────────────────────────── // // Run with: OLP_RUN_E2E=1 npm test