diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index f7b5ba3192..63a7f0678f 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -379,6 +379,46 @@ function stripStoredItemReferences(body: Record): void { } } +function repairMissingCodexFunctionCallOutputs(body: Record): void { + if (!Array.isArray(body.input)) return; + + const existingOutputIds = new Set(); + for (const item of body.input) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const record = item as Record; + if (record.type !== "function_call_output") continue; + if (typeof record.call_id === "string" && record.call_id.trim()) { + existingOutputIds.add(record.call_id.trim()); + } + } + + const repaired: unknown[] = []; + let insertedCount = 0; + for (const item of body.input) { + repaired.push(item); + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const record = item as Record; + if (record.type !== "function_call") continue; + const callId = typeof record.call_id === "string" ? record.call_id.trim() : ""; + if (!callId || existingOutputIds.has(callId)) continue; + + repaired.push({ + type: "function_call_output", + call_id: callId, + output: "", + }); + existingOutputIds.add(callId); + insertedCount++; + } + + if (insertedCount > 0) { + body.input = repaired; + console.debug( + `[Codex] repairMissingCodexFunctionCallOutputs: inserted ${insertedCount} empty function_call_output item(s)` + ); + } +} + // Responses-API hosted tool types that OpenAI/Codex executes server-side. // These arrive shaped as `{ type, ...params }` with no `function` object and no `name` — // e.g. Codex CLI injects `{ type: "image_generation", output_format: "png" }` or @@ -1138,6 +1178,7 @@ export class CodexExecutor extends BaseExecutor { if (Array.isArray(body.input)) { body.input = sanitizeResponsesInputItems(body.input, false); } + repairMissingCodexFunctionCallOutputs(body); // ── Cache-aware system prompt handling (both paths) ── // diff --git a/src/lib/guardrails/visionBridge.ts b/src/lib/guardrails/visionBridge.ts index b1fea0c111..94f092acc5 100644 --- a/src/lib/guardrails/visionBridge.ts +++ b/src/lib/guardrails/visionBridge.ts @@ -18,11 +18,14 @@ import { isVisionBridgeForcedModel, } from "@/shared/constants/visionBridgeDefaults"; -/// Check if a model with a combo mapping should trigger vision bridge processing. -/// Resolves the combo targets and only returns true if at least one target model -/// does NOT support vision natively. This avoids unnecessary vision model calls -/// when all combo targets can handle images directly. -async function shouldProcessImagesForComboModel(model: string): Promise { +type ComboVisionBridgeDecision = "process" | "skip" | "not-combo"; + +/// Check if a combo model should trigger vision bridge processing. +/// Resolves combo targets and returns: +/// - "process" if any target cannot be proven vision-capable +/// - "skip" if all model targets can handle images directly +/// - "not-combo" when the model is not a combo/mapping +async function getComboVisionBridgeDecision(model: string): Promise { try { const { getComboByName } = await import("@/lib/localDb"); const { resolveComboForModel } = await import("@/lib/db/modelComboMappings"); @@ -33,17 +36,17 @@ async function shouldProcessImagesForComboModel(model: string): Promise // 2. If no exact match, try model-combo mapping if (!combo) { const mapping = await resolveComboForModel(model); - if (!mapping) return false; + if (!mapping) return "not-combo"; const comboName = mapping.comboName ?? mapping.name ?? null; - if (!comboName) return false; + if (!comboName) return "not-combo"; combo = await getComboByName(comboName); } - if (!combo) return false; + if (!combo) return "not-combo"; // 3. Get the combo's models (target steps) const rawModels = (combo as Record).models; - if (!Array.isArray(rawModels)) return false; + if (!Array.isArray(rawModels)) return "process"; // 4. Check each target for vision support // combo-ref → conservative (process images) @@ -52,27 +55,29 @@ async function shouldProcessImagesForComboModel(model: string): Promise let hasModelStep = false; for (const step of rawModels) { const s = step as Record; - if (s.kind === "combo-ref") return true; + if (s.kind === "combo-ref") return "process"; if (s.kind === "model") { hasModelStep = true; const targetModel = s.model; if (typeof targetModel === "string") { const caps = getResolvedModelCapabilities(targetModel); if (caps.supportsVision !== true) { - return true; + return "process"; } + } else { + return "process"; } } } // All model steps support vision — safe to skip - if (hasModelStep) return false; + if (hasModelStep) return "skip"; // No recognizable steps — don't force bridge - return false; + return "not-combo"; } catch { // On error, try to process images (conservative) - return true; + return "process"; } } @@ -120,15 +125,24 @@ export class VisionBridgeGuardrail extends BaseGuardrail { // 4. Check if model supports vision const capabilities = getResolvedModelCapabilities(model); + const comboVisionBridgeDecision = forceVisionBridge + ? "process" + : this.deps.checkModelHasComboMapping + ? (await this.deps.checkModelHasComboMapping(model)) + ? "process" + : "skip" + : await getComboVisionBridgeDecision(model); + + if (comboVisionBridgeDecision === "skip") { + return { block: false }; + } + if (capabilities.supportsVision === true && !forceVisionBridge) { // The request model supports vision natively, but check if a // model-combo mapping routes this model through a combo where // some targets may NOT support vision. In that case, the vision // bridge must process images so combo targets can describe them. - const hasMapping = this.deps.checkModelHasComboMapping - ? await this.deps.checkModelHasComboMapping(model) - : await shouldProcessImagesForComboModel(model); - if (!hasMapping) { + if (comboVisionBridgeDecision !== "process") { return { block: false }; } // Combo mapping found — fall through to process images diff --git a/tests/unit/executor-codex.test.ts b/tests/unit/executor-codex.test.ts index 95431399a0..d4f9a5abc9 100644 --- a/tests/unit/executor-codex.test.ts +++ b/tests/unit/executor-codex.test.ts @@ -510,6 +510,53 @@ test("CodexExecutor.transformRequest strips raw internal assistant commentary wi ); }); +test("CodexExecutor.transformRequest inserts missing function_call_output items", () => { + const executor = new CodexExecutor(); + const result = executor.transformRequest( + "gpt-5.5-xhigh", + { + _nativeCodexPassthrough: true, + input: [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Continue." }], + }, + { + type: "function_call", + call_id: "call_missing_result", + name: "read_file", + arguments: "{}", + }, + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Next turn." }], + }, + ], + stream: false, + }, + false, + { + requestEndpointPath: "/responses", + } + ); + + const missingOutputIndex = result.input.findIndex( + (item) => item.type === "function_call_output" && item.call_id === "call_missing_result" + ); + const functionCallIndex = result.input.findIndex( + (item) => item.type === "function_call" && item.call_id === "call_missing_result" + ); + + assert.equal(missingOutputIndex, functionCallIndex + 1); + assert.deepEqual(result.input[missingOutputIndex], { + type: "function_call_output", + call_id: "call_missing_result", + output: "", + }); +}); + test("CodexExecutor.transformRequest strips internal assistant commentary before mapping messages to input", () => { const executor = new CodexExecutor(); const result = executor.transformRequest(