import { createHash } from "crypto"; import { BaseExecutor } from "./base.js"; import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.js"; import { PROVIDERS } from "../config/providers.js"; import { normalizeResponsesInput } from "../translator/helpers/responsesApiHelper.js"; import { fetchImageAsBase64 } from "../translator/helpers/imageHelper.js"; import { getModelUpstreamId } from "../config/providerModels.js"; import { getConsistentMachineId } from "../../src/shared/utils/machineId.js"; // In-memory map: hash(machineId + first assistant content) → { sessionId, lastUsed } const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour const assistantSessionMap = new Map(); // Cache machine ID at module level (resolved once) let cachedMachineId = null; getConsistentMachineId().then(id => { cachedMachineId = id; }); function hashContent(text) { return createHash("sha256").update(text).digest("hex").slice(0, 16); } function generateSessionId() { return `sess_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`; } // Extract text content from an input item function extractItemText(item) { if (!item) return ""; if (typeof item.content === "string") return item.content; if (Array.isArray(item.content)) { return item.content.map(c => c.text || c.output || "").filter(Boolean).join(""); } return ""; } // Resolve session_id from first assistant message + machineId to avoid cross-user collision function resolveConversationSessionId(input, machineId) { const machineSessionId = machineId ? `sess_${hashContent(machineId)}` : generateSessionId(); if (!Array.isArray(input) || input.length === 0) return machineSessionId; // Find first assistant message that has actual text content let text = ""; for (const item of input) { if (item.role === "assistant") { text = extractItemText(item); if (text) break; } } if (!text) return machineSessionId; const hash = hashContent((machineId || "") + text); const entry = assistantSessionMap.get(hash); if (entry) { entry.lastUsed = Date.now(); return entry.sessionId; } const sessionId = generateSessionId(); assistantSessionMap.set(hash, { sessionId, lastUsed: Date.now() }); return sessionId; } // Cleanup expired entries periodically setInterval(() => { const now = Date.now(); for (const [key, entry] of assistantSessionMap) { if (now - entry.lastUsed > SESSION_TTL_MS) assistantSessionMap.delete(key); } }, 10 * 60 * 1000); /** * Codex Executor - handles OpenAI Codex API (Responses API format) * Automatically injects default instructions if missing */ export class CodexExecutor extends BaseExecutor { constructor() { super("codex", PROVIDERS.codex); this._currentSessionId = null; } /** * Override headers to add session_id per conversation * transformRequest runs BEFORE buildHeaders, sets this._currentSessionId */ buildHeaders(credentials, stream = true) { const headers = super.buildHeaders(credentials, stream); headers["session_id"] = this._currentSessionId || credentials?.connectionId || "default"; return headers; } buildUrl(model, stream, urlIndex = 0, credentials = null) { const base = super.buildUrl(model, stream, urlIndex, credentials); return this._isCompact ? `${base}/compact` : base; } /** * Prefetch remote image URLs and inline them as base64 data URIs. * Runs before execute() because Codex backend cannot fetch remote images. * Mutates body.input in place. */ async prefetchImages(body) { if (!Array.isArray(body?.input)) return; for (const item of body.input) { if (!Array.isArray(item.content)) continue; const pending = item.content.map(async (c) => { if (c.type !== "image_url") return c; const url = typeof c.image_url === "string" ? c.image_url : c.image_url?.url; const detail = c.image_url?.detail || "auto"; if (!url) return c; if (url.startsWith("data:")) return { type: "input_image", image_url: url, detail }; const fetched = await fetchImageAsBase64(url, { timeoutMs: 15000 }); return { type: "input_image", image_url: fetched?.url || url, detail }; }); item.content = await Promise.all(pending); } } async execute(args) { // Fetch remote images before the synchronous transform/execute pipeline await this.prefetchImages(args.body); return super.execute(args); } // Parse Codex usage_limit_reached to extract precise resetsAtMs; fallback to default otherwise parseError(response, bodyText) { if (response.status === 429 && bodyText) { try { const json = JSON.parse(bodyText); const err = json?.error; if (err?.type === "usage_limit_reached") { const now = Date.now(); let resetsAtMs = null; if (typeof err.resets_at === "number" && err.resets_at > 0) { const ms = err.resets_at * 1000; if (ms > now) resetsAtMs = ms; } if (!resetsAtMs && typeof err.resets_in_seconds === "number" && err.resets_in_seconds > 0) { resetsAtMs = now + err.resets_in_seconds * 1000; } if (resetsAtMs) { return { status: 429, message: err.message || bodyText, resetsAtMs }; } } } catch { /* fall through to default */ } } return super.parseError(response, bodyText); } /** * Transform request before sending - inject default instructions if missing. * Image fetching is handled separately in prefetchImages() so this stays sync. */ transformRequest(model, body, stream, credentials) { this._isCompact = !!body._compact; delete body._compact; // Resolve conversation-stable session_id from input history + machineId this._currentSessionId = resolveConversationSessionId(body.input, cachedMachineId); // Convert string input to array format (Codex API requires input as array) const normalized = normalizeResponsesInput(body.input); if (normalized) body.input = normalized; // Ensure input is present and non-empty (Codex API rejects empty input) if (!body.input || (Array.isArray(body.input) && body.input.length === 0)) { body.input = [{ type: "message", role: "user", content: [{ type: "input_text", text: "..." }] }]; } // Ensure streaming is enabled (Codex API requires it) body.stream = true; // If no instructions provided, inject default Codex instructions if (!body.instructions || body.instructions.trim() === "") { body.instructions = CODEX_DEFAULT_INSTRUCTIONS; } // Ensure store is false (Codex requirement) body.store = false; // Map virtual Codex review models to the upstream Codex model before suffix parsing. body.model = getModelUpstreamId("cx", body.model || model); // Extract thinking level from model name suffix // e.g., gpt-5.3-codex-high → high, gpt-5.3-codex → medium (default) const effortLevels = ['none', 'low', 'medium', 'high', 'xhigh']; let modelEffort = null; for (const level of effortLevels) { if (body.model.endsWith(`-${level}`)) { modelEffort = level; // Strip suffix from model name for actual API call body.model = body.model.replace(`-${level}`, ''); break; } } // Priority: explicit reasoning.effort > reasoning_effort param > model suffix > default (medium) if (!body.reasoning) { const effort = body.reasoning_effort || modelEffort || 'low'; body.reasoning = { effort, summary: "auto" }; } else if (!body.reasoning.summary) { body.reasoning.summary = "auto"; } delete body.reasoning_effort; // Include reasoning encrypted content (required by Codex backend for reasoning models) if (body.reasoning && body.reasoning.effort && body.reasoning.effort !== 'none') { body.include = ["reasoning.encrypted_content"]; } // Remove unsupported parameters for Codex API delete body.temperature; delete body.top_p; delete body.frequency_penalty; delete body.presence_penalty; delete body.logprobs; delete body.top_logprobs; delete body.n; delete body.seed; delete body.max_tokens; delete body.max_completion_tokens; delete body.max_output_tokens; // Responses API clients send this but Codex rejects it delete body.user; // Cursor sends this but Codex doesn't support it delete body.prompt_cache_retention; // Cursor sends this but Codex doesn't support it delete body.metadata; // Cursor sends this but Codex doesn't support it delete body.stream_options; // Cursor sends this but Codex doesn't support it delete body.safety_identifier; // Droid CLI sends this but Codex doesn't support it return body; } }