import { buildClaudeCodeFallbackPrompt, buildClaudeCodeSystemPrompt } from "./llmClaudeCode.ts"; import { buildCodexCliBrainArgs, buildCodexCliTextArgs, createCodexCliOutputSchemaFile, createCodexCliStreamSession, type CodexCliStreamSessionLike, normalizeCodexCliError, parseCodexCliJsonlOutput, runCodexCli } from "./llmCodexCli.ts"; import type { ChatModelRequest, LlmTrace, UsageMetrics } from "./serviceShared.ts";
const CODEX_CLI_TIMEOUT_MS = 30_000; const CODEX_CLI_MAX_BUFFER_BYTES = 1024 * 1024;
function buildCodexCliTurnPreamble({
systemPrompt,
trace = {}
}: {
systemPrompt: string;
trace?: LlmTrace;
}) {
const normalizedSystemPrompt = String(systemPrompt || "").trim();
const scope = [
guild:${trace?.guildId ? String(trace.guildId) : "none"},
channel:${trace?.channelId ? String(trace.channelId) : "none"},
user:${trace?.userId ? String(trace.userId) : "none"},
source:${trace?.source ? String(trace.source) : "unknown"},
event:${trace?.event ? String(trace.event) : "unknown"},
reason:${trace?.reason ? String(trace.reason) : "unknown"},
message:${trace?.messageId ? String(trace.messageId) : "none"}
].join(" | ");
return [
"Runtime turn packet for a single serialized bot brain.",
Turn scope: ${scope},
"Privacy boundary: keep continuity/persona across turns, but do not disclose user-specific or channel-specific details from prior turns unless they are present in the current prompt/context.",
normalizedSystemPrompt
].filter(Boolean).join("
"); }
function buildCodexCliPrompt({ systemPrompt, userPrompt, contextMessages = [], imageInputs = [], trace, injectTurnPreamble = false }: ChatModelRequest & { trace?: LlmTrace; injectTurnPreamble?: boolean }) { const turnPreamble = injectTurnPreamble ? buildCodexCliTurnPreamble({ systemPrompt, trace }) : ""; const promptUserText = [turnPreamble, String(userPrompt || "").trim()].filter(Boolean).join("
"); return buildClaudeCodeFallbackPrompt({ contextMessages, userPrompt: promptUserText, imageInputs }); }
export type CodexCliServiceDeps = { codexCliAvailable: boolean; getBrainSession: () => CodexCliStreamSessionLike | null; setBrainSession: (session: CodexCliStreamSessionLike | null) => void; getBrainModel: () => string; setBrainModel: (model: string) => void; };
export async function runCodexCliBrainStream( deps: CodexCliServiceDeps, { model, input, timeoutMs, maxBufferBytes, signal }: { model: string; input: string; timeoutMs: number; maxBufferBytes: number; signal?: AbortSignal; } ) { const normalizedModel = String(model || "").trim(); if (!normalizedModel) { throw new Error("codex-cli brain stream requires a model"); }
if (!deps.getBrainSession() || deps.getBrainModel() !== normalizedModel) { const existingSession = deps.getBrainSession(); if (existingSession) { existingSession.close(); } deps.setBrainSession(createCodexCliStreamSession({ model: normalizedModel, maxBufferBytes })); deps.setBrainModel(normalizedModel); }
return await deps.getBrainSession()!.run({ input, timeoutMs, signal }); }
export async function callCodexCli( deps: CodexCliServiceDeps, { model, systemPrompt, userPrompt, imageInputs = [], contextMessages = [], maxOutputTokens, jsonSchema = "", signal, trace = { guildId: null, channelId: null, userId: null, source: null, event: null, reason: null, messageId: null } }: ChatModelRequest & { trace?: LlmTrace } ) { if (!deps.codexCliAvailable) { throw new Error("codex-cli provider requires the 'codex' CLI to be installed."); }
const normalizedJsonSchema = String(jsonSchema || "").trim(); const fallbackSystemPrompt = buildClaudeCodeSystemPrompt({ systemPrompt, maxOutputTokens }); const prompt = buildCodexCliPrompt({ model, systemPrompt, userPrompt, imageInputs, contextMessages, temperature: 0, maxOutputTokens, reasoningEffort: "", jsonSchema, trace, injectTurnPreamble: !normalizedJsonSchema }); const outputSchema = createCodexCliOutputSchemaFile(normalizedJsonSchema); let streamFailure = "";
try { const { stdout } = !normalizedJsonSchema ? await runCodexCliBrainStream(deps, { model, input: prompt, timeoutMs: CODEX_CLI_TIMEOUT_MS, maxBufferBytes: CODEX_CLI_MAX_BUFFER_BYTES, signal }) : await runCodexCli({ args: buildCodexCliBrainArgs({ model, prompt: [fallbackSystemPrompt, prompt].filter(Boolean).join("
"), outputSchemaPath: outputSchema?.path || "" }), input: "", timeoutMs: CODEX_CLI_TIMEOUT_MS, maxBufferBytes: CODEX_CLI_MAX_BUFFER_BYTES, signal });
const parsed = parseCodexCliJsonlOutput(stdout, model);
if (parsed?.isError) {
throw new Error(parsed.errorMessage || "codex-cli returned an error result.");
}
if (parsed && String(parsed.text || "").trim()) {
return {
text: parsed.text,
usage: parsed.usage,
costUsd: parsed.costUsd
};
}
streamFailure = "codex-cli returned an empty or invalid stream response.";
} catch (error) { const normalizedError = normalizeCodexCliError(error, { timeoutPrefix: "codex-cli timed out" }); if (normalizedError.isTimeout) { throw new Error(normalizedError.message); } streamFailure = normalizedError.message; } finally { outputSchema?.cleanup(); }
const jsonFallbackSchema = createCodexCliOutputSchemaFile(normalizedJsonSchema); let jsonFallbackFailure = ""; try { const { stdout } = await runCodexCli({ args: buildCodexCliBrainArgs({ model, prompt: [fallbackSystemPrompt, buildCodexCliPrompt({ model, systemPrompt, userPrompt, imageInputs, contextMessages, temperature: 0, maxOutputTokens, reasoningEffort: "", jsonSchema, trace, injectTurnPreamble: false })].filter(Boolean).join("
"), outputSchemaPath: jsonFallbackSchema?.path || "" }), input: "", timeoutMs: CODEX_CLI_TIMEOUT_MS, maxBufferBytes: CODEX_CLI_MAX_BUFFER_BYTES, signal }); const parsed = parseCodexCliJsonlOutput(stdout, model); if (parsed?.isError) { throw new Error(parsed.errorMessage || "codex-cli returned an error result."); } if (!parsed || !String(parsed.text || "").trim()) { throw new Error("codex-cli returned an empty or invalid fallback response."); }
return {
text: parsed.text,
usage: parsed.usage,
costUsd: parsed.costUsd
};
} catch (error) {
const normalizedError = normalizeCodexCliError(error, {
timeoutPrefix: "codex-cli fallback timed out"
});
if (normalizedError.isTimeout) {
throw new Error(streamFailure ? ${streamFailure} | fallback: ${normalizedError.message} : normalizedError.message);
}
jsonFallbackFailure = normalizedError.message;
} finally {
jsonFallbackSchema?.cleanup();
}
try { const { stdout } = await runCodexCli({ args: buildCodexCliTextArgs({ model, prompt: [fallbackSystemPrompt, buildCodexCliPrompt({ model, systemPrompt, userPrompt, imageInputs, contextMessages, temperature: 0, maxOutputTokens, reasoningEffort: "", jsonSchema, trace, injectTurnPreamble: false })].filter(Boolean).join("
") }), input: "", timeoutMs: CODEX_CLI_TIMEOUT_MS, maxBufferBytes: CODEX_CLI_MAX_BUFFER_BYTES }); const text = String(stdout || "").trim(); if (!text) { throw new Error("codex-cli returned an empty or invalid text fallback response."); } return { text, usage: { inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0 } satisfies UsageMetrics, costUsd: 0 }; } catch (error) { const normalizedError = normalizeCodexCliError(error, { timeoutPrefix: "codex-cli text fallback timed out" }); throw new Error([streamFailure, jsonFallbackFailure, normalizedError.message].filter(Boolean).join(" | ")); } }
export function closeCodexCliSession(deps: CodexCliServiceDeps) { const session = deps.getBrainSession(); if (!session || typeof session.close !== "function") return; session.close(); deps.setBrainSession(null); deps.setBrainModel(""); }
