diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 7bd5cb1074..af4caad913 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1627,6 +1627,7 @@ async function handleResponsesInner( }; let selectedForwardHeaders = req.headers; let subagentFallbackAccountId = config.activeCodexAccountId ?? null; + let subagentFallbackPreviewAccountId: string | null | undefined; let subagentQuotaFailureModel = parsed.modelId; const parentThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() ?? null; @@ -1652,6 +1653,7 @@ async function handleResponsesInner( undefined, previewSelectionOptions, ); + subagentFallbackPreviewAccountId = previewAccountId; subagentFallbackAccountId = previewAccountId ?? config.activeCodexAccountId ?? null; const fallback = applySubagentModelFallback( parsed, @@ -1737,6 +1739,46 @@ async function handleResponsesInner( // text. Bar it from the continuation cache before any recording path can reach it — // that cache is persisted to disk, which would defeat the recovery cache's TTL. markBodyNonPersistable(parsed._rawBody); + + // The ciphertext-only pass intentionally excludes routed candidates. Once recovery + // makes the assignment readable, run selection again with the full configured chain + // and keep the route in sync with any newly selected fallback. + const fallback = applySubagentModelFallback( + parsed, + req.headers, + config, + subagentFallbackPreviewAccountId, + Date.now(), + false, + previewSelectionOptions, + ); + if (fallback) { + (logCtx as unknown as Record).subagentModelFallbackFrom = fallback.from; + (logCtx as unknown as Record).subagentModelFallbackTo = fallback.to; + if (isInjectionDebugEnabled()) { + injectionDebugLog(`[opencodex] subagent model fallback ${fallback.from} -> ${fallback.to}`); + } + } + subagentQuotaFailureModel = fallback?.to ?? parsed.modelId; + + if (fallback?.to && !slugsEquivalent(fallback.to, route.modelId)) { + try { + route = routeModel(config, fallback.to, evidenceFromBody(parsed._rawBody)); + logCtx.routeDecision = route.routeDecision; + } catch (err) { + if (err instanceof NoAvailableComboTargetsError) { + return comboUnavailableResponse(err.message); + } + if (err instanceof NoEligiblePolicyCandidateError) { + logCtx.routeDecision = err.trace; + } + return formatErrorResponse( + 404, + "invalid_request_error", + err instanceof Error ? err.message : String(err), + ); + } + } toolBridgeMaps = buildToolBridgeMaps(parsed, translatorBudget); } catch { unreadableEncryptedAgentTask = true; diff --git a/tests/agent-task-recovery-fallback.test.ts b/tests/agent-task-recovery-fallback.test.ts new file mode 100644 index 0000000000..62b7053c2a --- /dev/null +++ b/tests/agent-task-recovery-fallback.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + noteSubagentModelFailure, + resetSubagentModelFallbackStateForTests, +} from "../src/codex/subagent-model-fallback"; +import { resetAgentTaskRecoveryState } from "../src/server/responses/agent-task-recovery"; +import { + codexHeaders, + encryptedInput, + originalFetch, + post, + providerResponse, + recoverySse, + routedConfig, +} from "./helpers/agent-task-recovery"; + +describe("agent task recovery fallback routing", () => { + beforeEach(() => { + resetAgentTaskRecoveryState(); + resetSubagentModelFallbackStateForTests(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + resetAgentTaskRecoveryState(); + resetSubagentModelFallbackStateForTests(); + }); + + test("routes a recovered task through the healthy routed fallback", async () => { + const config = routedConfig(); + config.subagentModelFallback = ["xai/grok-4.6"]; + noteSubagentModelFailure("xai/grok-4.5", "429", config); + + const fetchedUrls: string[] = []; + const providerModels: string[] = []; + globalThis.fetch = (async (input, init) => { + const url = String(input); + fetchedUrls.push(url); + if (url.includes("chatgpt.com")) { + return new Response(recoverySse("Dispatch this recovered task through the healthy fallback."), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + } + + const raw = typeof init?.body === "string" ? init.body : "{}"; + const body = JSON.parse(raw) as { model?: string }; + providerModels.push(body.model ?? ""); + return providerResponse(); + }) as typeof fetch; + + const response = await post( + config, + "xai/grok-4.5", + encryptedInput(), + codexHeaders(), + ); + + expect(response.status).toBe(200); + expect(fetchedUrls).toHaveLength(2); + expect(fetchedUrls[0]).toContain("chatgpt.com/backend-api/codex"); + expect(fetchedUrls[1]).toContain("api.x.ai"); + expect(providerModels).toEqual(["grok-4.6"]); + }); +});