ai: migrate LLM backbone from Kimi CLI to Codex CLI
Retires the Moonshot/Kimi subscription in favour of the already-paid ChatGPT plan. Both CLI wrappers now run `codex exec`; the kimi-agent container is gone. adolf-llm + hindsight-llm: - runKimi -> runCodex (`codex exec --json --skip-git-repo-check`), resume via `codex exec resume <thread_id>`. - MCP moves from a per-session .mcp.json (a workaround for Kimi having no --mcp-config-file flag) to a $CODEX_HOME/config.toml generated once at startup from shared-mcp.json. Field translation is load-bearing: bearerTokenEnvVar -> bearer_token_env_var, enabledTools -> enabled_tools. - approval_policy="never" + sandbox_mode required, or unattended turns block on an approval prompt nobody can answer. kimi-agent removed. It was the ONLY large-tier deployment behind LiteLLM, so deleting it outright would have silently degraded every large-tier request to the local 4B model via the existing fallbacks. tier-large, the auto_router complex-reasoning route and their fallbacks now point at the codex-backed adolf-llm wrapper (model_name: codex-agent). Three environment blockers fixed along the way: - OpenAI geo-blocks this host (403 unsupported_country_region_territory). Both containers now egress via the host xray proxy, with NO_PROXY keeping MCP and *.alogins.net traffic off the tunnel. - node:22-slim ships no system CA store; the Rust codex binary validates TLS against it, so every HTTPS call failed with a generic transport error while Node's own fetch worked. ca-certificates added to both images. - `codex exec resume` rejects -C/--cd (plain `codex exec` accepts it), which broke follow-up turns while first turns succeeded. Known regression: Kimi's managed-usage API has no Codex equivalent, so the /usage route returns 501 and there is no quota probe for the codex model. The two quota plugins degrade quietly to no output. Also: stop tracking cognee.env (live LLM + JWT secrets) and gitignore it. The secrets remain in earlier history and should be rotated. Verified live: plain turn, SSE streaming, session resume, MCP tool call, bearer-token MCP call, and completions through both LiteLLM routes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Y5QPagv4iun1ghpwM96Ff
This commit is contained in:
24
ai/adolf-llm/Dockerfile
Normal file
24
ai/adolf-llm/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
FROM node:22-slim
|
||||
|
||||
# ca-certificates is REQUIRED, not optional: node:22-slim ships no system CA
|
||||
# store. Node bundles its own so JS fetch works, but the Codex CLI is a Rust
|
||||
# binary and validates TLS against the system store — without this every HTTPS
|
||||
# call (incl. `codex login`) dies after the TCP/proxy connect with a generic
|
||||
# "error sending request". Cost us a long debug; do not drop it.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npm install -g @openai/codex
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
COPY server.js /app/server.js
|
||||
|
||||
# Codex reads auth + config from $CODEX_HOME (default ~/.codex). Pinned
|
||||
# explicitly so the compose volume mount and the config writer agree.
|
||||
ENV CODEX_HOME=/root/.codex
|
||||
|
||||
EXPOSE 8010
|
||||
|
||||
ENTRYPOINT ["node", "/app/server.js"]
|
||||
716
ai/adolf-llm/server.js
Normal file
716
ai/adolf-llm/server.js
Normal file
@@ -0,0 +1,716 @@
|
||||
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:<chat_id>` parsed from OpenClaw's "Conversation info" block
|
||||
// (Gate 2). Fallback key: `hist:<sha256(prior history)>` 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 <id>` 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 Kimi-specific implementation (kb #62/#87) was removed with
|
||||
// the Codex migration: it authenticated against Kimi's managed-usage API using
|
||||
// the Kimi CLI's OAuth creds file, and neither the endpoint nor the credential
|
||||
// exists on this backend. Codex exposes no equivalent machine-readable quota
|
||||
// endpoint, so /usage now reports "unsupported" rather than inventing numbers.
|
||||
//
|
||||
// The two consumers (kimi-quota-footer-plugin, quota-command-openclaw-plugin)
|
||||
// both treat a non-OK /usage as "no data" and degrade quietly -- the footer is
|
||||
// simply omitted. They still need a decision: retire them, or repoint them at
|
||||
// whatever quota signal the Codex/ChatGPT plan actually exposes.
|
||||
const USAGE_UNSUPPORTED = {
|
||||
error: 'usage_unsupported',
|
||||
backend: 'codex',
|
||||
detail: 'Codex backend exposes no machine-readable quota endpoint.',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 === '/usage') {
|
||||
// 501 rather than 502: this is not a transient upstream failure, it is a
|
||||
// capability the codex backend does not have. Consumers already treat any
|
||||
// non-OK response as "no data" and omit the quota footer.
|
||||
res.writeHead(501, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(USAGE_UNSUPPORTED));
|
||||
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}`));
|
||||
26
ai/adolf-llm/service-block.yml
Normal file
26
ai/adolf-llm/service-block.yml
Normal file
@@ -0,0 +1,26 @@
|
||||
# adolf-llm — conversational Codex-CLI wrapper (P2, :8010). OpenAI-compatible,
|
||||
# model id "adolf". Real streaming, chat_id session mapping (1:1 `codex exec
|
||||
# resume`), media persistence, shared MCP via a generated $CODEX_HOME/config.toml.
|
||||
# Needs `codex login` credentials seeded into its own home volume.
|
||||
#
|
||||
# Migrated off Kimi CLI (2026-07-31) for cost: the Moonshot subscription is
|
||||
# replaced by the existing ChatGPT plan. NOTE the home volume changed from
|
||||
# adolf-llm-home (/root/.kimi-code) to adolf-llm-codex-home (/root/.codex) —
|
||||
# the old volume holds Kimi OAuth creds and can be dropped once this is verified.
|
||||
#
|
||||
# Orchestrator: merge this `adolf-llm` service + the two named volumes into
|
||||
# ai/docker-compose.yml (do NOT edit that file here).
|
||||
services:
|
||||
adolf-llm:
|
||||
build: ./adolf-llm
|
||||
container_name: adolf-llm
|
||||
ports:
|
||||
- "8010:8010"
|
||||
volumes:
|
||||
- adolf-llm-workspace:/workspace
|
||||
- adolf-llm-codex-home:/root/.codex
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
adolf-llm-workspace:
|
||||
adolf-llm-codex-home:
|
||||
Reference in New Issue
Block a user