| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import { spawn } from "node:child_process"; |
| import { logger } from "../lib/logger"; |
| import { withGateway } from "./gateway"; |
| import type { LlmAdapter, LlmStreamOptions, StreamChunk } from "./types"; |
|
|
| interface CodexCliConfig { |
| bin: string; |
| args: string[]; |
| cwd: string; |
| } |
|
|
| |
| |
| |
| |
| |
| class CodexCliError extends Error { |
| readonly providerErrorCode: string; |
| constructor(message: string, providerErrorCode: string) { |
| super(message); |
| this.name = "CodexCliError"; |
| this.providerErrorCode = providerErrorCode; |
| } |
| } |
|
|
| interface NodeSpawnError extends Error { |
| code?: string; |
| syscall?: string; |
| path?: string; |
| } |
|
|
| function mapSpawnError(err: NodeSpawnError, bin: string): CodexCliError { |
| const code = err.code; |
| if (code === "ENOENT") { |
| return new CodexCliError( |
| `DoAtlas Code helper binary not found at "${bin}". ` + |
| `Set CODEX_CLI_BIN to the path of an installed local code helper.`, |
| "codex_cli_not_found", |
| ); |
| } |
| if (code === "EACCES" || code === "EPERM") { |
| return new CodexCliError( |
| `Permission denied executing DoAtlas Code helper at "${bin}". ` + |
| `Make sure the file is executable (chmod +x).`, |
| "codex_cli_permission_denied", |
| ); |
| } |
| return new CodexCliError( |
| `Failed to launch DoAtlas Code helper at "${bin}": ${err.message}`, |
| "codex_cli_spawn_failed", |
| ); |
| } |
|
|
| function readConfig(): CodexCliConfig { |
| const bin = process.env["CODEX_CLI_BIN"]; |
| if (!bin) { |
| throw new Error( |
| "DoAtlas Code not configured: set CODEX_CLI_BIN to the local code helper binary", |
| ); |
| } |
| const rawArgs = (process.env["CODEX_CLI_ARGS"] || "").trim(); |
| const args = rawArgs ? rawArgs.split(/\s+/) : []; |
| const cwd = process.env["CODEX_CLI_CWD"] || process.cwd(); |
| return { bin, args, cwd }; |
| } |
|
|
| interface RawEvent { |
| type?: string; |
| text?: string; |
| id?: string; |
| name?: string; |
| arguments?: Record<string, unknown>; |
| input_tokens?: number; |
| output_tokens?: number; |
| stop_reason?: string; |
| message?: string; |
| code?: string; |
| } |
|
|
| function toChunk(ev: RawEvent): StreamChunk | null { |
| switch (ev.type) { |
| case "text_delta": |
| return { type: "text_delta", text: ev.text || "" }; |
| case "reasoning_delta": |
| return { type: "reasoning_delta", text: ev.text || "" }; |
| case "tool_call": |
| if (!ev.id || !ev.name) return null; |
| return { |
| type: "tool_call", |
| toolCallId: ev.id, |
| toolName: ev.name, |
| toolArguments: ev.arguments ?? {}, |
| }; |
| case "usage": |
| return { |
| type: "usage", |
| inputTokens: ev.input_tokens ?? 0, |
| outputTokens: ev.output_tokens ?? 0, |
| }; |
| case "done": |
| return { type: "done", stopReason: ev.stop_reason }; |
| case "error": |
| return { |
| type: "error", |
| message: ev.message || "codex-cli error", |
| providerErrorCode: ev.code, |
| }; |
| default: |
| return null; |
| } |
| } |
|
|
| async function* runCodexCli( |
| cfg: CodexCliConfig, |
| opts: LlmStreamOptions, |
| signal: AbortSignal, |
| ): AsyncIterable<StreamChunk> { |
| const child = spawn(cfg.bin, cfg.args, { |
| cwd: cfg.cwd, |
| stdio: ["pipe", "pipe", "pipe"], |
| env: process.env, |
| }); |
|
|
| let spawned = false; |
| child.once("spawn", () => { |
| spawned = true; |
| }); |
|
|
| |
| const onAbort = () => { |
| if (!child.killed) child.kill("SIGTERM"); |
| }; |
| if (signal.aborted) onAbort(); |
| else signal.addEventListener("abort", onAbort, { once: true }); |
|
|
| |
| |
| |
| |
| let stderrBuf = ""; |
| child.stderr.setEncoding("utf8"); |
| child.stderr.on("data", (d: string) => { |
| stderrBuf += d; |
| if (stderrBuf.length > 8192) { |
| stderrBuf = stderrBuf.slice(-8192); |
| } |
| }); |
|
|
| |
| type Item = { line?: string; end?: true; err?: CodexCliError }; |
| const queue: Item[] = []; |
| let waiter: ((it: Item) => void) | null = null; |
| const push = (it: Item) => { |
| if (waiter) { |
| const w = waiter; |
| waiter = null; |
| w(it); |
| } else { |
| queue.push(it); |
| } |
| }; |
| const next = (): Promise<Item> => |
| queue.length > 0 |
| ? Promise.resolve(queue.shift()!) |
| : new Promise<Item>((r) => { |
| waiter = r; |
| }); |
|
|
| |
| |
| |
| |
| let stdoutEnded = false; |
| let childClosed = false; |
| let pendingErr: CodexCliError | null = null; |
|
|
| const maybeFinish = () => { |
| if (!stdoutEnded || !childClosed) return; |
| if (pendingErr) push({ err: pendingErr }); |
| push({ end: true }); |
| }; |
|
|
| let buf = ""; |
| child.stdout.setEncoding("utf8"); |
| child.stdout.on("data", (d: string) => { |
| buf += d; |
| let idx: number; |
| while ((idx = buf.indexOf("\n")) >= 0) { |
| const line = buf.slice(0, idx).trim(); |
| buf = buf.slice(idx + 1); |
| if (line) push({ line }); |
| } |
| }); |
| child.stdout.on("end", () => { |
| const tail = buf.trim(); |
| buf = ""; |
| if (tail) push({ line: tail }); |
| stdoutEnded = true; |
| maybeFinish(); |
| }); |
| child.on("error", (err) => { |
| pendingErr = mapSpawnError(err as NodeSpawnError, cfg.bin); |
| stdoutEnded = true; |
| childClosed = true; |
| maybeFinish(); |
| }); |
| child.on("close", (code, sigSignal) => { |
| if (!pendingErr && code != null && code !== 0) { |
| const tail = stderrBuf.trim().slice(-500); |
| const detail = tail ? `: ${tail}` : ""; |
| pendingErr = new CodexCliError( |
| `DoAtlas Code helper exited with code ${code}${detail}`, |
| spawned ? "codex_cli_exited" : "codex_cli_spawn_failed", |
| ); |
| } else if (!pendingErr && code == null && sigSignal && sigSignal !== "SIGTERM") { |
| const tail = stderrBuf.trim().slice(-500); |
| const detail = tail ? `: ${tail}` : ""; |
| pendingErr = new CodexCliError( |
| `DoAtlas Code helper terminated by signal ${sigSignal}${detail}`, |
| "codex_cli_exited", |
| ); |
| } |
| childClosed = true; |
| maybeFinish(); |
| }); |
|
|
| |
| const payload = JSON.stringify({ |
| model: opts.model, |
| messages: opts.messages, |
| tools: opts.tools ?? [], |
| temperature: opts.temperature, |
| max_tokens: opts.maxTokens, |
| }); |
| |
| |
| |
| child.stdin.on("error", () => { |
| |
| }); |
| try { |
| child.stdin.write(payload + "\n"); |
| child.stdin.end(); |
| } catch { |
| |
| } |
|
|
| let sawDone = false; |
| let sawUsage = false; |
| try { |
| while (true) { |
| const it = await next(); |
| if (it.err) throw it.err; |
| if (it.end) break; |
| if (!it.line) continue; |
| let ev: RawEvent; |
| try { |
| ev = JSON.parse(it.line) as RawEvent; |
| } catch { |
| |
| |
| |
| yield { type: "text_delta", text: it.line }; |
| continue; |
| } |
| const chunk = toChunk(ev); |
| if (!chunk) continue; |
| if (chunk.type === "usage") sawUsage = true; |
| if (chunk.type === "done") sawDone = true; |
| yield chunk; |
| } |
| } finally { |
| if (!child.killed) child.kill("SIGTERM"); |
| } |
|
|
| if (stderrBuf.trim()) { |
| logger.warn( |
| { adapter: "codex-cli", stderr: stderrBuf.trim().slice(0, 1000) }, |
| "codex-cli stderr", |
| ); |
| } |
| if (!sawUsage) { |
| yield { type: "usage", inputTokens: 0, outputTokens: 0 }; |
| } |
| if (!sawDone) { |
| yield { type: "done", stopReason: "end_turn" }; |
| } |
| } |
|
|
| export const codexCliAdapter: LlmAdapter = { |
| name: "codex-cli", |
| stream(opts: LlmStreamOptions): AsyncIterable<StreamChunk> { |
| const cfg = readConfig(); |
| return withGateway( |
| { name: "codex-cli", source: "byok-direct", upstream: cfg.bin, retries: 0 }, |
| (signal) => runCodexCli(cfg, opts, signal), |
| ); |
| }, |
| }; |
|
|