diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 55a6df207c..6b8a0f5460 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -106,8 +106,22 @@ export class CodexExecutor extends BaseExecutor { * Transform request before sending - inject default instructions if missing */ transformRequest(model, body, stream, credentials) { + const nativeCodexPassthrough = body?._nativeCodexPassthrough === true; + // Codex /responses rejects stream=false; we aggregate SSE back to JSON when needed. body.stream = true; + delete body._nativeCodexPassthrough; + + const requestServiceTier = normalizeServiceTierValue(body.service_tier); + if (requestServiceTier) { + body.service_tier = requestServiceTier; + } else if (defaultFastServiceTierEnabled) { + body.service_tier = CODEX_FAST_WIRE_VALUE; + } + + if (nativeCodexPassthrough) { + return body; + } // If no instructions provided, inject default Codex instructions if (!body.instructions || body.instructions.trim() === "") { @@ -117,13 +131,6 @@ export class CodexExecutor extends BaseExecutor { // Ensure store is false (Codex requirement) body.store = false; - const requestServiceTier = normalizeServiceTierValue(body.service_tier); - if (requestServiceTier) { - body.service_tier = requestServiceTier; - } else if (defaultFastServiceTierEnabled) { - body.service_tier = CODEX_FAST_WIRE_VALUE; - } - // 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"]; diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 5661765ebd..8d07286cd0 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -42,6 +42,20 @@ import { getIdempotencyKey, checkIdempotency, saveIdempotency } from "@/lib/idem import { createProgressTransform, wantsProgress } from "../utils/progressTracker.ts"; import { isModelUnavailableError, getNextFamilyFallback } from "../services/modelFamilyFallback.ts"; +export function shouldUseNativeCodexPassthrough({ + provider, + sourceFormat, + endpointPath, +}: { + provider?: string | null; + sourceFormat?: string | null; + endpointPath?: string | null; +}): boolean { + if (provider !== "codex") return false; + if (sourceFormat !== FORMATS.OPENAI_RESPONSES) return false; + return String(endpointPath || "").toLowerCase().endsWith("/responses"); +} + /** * Core chat handler - shared between SSE and Worker * Returns { success, response, status, error } for caller to handle fallback @@ -103,6 +117,11 @@ export async function handleChatCore({ const sourceFormat = detectFormat(body); const endpointPath = (clientRawRequest?.endpoint || "").toLowerCase(); const isResponsesEndpoint = endpointPath.endsWith("/responses"); + const nativeCodexPassthrough = shouldUseNativeCodexPassthrough({ + provider, + sourceFormat, + endpointPath, + }); // Check for bypass patterns (warmup, skip) - return fake response const bypassResponse = handleBypassRequest(body, model, userAgent); @@ -164,55 +183,60 @@ export async function handleChatCore({ // Translate request (pass reqLogger for intermediate logging) let translatedBody = body; try { - // Issue #199: Disable tool name prefix when routing Claude-format requests - // to non-Claude backends (prefix causes tool name mismatches) - const claudeProviders = ["claude", "anthropic"]; - if (targetFormat === FORMATS.CLAUDE && !claudeProviders.includes(provider?.toLowerCase?.())) { - translatedBody = { ...translatedBody, _disableToolPrefix: true }; - } + if (nativeCodexPassthrough) { + translatedBody = { ...translatedBody, _nativeCodexPassthrough: true }; + log?.debug?.("FORMAT", "native codex passthrough enabled"); + } else { + // Issue #199: Disable tool name prefix when routing Claude-format requests + // to non-Claude backends (prefix causes tool name mismatches) + const claudeProviders = ["claude", "anthropic"]; + if (targetFormat === FORMATS.CLAUDE && !claudeProviders.includes(provider?.toLowerCase?.())) { + translatedBody = { ...translatedBody, _disableToolPrefix: true }; + } - // ── #291: Strip empty name fields from messages/input items ── - // Upstream providers (OpenAI, Codex) reject name:"" with 400 errors. - // Clients like PocketPaw may forward empty name fields from assistant turns. - if (Array.isArray(body.messages)) { - body.messages = body.messages.map((msg: Record) => { - if (msg.name === "") { - const { name: _n, ...rest } = msg; - return rest; - } - return msg; - }); - } - if (Array.isArray(body.input)) { - body.input = body.input.map((item: Record) => { - if (item.name === "") { - const { name: _n, ...rest } = item; - return rest; - } - return item; - }); - } - // ── #346: Strip tools with empty function.name ── - // Claude Code sometimes forwards tool definitions with empty names, causing - // OpenAI-compatible upstream providers to reject with: - // "Invalid 'input[N].name': empty string. Expected minimum length 1." - if (Array.isArray(body.tools)) { - body.tools = body.tools.filter((tool: Record) => { - const fn = tool.function as Record | undefined; - return fn?.name && String(fn.name).trim().length > 0; - }); - } + // ── #291: Strip empty name fields from messages/input items ── + // Upstream providers (OpenAI, Codex) reject name:"" with 400 errors. + // Clients like PocketPaw may forward empty name fields from assistant turns. + if (Array.isArray(body.messages)) { + body.messages = body.messages.map((msg: Record) => { + if (msg.name === "") { + const { name: _n, ...rest } = msg; + return rest; + } + return msg; + }); + } + if (Array.isArray(body.input)) { + body.input = body.input.map((item: Record) => { + if (item.name === "") { + const { name: _n, ...rest } = item; + return rest; + } + return item; + }); + } + // ── #346: Strip tools with empty function.name ── + // Claude Code sometimes forwards tool definitions with empty names, causing + // OpenAI-compatible upstream providers to reject with: + // "Invalid 'input[N].name': empty string. Expected minimum length 1." + if (Array.isArray(body.tools)) { + body.tools = body.tools.filter((tool: Record) => { + const fn = tool.function as Record | undefined; + return fn?.name && String(fn.name).trim().length > 0; + }); + } - translatedBody = translateRequest( - sourceFormat, - targetFormat, - model, - translatedBody, - stream, - credentials, - provider, - reqLogger - ); + translatedBody = translateRequest( + sourceFormat, + targetFormat, + model, + translatedBody, + stream, + credentials, + provider, + reqLogger + ); + } } catch (error) { const parsedStatus = Number(error?.statusCode); const statusCode = diff --git a/tests/unit/plan3-p0.test.mjs b/tests/unit/plan3-p0.test.mjs index 379f534069..49e97b9b77 100644 --- a/tests/unit/plan3-p0.test.mjs +++ b/tests/unit/plan3-p0.test.mjs @@ -4,6 +4,7 @@ import assert from "node:assert/strict"; import { FORMATS } from "../../open-sse/translator/formats.ts"; import { getModelInfoCore } from "../../open-sse/services/model.ts"; import { detectFormat } from "../../open-sse/services/provider.ts"; +import { shouldUseNativeCodexPassthrough } from "../../open-sse/handlers/chatCore.ts"; import { translateRequest } from "../../open-sse/translator/index.ts"; import { GithubExecutor } from "../../open-sse/executors/github.ts"; import { @@ -79,6 +80,44 @@ test("CodexExecutor maps fast service tier to priority", () => { assert.equal(transformed.service_tier, "priority"); }); +test("shouldUseNativeCodexPassthrough only enables responses-native Codex requests", () => { + assert.equal( + shouldUseNativeCodexPassthrough({ + provider: "codex", + sourceFormat: FORMATS.OPENAI_RESPONSES, + endpointPath: "/v1/responses", + }), + true + ); + + assert.equal( + shouldUseNativeCodexPassthrough({ + provider: "codex", + sourceFormat: FORMATS.OPENAI, + endpointPath: "/v1/responses", + }), + false + ); + + assert.equal( + shouldUseNativeCodexPassthrough({ + provider: "openai", + sourceFormat: FORMATS.OPENAI_RESPONSES, + endpointPath: "/v1/responses", + }), + false + ); + + assert.equal( + shouldUseNativeCodexPassthrough({ + provider: "codex", + sourceFormat: FORMATS.OPENAI_RESPONSES, + endpointPath: "/v1/chat/completions", + }), + false + ); +}); + test("CodexExecutor can force fast service tier from settings", () => { setDefaultFastServiceTierEnabled(true); @@ -101,6 +140,33 @@ test("CodexExecutor always requests SSE accept header", () => { assert.equal(headers.Accept, "text/event-stream"); }); +test("CodexExecutor preserves native responses payloads for Codex passthrough", () => { + const executor = new CodexExecutor(); + const transformed = executor.transformRequest( + "gpt-5.1-codex", + { + model: "gpt-5.1-codex", + input: "ship it", + instructions: "custom system prompt", + store: true, + metadata: { source: "codex-client" }, + reasoning_effort: "high", + service_tier: "fast", + _nativeCodexPassthrough: true, + stream: false, + }, + false + ); + + assert.equal(transformed.stream, true); + assert.equal(transformed.service_tier, "priority"); + assert.equal(transformed.instructions, "custom system prompt"); + assert.equal(transformed.store, true); + assert.deepEqual(transformed.metadata, { source: "codex-client" }); + assert.equal(transformed.reasoning_effort, "high"); + assert.ok(!("_nativeCodexPassthrough" in transformed)); +}); + test("translateNonStreamingResponse converts Responses API payload to OpenAI chat.completion", () => { const responseBody = { id: "resp_123",