const http = require('http'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { spawn } = require('child_process'); // Both overridable purely so the wrapper can be exercised outside the // container (the defaults are the in-container values). const PORT = Number(process.env.PORT) || 8010; const SHARED_MCP_PATH = process.env.SHARED_MCP_PATH || '/shared-mcp.json'; const MODEL_ID = 'adolf'; const TIMEOUT_MS = 15 * 60 * 1000; const WORKSPACE = process.env.WORKSPACE || '/workspace'; const CONV_ROOT = path.join(WORKSPACE, 'conversations'); const STATE_DIR = path.join(WORKSPACE, '.adolf-llm'); const MAP_FILE = path.join(STATE_DIR, 'sessions.json'); const MAX_ENTRIES = 1000; // prune oldest beyond this fs.mkdirSync(CONV_ROOT, { recursive: true }); fs.mkdirSync(STATE_DIR, { recursive: true }); // --------------------------------------------------------------------------- // Shared MCP layer. Unlike Kimi Code CLI (which had no --mcp-config-file flag // and forced a per-session `.mcp.json` dropped into each working directory), // Codex reads MCP servers from `$CODEX_HOME/config.toml` under `[mcp_servers.*]` // tables. So this is now written ONCE at startup instead of per session. // // Single source of truth is unchanged: `/shared-mcp.json` (mounted read-only // from the repo root's `shared-mcp.json`, the same file OpenClaw's own // `mcp.servers` registry uses). Adding a server stays a one-file change. // // Transport mapping. shared-mcp.json entries are either: // { command, args, env } -> stdio server // { url, type: "http" } -> remote streamable-http server // Codex expresses stdio servers as `command`/`args`/`env`, and remote servers // as `url` with an optional `bearer_token_env_var`. It has no `type` key; the // shape (command vs url) selects the transport, same inference Kimi did. The // `type: "http"` key that shared-mcp.json carries for OpenClaw's benefit is // simply not emitted here. const CODEX_HOME = process.env.CODEX_HOME || '/root/.codex'; let SHARED_MCP_SERVERS = {}; try { const raw = fs.readFileSync(SHARED_MCP_PATH, 'utf8'); SHARED_MCP_SERVERS = JSON.parse(raw).mcpServers || {}; } catch (err) { console.error(`shared-mcp.json not loaded (${err.message}); sessions will get no shared MCP servers`); } // Minimal TOML emitter — we only ever emit strings, string arrays and flat // string maps, so a full TOML library would be dead weight. function tomlString(s) { return JSON.stringify(String(s)); // TOML basic strings share JSON escaping } function tomlValue(v) { if (Array.isArray(v)) return `[${v.map(tomlString).join(', ')}]`; return tomlString(v); } function renderMcpToml(servers) { const lines = [ '# GENERATED by adolf-llm from /shared-mcp.json — do not edit by hand.', '# Regenerated on every container start; manual edits are lost.', '', ]; for (const [name, cfg] of Object.entries(servers)) { lines.push(`[mcp_servers.${name}]`); if (cfg.command) { lines.push(`command = ${tomlValue(cfg.command)}`); if (cfg.args && cfg.args.length) lines.push(`args = ${tomlValue(cfg.args)}`); } else if (cfg.url) { lines.push(`url = ${tomlValue(cfg.url)}`); } else { console.error(`shared-mcp.json: server "${name}" has neither command nor url; skipped`); lines.pop(); continue; } // Field-name translation, Kimi -> Codex. shared-mcp.json is written in // Kimi/OpenClaw's camelCase dialect; Codex's RawMcpServerConfig uses // snake_case. Both keys are load-bearing: // bearerTokenEnvVar -> bearer_token_env_var (agap + marketplace auth; // without it every tool call on those servers returns HTTP 401) // enabledTools -> enabled_tools (the capability allow-list // that ai/agent-registry.yaml's mcp_tool_filter is validated against; // dropping it would silently widen Adolf's tool access) if (cfg.bearerTokenEnvVar) { lines.push(`bearer_token_env_var = ${tomlValue(cfg.bearerTokenEnvVar)}`); } if (cfg.enabledTools && cfg.enabledTools.length) { lines.push(`enabled_tools = ${tomlValue(cfg.enabledTools)}`); } if (cfg.env && Object.keys(cfg.env).length) { lines.push(`[mcp_servers.${name}.env]`); for (const [k, v] of Object.entries(cfg.env)) lines.push(`${k} = ${tomlValue(v)}`); } lines.push(''); } return lines.join('\n'); } // Write the Codex config once at startup: MCP servers + the headless-operation // settings. `approval_policy = "never"` and `sandbox_mode` are load-bearing — // Codex defaults to asking for approval before running a tool, and nobody is // there to answer, so without these a turn hangs until the 15-minute timeout // instead of failing loudly. function writeCodexConfig() { fs.mkdirSync(CODEX_HOME, { recursive: true }); const header = [ 'approval_policy = "never"', 'sandbox_mode = "danger-full-access"', '', ].join('\n'); fs.writeFileSync(path.join(CODEX_HOME, 'config.toml'), header + renderMcpToml(SHARED_MCP_SERVERS)); } writeCodexConfig(); // --------------------------------------------------------------------------- // Memory lives at the OpenClaw layer, not here (P8). The Adolf gateway loads // the `cognee-memory` OpenClaw plugin, which owns all memory touchpoints: // - before_prompt_build => LLM-free cognee graph recall, injected into the // prompt this wrapper then receives from OpenClaw. // - agent_end => raw `add` of the turn to cognee. // - background sweep => async `cognify` on cognee-llm (Kimi). // - cognee_recall tool + cognee-mcp `recall` for on-demand deep queries. // This wrapper is therefore a dumb model endpoint again: it must never call // cognee itself. The former cogneeSearch/cogneeAdd stubs (and their call sites) // were deleted when the plugin took over (P8). // --------------------------------------------------------------------------- // Persistent conversation -> Codex session (thread) map. // Primary key: `chat:` parsed from OpenClaw's "Conversation info" block // (Gate 2). Fallback key: `hist:` when no chat_id is // present (e.g. webchat surface / future OpenClaw layout change). // value = { convId, sessionId, dir, ts } let sessionMap = {}; try { sessionMap = JSON.parse(fs.readFileSync(MAP_FILE, 'utf8')); } catch { sessionMap = {}; } let writeQueue = Promise.resolve(); function persistMap() { // prune to the MAX_ENTRIES most-recently-used before writing const keys = Object.keys(sessionMap); if (keys.length > MAX_ENTRIES) { keys.sort((a, b) => (sessionMap[a].ts || 0) - (sessionMap[b].ts || 0)); for (const k of keys.slice(0, keys.length - MAX_ENTRIES)) delete sessionMap[k]; } const snapshot = JSON.stringify(sessionMap); writeQueue = writeQueue.then( () => fs.promises.writeFile(MAP_FILE, snapshot), () => fs.promises.writeFile(MAP_FILE, snapshot), ); return writeQueue; } // --------------------------------------------------------------------------- // Message helpers. function textOf(msg) { const c = msg.content; if (Array.isArray(c)) return c.map(p => (typeof p === 'string' ? p : p.text || '')).join('\n'); return c == null ? '' : String(c); } // only user/assistant turns define conversation identity (system is constant) function convTurns(messages) { return messages.filter(m => m.role === 'user' || m.role === 'assistant'); } function historyKey(turns) { const norm = turns.map(m => ({ role: m.role, text: textOf(m).trim() })); return crypto.createHash('sha256').update(JSON.stringify(norm)).digest('hex'); } function renderTranscript(turns) { return turns .map(m => `${m.role === 'assistant' ? 'Assistant' : 'User'}: ${textOf(m)}`) .join('\n\n'); } // --------------------------------------------------------------------------- // Gate 2 — parse the stable chat_id out of OpenClaw's untrusted-metadata block. // OpenClaw injects, into the *user-role* content, a block that looks like: // Conversation info (untrusted metadata): // ```json // { "chat_id": "matrix:!room:server", "message_id": "...", ... } // ``` // We grep on the label string (never a fixed line offset) then pull the first // balanced JSON object after it and read chat_id. Robust to edited/truncated // history, which is exactly why it beats a history hash for the common case. const CONV_INFO_LABEL = 'Conversation info (untrusted metadata):'; function extractBalancedJson(str, from) { const start = str.indexOf('{', from); if (start === -1) return null; let depth = 0; let inStr = false; let esc = false; for (let i = start; i < str.length; i++) { const ch = str[i]; if (inStr) { if (esc) esc = false; else if (ch === '\\') esc = true; else if (ch === '"') inStr = false; continue; } if (ch === '"') inStr = true; else if (ch === '{') depth++; else if (ch === '}') { depth--; if (depth === 0) return str.slice(start, i + 1); } } return null; } function extractChatId(userMsg) { const text = textOf(userMsg); const at = text.indexOf(CONV_INFO_LABEL); if (at === -1) return null; const jsonStr = extractBalancedJson(text, at + CONV_INFO_LABEL.length); if (!jsonStr) return null; try { const obj = JSON.parse(jsonStr); const id = obj.chat_id; return typeof id === 'string' && id ? id : null; } catch { return null; } } // --------------------------------------------------------------------------- // Gate 3 — media. Persist inbound image parts into the session dir and return // relative path references; the CLI autonomously calls its built-in // ReadMediaFile tool on referenced paths (no flag/placeholder syntax needed). const MIME_EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif', 'image/bmp': 'bmp', 'image/heic': 'heic', 'image/heif': 'heif', }; function extFromMime(mime) { return MIME_EXT[(mime || '').toLowerCase()] || 'img'; } // Persist one image_url part; returns "./img_N.ext" or null if it couldn't. async function persistImage(url, dir, n) { if (typeof url !== 'string' || !url) return null; if (url.startsWith('data:')) { const m = /^data:([^;,]+)?(;base64)?,(.*)$/s.exec(url); if (!m) return null; const mime = m[1] || 'application/octet-stream'; const isB64 = !!m[2]; const ext = extFromMime(mime); const name = `img_${n}.${ext}`; const buf = isB64 ? Buffer.from(m[3], 'base64') : Buffer.from(decodeURIComponent(m[3]), 'utf8'); fs.writeFileSync(path.join(dir, name), buf); return `./${name}`; } // Remote URL: best-effort fetch so the CLI gets a local path to ReadMediaFile. // On any failure fall back to handing the raw URL to the model as text. if (/^https?:\/\//i.test(url)) { try { const resp = await fetch(url); if (!resp.ok) return url; const mime = resp.headers.get('content-type') || ''; const ext = extFromMime(mime.split(';')[0].trim()); const name = `img_${n}.${ext}`; const buf = Buffer.from(await resp.arrayBuffer()); fs.writeFileSync(path.join(dir, name), buf); return `./${name}`; } catch { return url; } } return null; } // Build the prompt for the current user turn: join text parts, persist any // image parts, append path references. async function buildPrompt(userMsg, dir) { const content = userMsg.content; const textParts = []; const imageRefs = []; if (Array.isArray(content)) { let n = 0; for (const part of content) { if (typeof part === 'string') { textParts.push(part); } else if (part && (part.type === 'text' || typeof part.text === 'string')) { textParts.push(part.text || ''); } else if (part && part.type === 'image_url' && part.image_url && part.image_url.url) { n++; const ref = await persistImage(part.image_url.url, dir, n); if (ref) imageRefs.push(ref); } else if (part && part.type === 'input_image' && (part.image_url || part.url)) { n++; const u = typeof part.image_url === 'string' ? part.image_url : part.url; const ref = await persistImage(u, dir, n); if (ref) imageRefs.push(ref); } } } else { textParts.push(content == null ? '' : String(content)); } let prompt = textParts.join('\n'); if (imageRefs.length) { prompt += '\n\n' + imageRefs.map(r => `See attached image: ${r}`).join('\n'); } return prompt; } // --------------------------------------------------------------------------- // Codex invocation with REAL streaming. Parses `codex exec --json` incrementally: // each complete stdout line is one JSON event. // // Codex 0.146 ships TWO event schemas and which one `--json` emits can change // between releases, so we handle both rather than pinning to one: // // legacy "msg" schema: // {"id":..,"msg":{"type":"agent_message_delta","delta":"..."}} -> delta // {"id":..,"msg":{"type":"agent_message","message":"..."}} -> full text // {"id":..,"msg":{"type":"session_configured","session_id":".."}}-> session id // newer thread/turn/item schema: // {"type":"thread.started","thread_id":"..."} -> session id // {"type":"item.completed","item":{"type":"agent_message",...}} -> full text // // Non-assistant items (reasoning, command execution, MCP tool calls) are // deliberately ignored — Adolf's users see the answer, not the agent's work. // // Sequencing note: when a run emits streaming deltas AND a terminal full-text // message, the full text is the same content already streamed. We therefore // prefer deltas when any arrived, and fall back to the terminal message only // when none did — otherwise the reply would be duplicated. // // onDelta(chunk) is called per assistant content fragment as it arrives. // Resolves { text, sessionId } once the process closes. function runCodex({ prompt, cwd, resumeId, onDelta, signal }) { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(new Error('aborted before start')); return; } // `exec resume ` must come before the prompt; `--skip-git-repo-check` // is required because session dirs under /workspace are not git repos. // // `-C/--cd` is accepted by `codex exec` but NOT by `codex exec resume` — // passing it there fails with "unexpected argument '-C' found" and breaks // every follow-up turn while first turns still work. The spawn cwd below // already puts the process in the right directory, so -C is only an // explicit belt-and-braces on the fresh-session path. const args = ['exec']; if (resumeId) { args.push('resume', resumeId, '--json', '--skip-git-repo-check', prompt); } else { args.push('--json', '--skip-git-repo-check', '-C', cwd, prompt); } // stdin MUST be 'ignore'. With the default 'pipe', codex prints "Reading // additional input from stdin..." and blocks waiting for EOF on a pipe this // wrapper never writes to or closes — every turn would hang until the // 15-minute timeout. (Kimi's CLI did not read stdin, so this is new.) const child = spawn('codex', args, { cwd, timeout: TIMEOUT_MS, stdio: ['ignore', 'pipe', 'pipe'], }); let buf = ''; let stderr = ''; const parts = []; // streamed deltas, in order let finalText = null; // terminal full-text message, if the run emits one let errText = null; // structured error reported on the event stream let sessionId = null; let settled = false; let aborted = false; // If the caller aborts (the gateway/client disconnected — e.g. its idle // watchdog gave up), kill the child so it doesn't keep grinding an // orphaned agent turn to completion, wasting Codex quota and streaming into // a dead socket. SIGTERM first, hard SIGKILL if it lingers. const onAbort = () => { aborted = true; try { child.kill('SIGTERM'); } catch {} setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 3000).unref(); }; if (signal) signal.addEventListener('abort', onAbort, { once: true }); function handleLine(line) { const t = line.trim(); if (!t) return; let obj; try { obj = JSON.parse(t); } catch { return; } // --- legacy "msg" schema ------------------------------------------- const msg = obj.msg; if (msg && typeof msg.type === 'string') { if (msg.type === 'agent_message_delta' && typeof msg.delta === 'string' && msg.delta) { parts.push(msg.delta); if (onDelta) onDelta(msg.delta); } else if (msg.type === 'agent_message' && typeof msg.message === 'string' && msg.message) { finalText = msg.message; } else if (msg.type === 'session_configured' && msg.session_id) { sessionId = msg.session_id; } else if (msg.type === 'error' && msg.message) { errText = msg.message; } return; } // --- newer thread/turn/item schema ---------------------------------- if (obj.type === 'thread.started' && obj.thread_id) { sessionId = obj.thread_id; } else if (obj.type === 'item.completed' && obj.item) { const item = obj.item; if (item.type === 'agent_message') { const text = typeof item.text === 'string' ? item.text : item.message; if (typeof text === 'string' && text) finalText = text; } } else if (obj.type === 'turn.failed') { errText = (obj.error && (obj.error.message || obj.error)) || 'turn.failed'; } } child.stdout.on('data', d => { buf += d; let nl; while ((nl = buf.indexOf('\n')) !== -1) { const line = buf.slice(0, nl); buf = buf.slice(nl + 1); handleLine(line); } }); child.stderr.on('data', d => { stderr += d; }); child.on('error', err => { if (settled) return; settled = true; if (signal) signal.removeEventListener('abort', onAbort); reject(err); }); child.on('close', code => { if (settled) return; settled = true; if (signal) signal.removeEventListener('abort', onAbort); if (buf) handleLine(buf); // flush any trailing partial line // Deltas win when present — the terminal agent_message repeats content // already streamed to the client. Only fall back to it if nothing streamed. const streamed = parts.join('').trim(); const text = streamed || (finalText || '').trim(); // A non-streaming run still has to reach the client: emit the terminal // message as one delta so callers relying on onDelta aren't left empty. if (!streamed && text && onDelta) onDelta(text); if (aborted) { reject(new Error('aborted: client disconnected')); } else if (!text && code !== 0) { reject(new Error(`codex exited ${code}: ${(errText || stderr).slice(0, 2000)}`)); } else if (!text && errText) { reject(new Error(`codex error: ${errText.slice(0, 2000)}`)); } else { resolve({ text, sessionId }); } }); }); } // --------------------------------------------------------------------------- // One turn: resolve session (chat_id primary, history-hash fallback), persist // media, run codex (streaming through onDelta), record the mapping, // and fire the async cognee ingest. Returns { text }. async function handleTurn(messages, onDelta, signal) { const turns = convTurns(messages); let lastUserIdx = -1; for (let i = turns.length - 1; i >= 0; i--) { if (turns[i].role === 'user') { lastUserIdx = i; break; } } if (lastUserIdx === -1) throw new Error('no user message found'); const userMsg = turns[lastUserIdx]; const prior = turns.slice(0, lastUserIdx); const chatId = extractChatId(userMsg); // Resolve the session key + working dir + resume id. let key; let convId; let dir; let resumeId = null; let reseed = false; // when true, prompt with the full transcript to rebuild continuity if (chatId) { key = `chat:${chatId}`; const entry = sessionMap[key]; if (entry) { convId = entry.convId; dir = entry.dir; resumeId = entry.sessionId; } else { convId = crypto.randomUUID(); dir = path.join(CONV_ROOT, convId); // First time we see this chat_id but history exists (server restart / lost // map): reseed the fresh session with the transcript so context survives. reseed = prior.length > 0; } } else { // Fallback: no chat_id -> forward history-hash mapping. if (prior.length === 0) { convId = crypto.randomUUID(); dir = path.join(CONV_ROOT, convId); } else { const entry = sessionMap[`hist:${historyKey(prior)}`]; if (entry) { convId = entry.convId; dir = entry.dir; resumeId = entry.sessionId; } else { convId = crypto.randomUUID(); dir = path.join(CONV_ROOT, convId); reseed = true; } } } fs.mkdirSync(dir, { recursive: true }); let prompt; if (reseed) { // Rebuild the whole conversation for a fresh session, plus current media. const base = renderTranscript(turns.slice(0, lastUserIdx + 1)); const media = await buildPrompt(userMsg, dir); // buildPrompt already includes the current user text; for reseed we want the // transcript to carry it, so only append image refs. prompt = base; const extra = media.replace(textOf(userMsg), '').trim(); if (extra) prompt += `\n\n${extra}`; } else { prompt = await buildPrompt(userMsg, dir); } const { text, sessionId } = await runCodex({ prompt, cwd: dir, resumeId, onDelta, signal }); // Record the forward mapping. const entry = { convId, sessionId: sessionId || resumeId, dir, ts: Date.now() }; if (chatId) { sessionMap[`chat:${chatId}`] = entry; } else { const forward = turns.slice(0, lastUserIdx + 1).concat([{ role: 'assistant', content: text }]); sessionMap[`hist:${historyKey(forward)}`] = entry; } persistMap(); return { text }; } // --------------------------------------------------------------------------- // Quota readout (the Codex-era replacement for the Kimi /usages implementation // removed in the migration; kb #62/#87 for the original). // // Source: `codex app-server`, an experimental JSON-RPC-over-stdio surface the // CLI ships. Method `account/rateLimits/read` returns the same snapshot the // interactive TUI shows. Handshake is: `initialize` request, then an // `initialized` NOTIFICATION (the read returns nothing without it), then the // read. Shape as of codex-cli 0.146.0: // // { rateLimits: { planType, primary: { usedPercent, windowDurationMins, // resetsAt /* unix seconds */ }, secondary: {…}|null } } // // `primary` is the long window (windowDurationMins 43200 = 30d on the current // plan); `secondary`, when present, is the shorter burst window. Both are // normalised below to the same {pct, window_mins, window_label, resets} rows // so a consumer never has to know which is which. // // Cost: this spawns a codex process (~1-2s) and does a network round trip, so // results are cached in memory + on the workspace volume, exactly as the Kimi // implementation did. On failure we serve the last good reading tagged // `stale` with `as_of`/`age_s`, so a caller can decide if it is fresh enough // to gate on rather than being handed nothing. const USAGE_CACHE_PATH = '/workspace/.adolf-llm/usage-cache.json'; const USAGE_TTL_MS = 5 * 60 * 1000; // don't spawn codex more than once per 5min const USAGE_PROBE_TIMEOUT_MS = 45000; let usageCache = null; // { payload, cached_at } let usageInFlight = null; // de-dupe concurrent probes function readUsageCache() { if (usageCache) return usageCache; try { const parsed = JSON.parse(fs.readFileSync(USAGE_CACHE_PATH, 'utf8')); if (parsed && parsed.payload && parsed.cached_at) usageCache = parsed; } catch {} return usageCache; } function writeUsageCache(payload) { usageCache = { payload, cached_at: new Date().toISOString() }; try { fs.mkdirSync(path.dirname(USAGE_CACHE_PATH), { recursive: true }); fs.writeFileSync(USAGE_CACHE_PATH, JSON.stringify(usageCache)); } catch {} } // Minutes -> a short human label ("5h", "7d", "30d") for display. function windowLabel(mins) { if (!mins || mins <= 0) return null; if (mins % 1440 === 0) return `${mins / 1440}d`; if (mins % 60 === 0) return `${mins / 60}h`; return `${mins}m`; } function usageRow(raw) { if (!raw || typeof raw.usedPercent !== 'number') return null; return { pct: Math.round(raw.usedPercent), window_mins: raw.windowDurationMins ?? null, window_label: windowLabel(raw.windowDurationMins), resets: raw.resetsAt ? new Date(raw.resetsAt * 1000).toISOString() : null, }; } // Drive `codex app-server` for one rateLimits read. Resolves the raw result. function probeRateLimits() { return new Promise((resolve, reject) => { const child = spawn('codex', ['app-server'], { stdio: ['pipe', 'pipe', 'pipe'], timeout: USAGE_PROBE_TIMEOUT_MS, }); let buf = ''; let stderr = ''; let settled = false; const done = (err, val) => { if (settled) return; settled = true; try { child.kill('SIGTERM'); } catch {} err ? reject(err) : resolve(val); }; child.stdout.on('data', d => { buf += d; let nl; while ((nl = buf.indexOf('\n')) !== -1) { const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1); if (!line) continue; let obj; try { obj = JSON.parse(line); } catch { continue; } if (obj.id === 1 && obj.result) { // Handshake accepted -> `initialized` notification, then the read. child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'initialized', params: {} }) + '\n'); child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'account/rateLimits/read', params: {} }) + '\n'); } else if (obj.id === 2) { if (obj.error) done(new Error(`rateLimits/read: ${obj.error.message || JSON.stringify(obj.error)}`)); else done(null, obj.result); } } }); child.stderr.on('data', d => { stderr += d; }); child.on('error', err => done(err)); child.on('close', code => done(new Error(`codex app-server exited ${code}: ${stderr.slice(0, 500)}`))); child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { clientInfo: { name: 'adolf-llm', version: '1' } }, }) + '\n'); }); } function normalizeUsage(result) { const rl = (result && result.rateLimits) || {}; const primary = usageRow(rl.primary); const secondary = usageRow(rl.secondary); // Highest utilisation across the live windows — the number a gate should read // without caring which window is the binding one. const pcts = [primary, secondary].filter(Boolean).map(r => r.pct); return { backend: 'codex', plan: rl.planType ?? null, pct: pcts.length ? Math.max(...pcts) : null, primary, secondary, limit_reached: Boolean(rl.rateLimitReachedType) || Boolean(rl.spendControlReached), }; } // --------------------------------------------------------------------------- // OpenAI-compatible HTTP surface. function completionBody(text) { return { id: `chatcmpl-${Date.now()}`, object: 'chat.completion', created: Math.floor(Date.now() / 1000), model: MODEL_ID, choices: [{ index: 0, message: { role: 'assistant', content: text }, finish_reason: 'stop', }], }; } function sseChunk(id, created, delta, finishReason) { return `data: ${JSON.stringify({ id, object: 'chat.completion.chunk', created, model: MODEL_ID, choices: [{ index: 0, delta, finish_reason: finishReason }], })}\n\n`; } const server = http.createServer((req, res) => { if (req.method === 'GET' && req.url === '/v1/models') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ object: 'list', data: [{ id: MODEL_ID, object: 'model', owned_by: 'openai' }], })); return; } if (req.method === 'GET' && req.url.split('?')[0] === '/usage') { (async () => { const force = /[?&]force=1/.test(req.url); const cached = readUsageCache(); const ageMs = cached ? Date.now() - Date.parse(cached.cached_at) : Infinity; // Serve a warm cache rather than spawning codex on every request — the // footer plugin polls this on a timer. if (!force && cached && ageMs < USAGE_TTL_MS) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ...cached.payload, stale: false, as_of: cached.cached_at, age_s: Math.round(ageMs / 1000), })); return; } try { // De-dupe: concurrent callers share one probe instead of each spawning. if (!usageInFlight) { usageInFlight = probeRateLimits().finally(() => { usageInFlight = null; }); } const out = normalizeUsage(await usageInFlight); writeUsageCache(out); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ...out, stale: false, as_of: usageCache.cached_at, age_s: 0 })); } catch (err) { // Serve the last good reading, clearly labelled, rather than nothing. const prev = readUsageCache(); if (prev) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ...prev.payload, stale: true, as_of: prev.cached_at, age_s: Math.max(0, Math.round((Date.now() - Date.parse(prev.cached_at)) / 1000)), stale_reason: String(err.message || err), })); return; } res.writeHead(502, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: String(err.message || err), backend: 'codex' })); } })(); return; } if (req.method === 'POST' && req.url === '/v1/chat/completions') { let body = ''; req.on('data', d => { body += d; }); req.on('end', async () => { let parsed; try { parsed = JSON.parse(body); } catch { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'invalid JSON body' })); return; } const messages = parsed.messages || []; if (parsed.stream) { // Real streaming: open SSE, emit role chunk, then forward codex deltas. res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', }); const id = `chatcmpl-${Date.now()}`; const created = Math.floor(Date.now() / 1000); // Heartbeat keepalive: OpenClaw's LLM idle watchdog aborts a turn on // any >120s gap between SSE stream events (default timeoutSeconds), // not on total run length. During long thinking/tool/MCP phases Codex // emits stream-json events we don't forward, so the SSE stream can go // silent well past that window. Every write resets `lastWrite`; a 5s // ticker emits an empty-content delta once 25s of silence elapses — // still a stream event (resets the watchdog) but appends nothing // visible to the rendered reply or cognee-persisted text. let lastWrite = Date.now(); const write = (delta, finish) => { if (res.writableEnded || res.destroyed) return; res.write(sseChunk(id, created, delta, finish)); lastWrite = Date.now(); }; write({ role: 'assistant' }, null); const hb = setInterval(() => { if (Date.now() - lastWrite >= 25_000) write({ content: '' }, null); }, 5_000); // Propagate a client/gateway disconnect down to the codex child so an // abandoned turn (e.g. OpenClaw's idle watchdog gave up) is killed // instead of finishing invisibly and burning quota. `done` guards // against the normal res.end() 'close' also aborting. const ac = new AbortController(); let done = false; res.on('close', () => { if (!done) ac.abort(); }); try { await handleTurn(messages, delta => write({ content: delta }, null), ac.signal); done = true; write({}, 'stop'); res.write('data: [DONE]\n\n'); } catch (err) { done = true; // Headers already sent — surface the error inside the stream (unless // the socket is already gone, in which case there is nowhere to write). if (!res.writableEnded && !res.destroyed) { write({ content: `\n[error: ${String(err.message || err)}]` }, 'stop'); res.write('data: [DONE]\n\n'); } } finally { clearInterval(hb); if (!res.writableEnded) res.end(); } return; } const ac = new AbortController(); let done = false; res.on('close', () => { if (!done) ac.abort(); }); try { const { text } = await handleTurn(messages, null, ac.signal); done = true; res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(completionBody(text))); } catch (err) { done = true; if (!res.writableEnded && !res.destroyed) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: String(err.message || err) })); } } }); return; } res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'not found' })); }); server.listen(PORT, () => console.log(`adolf-llm wrapper listening on :${PORT}`));