UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

772 lines (771 loc) • 33.9 kB
import { resolveClaudeMaxTokens } from "../utils/tokenLimits.js"; import { inlineJsonSchema } from "../utils/schemaConversion.js"; import { modelSupportsForcedToolChoice, claudeDisabledThinkingReplacement, } from "../models/modelRegistry.js"; import { parseToolArguments } from "./argumentParsing.js"; import { classifyProxyFailureCode } from "./proxyFailureDetails.js"; import { recordCodexOutboundSchemaDegraded, recordCodexOutboundUnsupportedField, } from "./proxyTracer.js"; import { CODEX_STREAM_MAX_TOOL_CALLS, CODEX_STREAM_SIZE_CEILING_BYTES, } from "./streamLimits.js"; // --------------------------------------------------------------------------- // §3.1 parseCodexNativeRequest — the untrusted-JSON -> typed boundary. // --------------------------------------------------------------------------- function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } function isCodexContentPart(value) { if (!isRecord(value)) { return false; } if (value.type === "input_text" || value.type === "output_text") { return typeof value.text === "string"; } if (value.type === "input_image") { return typeof value.image_url === "string"; } return false; } function isCodexNativeToolDeclaration(value) { if (!isRecord(value) || typeof value.name !== "string") { return false; } if (value.type === "function") { return (typeof value.strict === "boolean" && isRecord(value.parameters) && (value.description === undefined || typeof value.description === "string")); } if (value.type === "custom") { return (isRecord(value.format) && value.format.type === "grammar" && (value.format.syntax === "lark" || value.format.syntax === "regex") && typeof value.format.definition === "string" && (value.description === undefined || typeof value.description === "string")); } return false; } function isCodexNativeToolNamespace(value) { return (isRecord(value) && value.type === "namespace" && typeof value.name === "string" && typeof value.description === "string" && Array.isArray(value.tools) && value.tools.every(isCodexNativeToolDeclaration)); } function isCodexNativeInputItem(value) { if (!isRecord(value)) { return false; } switch (value.type) { case "message": return ((value.role === "user" || value.role === "assistant" || value.role === "developer") && Array.isArray(value.content) && value.content.every(isCodexContentPart)); case "function_call": return (typeof value.call_id === "string" && typeof value.name === "string" && typeof value.arguments === "string"); case "function_call_output": case "custom_tool_call_output": return (typeof value.call_id === "string" && typeof value.output === "string"); case "custom_tool_call": return (typeof value.call_id === "string" && typeof value.name === "string" && typeof value.input === "string"); case "reasoning": return true; case "additional_tools": return (value.role === "developer" && Array.isArray(value.tools) && value.tools.every(isCodexNativeToolNamespace)); default: return false; } } function isCodexNativeToolChoice(value) { if (value === "auto" || value === "required" || value === "none") { return true; } return (isRecord(value) && value.type === "function" && typeof value.name === "string"); } const CODEX_REASONING_EFFORTS = [ "none", "minimal", "low", "medium", "high", "xhigh", "max", ]; function isCodexNativeReasoning(value) { return (isRecord(value) && CODEX_REASONING_EFFORTS.includes(value.effort) && (value.context === undefined || typeof value.context === "string")); } export function parseCodexNativeRequest(raw) { if (!isRecord(raw) || typeof raw.model !== "string" || typeof raw.stream !== "boolean" || typeof raw.store !== "boolean" || !Array.isArray(raw.input) || !raw.input.every(isCodexNativeInputItem) || (raw.tool_choice !== undefined && !isCodexNativeToolChoice(raw.tool_choice)) || (raw.reasoning !== undefined && !isCodexNativeReasoning(raw.reasoning)) || (raw.parallel_tool_calls !== undefined && typeof raw.parallel_tool_calls !== "boolean")) { return { ok: false, error: { code: "MALFORMED_REQUEST", message: "inbound body does not match CodexNativeRequest", }, }; } return { ok: true, value: raw }; } // --------------------------------------------------------------------------- // §3.3 tool_choice — exact inversion of codexFallback.ts's convertClaudeRequestToCodex. // --------------------------------------------------------------------------- // Absent tool_choice and "auto" both mean the same thing to Codex, and both // now map to an *explicit* `{type:"auto"}` (previously: `undefined` for the // absent case) so `disable_parallel_tool_use` always has a field on the // output to attach to, regardless of whether the source request bothered to // say "auto" out loud. `parallel_tool_calls: false` is inert on `"none"` — // there is nothing running in parallel to disable. // // `targetModel` gates a second degradation: some Claude families (Sonnet // 5.5, Opus 5.5, Fable/Mythos 5.1 — see modelSupportsForcedToolChoice) answer // a forced tool_choice (`any`/named `tool`) with an HTTP 400. For those // targets only, a forced choice is downgraded to `{type:"auto"}` (keeping // `disable_parallel_tool_use` when the source asked for it) instead of going // out as a request the target is guaranteed to reject. Every other target // model is unaffected — this is a per-request, per-target decision, not a // global behavior change. function mapCodexToolChoiceToClaude(choice, parallelToolCalls, targetModel) { const disable = parallelToolCalls === false; if (choice === undefined || choice === "auto") { return disable ? { type: "auto", disable_parallel_tool_use: true } : { type: "auto" }; } if (choice === "none") { return { type: "none" }; } if (!modelSupportsForcedToolChoice(targetModel)) { return disable ? { type: "auto", disable_parallel_tool_use: true } : { type: "auto" }; } if (choice === "required") { return disable ? { type: "any", disable_parallel_tool_use: true } : { type: "any" }; } return disable ? { type: "tool", name: choice.name, disable_parallel_tool_use: true } : { type: "tool", name: choice.name }; } // --------------------------------------------------------------------------- // §3.4 Tools: flattened across namespaces, split by each declaration's own type. // --------------------------------------------------------------------------- /** * Detection-only walk of a schema's `$ref` chain, independent of * `inlineJsonSchema`'s own flattening — the actual flattening stays fully * delegated to that function unmodified (never reimplemented here), so this * only answers "would a ref chain revisit itself" for the schemaDegraded * counter's benefit. */ function hasCircularRef(schema, defs, seen = new Set()) { if (typeof schema.$ref === "string" && schema.$ref.startsWith("#/")) { if (seen.has(schema.$ref)) { return true; } const key = schema.$ref.split("/").pop(); const target = defs && key ? defs[key] : undefined; return target ? hasCircularRef(target, defs, new Set(seen).add(schema.$ref)) : false; } const properties = isRecord(schema.properties) ? Object.values(schema.properties) : []; const items = Array.isArray(schema.items) ? schema.items : isRecord(schema.items) ? [schema.items] : []; return [...properties, ...items].some((value) => isRecord(value) && hasCircularRef(value, defs, seen)); } function mapCodexFunctionToolToClaude(tool) { const reasons = []; const defs = (tool.parameters.definitions ?? tool.parameters.$defs); if (defs && hasCircularRef(tool.parameters, defs)) { reasons.push("circular_ref_flattened"); } const input_schema = inlineJsonSchema(tool.parameters); if (tool.strict) { // Anthropic tool declarations have no `strict`-mode equivalent, so the // flag is dropped rather than silently passed through as an unknown key. reasons.push("strict_mode_flag_dropped"); } return { tool: { name: tool.name, ...(tool.description ? { description: tool.description } : {}), input_schema, }, kind: "function", reasons, }; } function mapCodexCustomToolToClaude(tool) { // Lossy, explicitly flagged: no JSON Schema exists for a Lark/regex grammar. // The model is no longer grammar-constrained after this mapping. SIGNED OFF // 2026-09-28 (design doc §10, ruling 7): grammar/custom tools wrapped as a // single `{input: string}` parameter, with `custom_tool_call` on the way // back, is the shipped behavior — not a placeholder awaiting approval. return { tool: { name: tool.name, description: [ tool.description, `Grammar (${tool.format.syntax}):\n${tool.format.definition}`, ] .filter((s) => Boolean(s)) .join("\n\n"), input_schema: { type: "object", properties: { input: { type: "string", description: "Raw command text, constrained by the grammar in this tool's description.", }, }, required: ["input"], }, }, kind: "custom", reasons: ["custom_tool_wrapped"], }; } function mapCodexToolDeclarationToClaude(tool) { // Discriminate on the declaration's own `type`, never on which namespace it // came from — the real capture disproves the namespace-implies-kind // assumption (`exec` is `type:"custom"` inside a `type:"function"`-heavy // namespace). return tool.type === "custom" ? mapCodexCustomToolToClaude(tool) : mapCodexFunctionToolToClaude(tool); } function buildClaudeTools(additionalTools) { if (!additionalTools) { return { tools: undefined, toolKindByName: new Map(), reasons: [] }; } const mapped = additionalTools.tools.flatMap((namespace) => namespace.tools.map(mapCodexToolDeclarationToClaude)); return { tools: mapped.length > 0 ? mapped.map((m) => m.tool) : undefined, toolKindByName: new Map(mapped.map((m) => [m.tool.name, m.kind])), reasons: mapped.flatMap((m) => m.reasons.map((reason) => ({ toolName: m.tool.name, reason }))), }; } // --------------------------------------------------------------------------- // §3.5 Reasoning: reasoning.effort -> thinking. // --------------------------------------------------------------------------- const REASONING_BUDGET = { low: 4096, medium: 8192, high: 16384, xhigh: 24576, max: 32768, }; // Codex "no reasoning" never goes out as an explicit `thinking:{type:"disabled"}` // — some Claude 5.5/5.1 families reject that shape with a 400 (probed on // Vertex, 2026-09-29) because they think adaptively and always on. What // "thinking off" means for those targets is decided by // claudeDisabledThinkingReplacement: `between_tools` (Sonnet 5.5's lowest // setting) when available, otherwise `omit` (nothing sent — the model cannot // be turned off, and the field is dropped rather than asking for a rejected // state). For every target outside that family the function returns "keep", // which here means the historical, unaffected behavior: omit the field. function mapCodexReasoningToThinking(reasoning, resolvedMaxTokens, targetModel) { if (!reasoning || reasoning.effort === "none" || reasoning.effort === "minimal") { const replacement = claudeDisabledThinkingReplacement(targetModel, reasoning?.effort); return replacement === "between_tools" ? { type: "between_tools" } : undefined; } const table = REASONING_BUDGET[reasoning.effort]; // Explicit floor: makes the ">= 1024" invariant correct by construction // rather than an accident of today's model-table minimums. const budget_tokens = Math.max(1024, Math.min(table, resolvedMaxTokens - 1024)); return { type: "enabled", budget_tokens }; } // --------------------------------------------------------------------------- // §3.6 Non-JSON function_call.arguments — never throws. Parsing itself is // `parseToolArguments` (argumentParsing.ts); this is a cheap, separate, // redundant check purely so a malformed (non-empty, non-record) arguments // string can be reported via the unsupportedFieldTotal counter without // polluting that pure function's return shape with a degrade flag. // --------------------------------------------------------------------------- function isMalformedFunctionArguments(raw) { if (raw === "") { return false; } try { const parsed = JSON.parse(raw); return !isRecord(parsed); } catch { return true; } } // --------------------------------------------------------------------------- // §3.7 Content parts, with the image data-URI fix. // --------------------------------------------------------------------------- const DATA_URI_RE = /^data:([^;,]+);base64,(.+)$/s; // The four image types the Messages API accepts (mirrors // SUPPORTED_IMAGE_MEDIA_TYPES in providers/anthropicImageBlocks.ts). const ANTHROPIC_IMAGE_MEDIA_TYPES = new Set([ "image/jpeg", "image/png", "image/gif", "image/webp", ]); const ANTHROPIC_TOOL_NAME_RE = /^[a-zA-Z0-9_-]{1,64}$/; function normalizeImageMediaType(mediaType) { const lower = mediaType.toLowerCase(); return lower === "image/jpg" ? "image/jpeg" : lower; } function toClaudeContentPart(part) { if (part.type === "input_image") { const match = DATA_URI_RE.exec(part.image_url); if (match) { return { type: "image", source: { type: "base64", media_type: normalizeImageMediaType(match[1]), data: match[2], }, }; } return { type: "image", source: { type: "url", url: part.image_url } }; } return { type: "text", text: part.text }; } // --------------------------------------------------------------------------- // §4 Role-alternation coalescing. // --------------------------------------------------------------------------- function coalesceCodexInputToClaudeMessages(items) { const groups = []; let current; const open = (role) => { if (current?.role === role) { return current; } if (current) { groups.push(current); } current = { role, blocks: [] }; return current; }; for (const item of items) { // additional_tools is handled out-of-band (§3.4). A reasoning item carries // OpenAI-encrypted chain of thought that only the OpenAI backend can read; // Anthropic has no way to consume it, so it is dropped by rule, not by // accident (unknown item types still fail parsing, per A.1). if (item.type === "additional_tools" || item.type === "reasoning") { continue; } if (item.type === "message") { // Developer messages become system blocks (below). An empty message // adds nothing, and on its own would become a content-less turn. if (item.role === "developer" || item.content.length === 0) { continue; } open(item.role).blocks.push(...item.content.map(toClaudeContentPart)); continue; } if (item.type === "function_call" || item.type === "custom_tool_call") { open("assistant").blocks.push({ type: "tool_use", id: item.call_id, name: item.name, // A custom tool is declared with a single `input` string (§3.4), so // its raw call text maps onto that field unchanged. input: item.type === "function_call" ? parseToolArguments(item.arguments, "function") : parseToolArguments(item.input, "custom"), }); continue; } open("user").blocks.push({ type: "tool_result", tool_use_id: item.call_id, content: item.output, }); } if (current) { groups.push(current); } // In a user turn the Messages API wants tool_result blocks before any other // content, so a user message that arrives between a call and its output is // moved after the result rather than sent in an order the API rejects. return groups.map((g) => ({ role: g.role, content: g.role === "user" ? [ ...g.blocks.filter((block) => block.type === "tool_result"), ...g.blocks.filter((block) => block.type !== "tool_result"), ] : g.blocks, })); } // Exported so callers (codexProxyRoutes.ts's cache-preservation wiring) can // independently recompute the expected system-prefix length from the same // native request `translateCodexRequestToClaude` translated, for // `assertClaudeSystemPrefixShape`'s guard against a future translation // regression that leaks extra content into `system`. export function buildSystemBlocksFromDeveloperMessages(items) { return items .filter((i) => i.type === "message" && i.role === "developer") .flatMap((i) => i.content.map(toClaudeContentPart)) .filter((b) => b.type === "text"); } // --------------------------------------------------------------------------- // §5 Resumed-session history contract — fail loud, never guess. // --------------------------------------------------------------------------- // Detects only a missing tools preamble. A client-side prune that drops turns // from the middle while resending the preamble passes this check; nothing in // the wire format marks such a gap (spec Q6, still open). function hasFreshSessionPreamble(items) { return items[0]?.type === "additional_tools"; } // --------------------------------------------------------------------------- // Tool-call history ceilings — the same streamLimits.ts numbers the response // leg enforces, so a history is never accepted that the reply could not carry. // --------------------------------------------------------------------------- function toolHistoryText(item) { switch (item.type) { case "function_call": return item.arguments; case "custom_tool_call": return item.input; case "function_call_output": case "custom_tool_call_output": return item.output; default: return undefined; } } function describeOversizedToolHistory(items) { const calls = items.filter((item) => item.type === "function_call" || item.type === "custom_tool_call").length; if (calls > CODEX_STREAM_MAX_TOOL_CALLS) { return `the history carries ${calls} tool calls, over the limit of ${CODEX_STREAM_MAX_TOOL_CALLS}`; } return items.some((item) => (toolHistoryText(item)?.length ?? 0) > CODEX_STREAM_SIZE_CEILING_BYTES) ? `a tool call's arguments or output exceed the limit of ${CODEX_STREAM_SIZE_CEILING_BYTES} characters` : undefined; } // --------------------------------------------------------------------------- // Inputs Codex accepts but the Messages API rejects — fail loud, never guess. // --------------------------------------------------------------------------- function describeUntranslatableItem(item, provider) { if (item.type === "additional_tools") { const badTool = item.tools .flatMap((namespace) => namespace.tools) .find((tool) => !ANTHROPIC_TOOL_NAME_RE.test(tool.name)); return badTool ? `tool name ${JSON.stringify(badTool.name)} does not match the Messages API's ${String(ANTHROPIC_TOOL_NAME_RE)}` : undefined; } if (item.type !== "message") { return undefined; } return item.content .map((part) => { if (part.type !== "input_image") { return undefined; } if (item.role === "developer") { return "a developer message carries an image, which a text-only system prompt cannot hold"; } if (!part.image_url.startsWith("data:")) { // Claude on Vertex takes only inline (base64) image sources; the // Vertex client in providers/googleVertex fetches URLs for the same reason. return provider === "vertex" ? "Claude on Vertex accepts only base64 image sources, not an image URL" : undefined; } const match = DATA_URI_RE.exec(part.image_url); if (!match) { return "an image data URI is not base64-encoded"; } const mediaType = normalizeImageMediaType(match[1]); return ANTHROPIC_IMAGE_MEDIA_TYPES.has(mediaType) ? undefined : `image media type ${mediaType} is not one the Messages API accepts (jpeg, png, gif, webp)`; }) .find((reason) => reason !== undefined); } function toolUseIds(message) { return message?.role === "assistant" && Array.isArray(message.content) ? message.content.flatMap((block) => block.type === "tool_use" ? [block.id] : []) : []; } function toolResultIds(message) { return message?.role === "user" && Array.isArray(message.content) ? message.content.flatMap((block) => block.type === "tool_result" ? [block.tool_use_id] : []) : []; } // The Messages API requires every tool call to be answered in the very next // user turn, and every tool result to answer a call in the turn before it. // History pruned on the client side can break either; this catches it here // instead of as an upstream 400. function describeToolPairingGap(messages) { return messages .map((message, index) => { const answered = toolResultIds(messages[index + 1]); const unanswered = toolUseIds(message).find((id) => !answered.includes(id)); if (unanswered) { return `tool call ${unanswered} has no result in the next user turn`; } const called = toolUseIds(messages[index - 1]); const orphaned = toolResultIds(message).find((id) => !called.includes(id)); return orphaned ? `tool result for ${orphaned} answers no call in the preceding assistant turn` : undefined; }) .find((reason) => reason !== undefined); } function lastAssistantMessageUsesTool(messages) { const lastAssistant = [...messages] .reverse() .find((message) => message.role === "assistant"); return (Array.isArray(lastAssistant?.content) && lastAssistant.content.some((block) => block.type === "tool_use")); } // --------------------------------------------------------------------------- // §3.2 translateCodexRequestToClaude — the full field-by-field mapping. // --------------------------------------------------------------------------- export function translateCodexRequestToClaude(request, target) { const oversized = describeOversizedToolHistory(request.input); if (oversized) { return { ok: false, error: { code: "REQUEST_TOO_LARGE", message: oversized }, }; } // Read from client_metadata, not top-level session_id/thread_id: the real // capture carries neither at top level (only nested in client_metadata), // which is why CodexNativeRequest has no top-level fields for them. if ((request.client_metadata?.session_id !== undefined || request.client_metadata?.thread_id !== undefined) && !hasFreshSessionPreamble(request.input)) { return { ok: false, error: { code: "SUSPECTED_PARTIAL_HISTORY", message: "a request with session metadata does not open with the additional_tools preamble; refusing to translate what may be an incremental history as if it were complete", }, }; } const untranslatable = request.input .map((item) => describeUntranslatableItem(item, target.provider)) .find((reason) => reason !== undefined); if (untranslatable) { return { ok: false, error: { code: "UNTRANSLATABLE_REQUEST", message: untranslatable }, }; } // The spec's cardinality rule: when more than one additional_tools item // appears, the last one wins. const additionalTools = request.input .filter((item) => item.type === "additional_tools") .at(-1); const { tools, toolKindByName, reasons: schemaReasons, } = buildClaudeTools(additionalTools); const toolChoice = mapCodexToolChoiceToClaude(request.tool_choice, request.parallel_tool_calls, target.model); const forcesTool = toolChoice.type === "any" || toolChoice.type === "tool"; if (forcesTool && !tools) { return { ok: false, error: { code: "MALFORMED_REQUEST", message: "tool_choice forces a tool call but the request declares no tools", }, }; } if (toolChoice.type === "tool" && !tools?.some((tool) => tool.name === toolChoice.name)) { return { ok: false, error: { code: "MALFORMED_REQUEST", message: `tool_choice names ${JSON.stringify(toolChoice.name)}, which the request does not declare`, }, }; } const messages = coalesceCodexInputToClaudeMessages(request.input); if (messages[0]?.role !== "user") { return { ok: false, error: { code: "UNTRANSLATABLE_REQUEST", message: "the translated history does not open with a user turn, which the Messages API requires", }, }; } const pairingGap = describeToolPairingGap(messages); if (pairingGap) { return { ok: false, error: { code: "UNTRANSLATABLE_REQUEST", message: pairingGap }, }; } const maxTokens = resolveClaudeMaxTokens(target.model, undefined); const systemBlocks = buildSystemBlocksFromDeveloperMessages(request.input); // Thinking is left off in two cases the Messages API would reject: a forced // tool_choice (the same rule oauthFetch.ts applies), and a history whose // final assistant turn calls a tool, which with thinking on must open with a // thinking block. Codex history carries none that can be translated, since // its reasoning is OpenAI ciphertext. const thinking = forcesTool || lastAssistantMessageUsesTool(messages) ? undefined : mapCodexReasoningToThinking(request.reasoning, maxTokens, target.model); const claudeRequest = { model: target.model, messages, max_tokens: maxTokens, ...(systemBlocks.length > 0 ? { system: systemBlocks } : {}), ...(tools ? { tools } : {}), // The Messages API accepts tool_choice only alongside tools. ...(tools ? { tool_choice: toolChoice } : {}), ...(thinking ? { thinking } : {}), stream: request.stream ?? true, }; // Recorded only once translation has fully succeeded, so a request that is // rejected earlier (malformed tool_choice, untranslatable content, a // pairing gap) never fires a degrade counter for work that was discarded. for (const reason of schemaReasons) { recordCodexOutboundSchemaDegraded({ toolName: reason.toolName, reason: reason.reason, }); } for (const item of request.input) { if (item.type === "function_call" && isMalformedFunctionArguments(item.arguments)) { recordCodexOutboundUnsupportedField({ field: "function_call.arguments", reason: "malformed_arguments_wrapped", }); } } return { ok: true, value: claudeRequest, toolKindByName }; } // --------------------------------------------------------------------------- // §1 Trigger policy (stage-c-trigger.md). Pure classification of a native- // Codex-route failure into an outbound-fallback eligibility decision. Owns no // dispatch, no HTTP, no account state — `codexProxyRoutes.ts`'s four insertion // points call this and still apply their own config/loop-prevention/depth // gates on top of a `true` verdict. // --------------------------------------------------------------------------- /** Caps how many times one request may cross between engines (Codex->Anthropic * or Anthropic->Codex) in a single turn. A crossing is an engine change, never * a same-engine account rotation, so this bounds `Codex -> Anthropic -> * Codex -> ...` cycles without limiting ordinary in-pool retries. */ export const MAX_ENGINE_CROSSINGS = 1; /** Read a transport error code the same way `codexProxyRoutes.ts`'s own * (unexported) `getCodexTransportErrorCode` does: a direct `.code`, falling * back to `.cause.code`. Kept here, independently, as a pure utility the * trigger-policy classifier can be exercised against without importing a * route-handler module. */ export function parseCodexErrorCode(error) { if (!error || typeof error !== "object") { return undefined; } const directCode = error.code; if (typeof directCode === "string") { return directCode; } const cause = error.cause; if (!cause || typeof cause !== "object") { return undefined; } const causeCode = cause.code; return typeof causeCode === "string" ? causeCode : undefined; } /** * Classify one `codexProxyRoutes.ts` `dispatch()` failure into an * outbound-fallback eligibility decision. A total function over * `CodexOutboundFailureClass` (exhaustive switch, no `default:`): a missing * case fails to compile under `noImplicitReturns`, never falls through to a * silent `undefined`. * * `context_preflight` / `token_budget` errors are never modeled here: both * are self-contained terminal returns inside `dispatch()`'s own outer `try`, * before any of the four insertion points this classifier serves, so they * never reach it. */ export function classifyCodexOutboundFailure(input) { switch (input.failureClass) { case "no_accounts": return { eligible: true, reason: "no Codex accounts are configured for this request", }; case "pool_exhausted": return { eligible: true, reason: "every configured Codex account is cooling down or otherwise unhealthy", }; case "loop_fallthrough": { // A bare 401/403 is a credential failure on Codex's own OAuth pool, // which says nothing about the fallback target; only the error code can // tell a content-policy 403 from an auth one. if (classifyProxyFailureCode(input.lastErrorCode).retryable === false) { return { eligible: false, reason: `the last exhausted account attempt ended in ${input.lastErrorCode ?? "unknown"}, a rejection of the request itself rather than a Codex pool failure`, }; } const status = input.lastStatus; if (status !== undefined && status >= 400 && status < 500 && status !== 401 && status !== 403 && status !== 429) { return { eligible: false, reason: `the last exhausted account attempt ended in a ${status}, a rejection of the request itself rather than a Codex pool failure`, }; } return { eligible: true, reason: "the account-retry loop exhausted every eligible Codex account without a terminal response", }; } case "non_retryable_transport": { // Reuses the same classifier the Claude engine's own failure handling // relies on, as loop_fallthrough does: a content-policy or // invalid-request code is not a pool problem, and not something a // different upstream is likely to answer differently. if (classifyProxyFailureCode(input.transportErrorCode).retryable === false) { return { eligible: false, reason: `transport error ${input.transportErrorCode ?? "unknown"} rejects the request itself, not a Codex pool failure`, }; } return { eligible: true, reason: `non-retryable transport error ${input.transportErrorCode ?? "unknown"} exhausted this Codex account attempt`, }; } } }