diff --git a/changelog.d/fixes/13161-cloudflare-managed-challenge-fingerprint.md b/changelog.d/fixes/13161-cloudflare-managed-challenge-fingerprint.md new file mode 100644 index 0000000000..a9cb3cb08d --- /dev/null +++ b/changelog.d/fixes/13161-cloudflare-managed-challenge-fingerprint.md @@ -0,0 +1 @@ +- **resilience:** a Cloudflare managed challenge (`cf-mitigated: challenge` / challenge HTML on 403) is classified as a fingerprint rejection and retried on another account/transport instead of banning the connection (#13161 — thanks @anhtran-ai) diff --git a/changelog.d/fixes/responses-web-search-call-replay.md b/changelog.d/fixes/responses-web-search-call-replay.md new file mode 100644 index 0000000000..429871da47 --- /dev/null +++ b/changelog.d/fixes/responses-web-search-call-replay.md @@ -0,0 +1 @@ +- **responses:** Responses-to-Chat fallback now skips replayed `web_search_call` metadata while preserving the paired function result, preventing deterministic HTTP 400 failures on follow-up turns routed to Chat Completions providers (#13304 — thanks @anhtran-ai) diff --git a/open-sse/services/errorClassifier.ts b/open-sse/services/errorClassifier.ts index 095d329a44..e72df311d3 100644 --- a/open-sse/services/errorClassifier.ts +++ b/open-sse/services/errorClassifier.ts @@ -235,12 +235,44 @@ function isGeoBlockEligibleProvider(provider?: string | null): boolean { const CLOUDFLARE_1010_REGEX = /(? text.includes(marker.toLowerCase())); +} + export function isCloudflareFingerprintRejection(errorText: string): boolean { const text = String(errorText || "").toLowerCase(); return ( CLOUDFLARE_1010_REGEX.test(text) || text.includes("browser_signature_banned") || - text.includes("fingerprint_rejection") + text.includes("fingerprint_rejection") || + isCloudflareChallengeInterstitial(text) ); } diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 697f6371b4..a883250ee0 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -516,14 +516,16 @@ export function openaiResponsesToOpenAIRequest( continue; } - // Skip tool_search_call items. These are Responses-API-only metadata items - // emitted by Codex's dynamic tool-search optimization: they record that the - // model queried a subset of available tools, but carry no content that Chat - // Completions can represent. Throwing here would break every multi-turn - // conversation where Codex previously used tool_search (the whole session - // would carry tool_search_call items forward in `input`). Skipping matches - // the reasoning-item policy: display-only metadata, no chat side-effect. - if (itemType === "tool_search_call" || itemType === "tool_search_result") { + // Skip Responses-only search metadata. tool_search_call/tool_search_result + // are Codex's dynamic tool-discovery items; web_search_call is emitted by + // OmniRoute's web-search fallback alongside function_call_output, which + // already carries the result for Chat Completions. Replayed metadata has no + // lossless Chat representation and must not fail a follow-up turn. + if ( + itemType === "tool_search_call" || + itemType === "tool_search_result" || + itemType === "web_search_call" + ) { continue; } diff --git a/tests/unit/error-classifier.test.ts b/tests/unit/error-classifier.test.ts index 37e6cbe708..e662831e0b 100644 --- a/tests/unit/error-classifier.test.ts +++ b/tests/unit/error-classifier.test.ts @@ -5,6 +5,7 @@ const { classifyProviderError, isResourceNotFoundResponse, isCloudflareFingerprintRejection, + isCloudflareChallengeInterstitial, isAnthropicOAuthProvider, isAnthropicRequestNotAllowed, PROVIDER_ERROR_TYPES, @@ -404,6 +405,115 @@ test("classifyProviderError: 422 without the BYOP code stays unclassified (no mo assert.equal(classifyProviderError(422, "some other body", "antigravity"), null); }); +// ───────────────────────────────────────────────────────────────────────────── +// Cloudflare managed-challenge interstitial (Codex /responses/input_tokens) +// ───────────────────────────────────────────────────────────────────────────── + +// Trimmed but verbatim-shaped excerpt of the interstitial served by Cloudflare on +// POST chatgpt.com/backend-api/codex/responses/input_tokens (response headers carry +// `cf-mitigated: challenge`, `server: cloudflare`, `content-type: text/html`). +const CF_MANAGED_CHALLENGE_BODY = [ + 'Just a moment...', + '
", + "", +].join(""); + +test("classifyProviderError: Cloudflare managed challenge on codex => FINGERPRINT_REJECTION, not FORBIDDEN", () => { + // Regression guard: this body previously fell through the whole 403 ladder to + // FORBIDDEN, which chatCore persists as the terminal banned/isActive:false state. + // The account was healthy — its OAuth token refreshed successfully in the same + // second and normal /responses traffic succeeded seconds before and after. + const result = classifyProviderError(403, CF_MANAGED_CHALLENGE_BODY, "codex"); + assert.equal( + result, + PROVIDER_ERROR_TYPES.FINGERPRINT_REJECTION, + "a Cloudflare managed challenge must never terminalize the connection" + ); + assert.notEqual(result, PROVIDER_ERROR_TYPES.FORBIDDEN, "must not fall through to FORBIDDEN"); +}); + +test("classifyProviderError: managed challenge nested in the gateway error.message => FINGERPRINT_REJECTION", () => { + // The executor commonly wraps the upstream body inside error.message, which is + // how the operator-visible "[403]: " message is produced. + const body = JSON.stringify({ + error: { message: `[codex/gpt-5.6-sol] [403]: ${CF_MANAGED_CHALLENGE_BODY}` }, + }); + assert.equal( + classifyProviderError(403, body, "codex"), + PROVIDER_ERROR_TYPES.FINGERPRINT_REJECTION + ); +}); + +test("isCloudflareChallengeInterstitial: each distinctive marker is recognized", () => { + assert.equal(isCloudflareChallengeInterstitial(CF_MANAGED_CHALLENGE_BODY), true, "full body"); + assert.equal( + isCloudflareChallengeInterstitial("window._cf_chl_opt = {cType: 'managed'}"), + true, + "_cf_chl_opt" + ); + assert.equal( + isCloudflareChallengeInterstitial("/cdn-cgi/challenge-platform/h/g/orchestrate/chl_page/v1"), + true, + "challenge-platform path" + ); + assert.equal( + isCloudflareChallengeInterstitial(''), + true, + "challenge-error-text span" + ); + assert.equal( + isCloudflareChallengeInterstitial(String.raw``), + true, + "escaped-quote nested form" + ); +}); + +test("isCloudflareChallengeInterstitial: prose mentioning a challenge is NOT a match (FP guard)", () => { + // The markers are full Cloudflare-internal strings, never the loose word + // "challenge" — provider bodies legitimately discuss challenges in prose. + assert.equal( + isCloudflareChallengeInterstitial("this request failed a security challenge, please retry"), + false, + "prose challenge" + ); + assert.equal( + isCloudflareChallengeInterstitial( + '{"error":"challenge_required","detail":"solve a challenge"}' + ), + false, + "challenge_required code" + ); + assert.equal( + isCloudflareChallengeInterstitial("challenge-platform"), + false, + "bare, no cdn-cgi path" + ); + assert.equal(isCloudflareChallengeInterstitial(""), false, "empty body"); +}); + +test("classifyProviderError: the terminal 403 paths are unchanged by the challenge branch", () => { + // Regression guard for the neighbouring carve-outs: a genuine permission 403 + // still bans, and ChatGPT Web's Sentinel/Turnstile 403 (#8813) stays FORBIDDEN. + assert.equal( + classifyProviderError(403, { error: { message: "you do not have permission" } }, "codex"), + PROVIDER_ERROR_TYPES.FORBIDDEN + ); + assert.equal( + classifyProviderError(403, JSON.stringify({ error: "SENTINEL_BLOCKED" }), "chatgpt-web"), + PROVIDER_ERROR_TYPES.FORBIDDEN + ); + assert.equal( + classifyProviderError(403, "Turnstile required", "chatgpt-web"), + PROVIDER_ERROR_TYPES.FORBIDDEN + ); +}); + // ── Anthropic OAuth 403 "Request not allowed" is a per-request refusal, not a ban ── test("classifyProviderError: claude 403 'Request not allowed' (Anthropic body) => REQUEST_REJECTED, never FORBIDDEN", () => { diff --git a/tests/unit/responses-chat-translation-gaps.test.ts b/tests/unit/responses-chat-translation-gaps.test.ts index b7892b1478..e2db44a269 100644 --- a/tests/unit/responses-chat-translation-gaps.test.ts +++ b/tests/unit/responses-chat-translation-gaps.test.ts @@ -125,7 +125,6 @@ test("Responses -> Chat rejects input item types without a lossless Chat equival { type: "item_reference", id: "item_123" }, { type: "computer_call_output", call_id: "call_1", output: {} }, { type: "mcp_call", name: "remote", arguments: "{}" }, - { type: "web_search_call", id: "search_1" }, { unexpected: true }, ]) { assert.throws( diff --git a/tests/unit/responses-tool-search-call-skip.test.ts b/tests/unit/responses-tool-search-call-skip.test.ts index f94b5f6df4..eb68718470 100644 --- a/tests/unit/responses-tool-search-call-skip.test.ts +++ b/tests/unit/responses-tool-search-call-skip.test.ts @@ -58,6 +58,51 @@ test("tool_search_result input item is silently skipped", () => { assert.equal(messages[0].role, "user"); }); +test("web_search_call replay is skipped while its function result is preserved", () => { + const body = { + model: "test-model", + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "Find docs" }] }, + { + type: "function_call", + call_id: "call_search", + name: "omniroute_web_search", + arguments: '{"query":"OmniRoute docs"}', + }, + { + type: "function_call_output", + call_id: "call_search", + output: '{"success":true,"results":[{"url":"https://example.com","title":"Example"}]}', + }, + { + type: "web_search_call", + id: "ws_call_search", + status: "completed", + action: { + type: "web_search", + query: "OmniRoute docs", + sources: [{ title: "Example", url: "https://example.com", caption: "Result" }], + }, + }, + ], + stream: false, + }; + let result; + assert.doesNotThrow(() => { + result = translateRequest("openai-responses", "openai", "test-model", body, false); + }, "web_search_call replay must not throw"); + const messages = ( + result as { messages?: Array<{ role?: string; tool_calls?: unknown; content?: unknown }> } + ).messages; + assert.ok(Array.isArray(messages)); + assert.equal(messages.length, 3, "web_search_call metadata must not create a duplicate message"); + assert.deepEqual( + messages.map((message) => message.role), + ["user", "assistant", "tool"] + ); + assert.equal(messages[2].content, body.input[2].output); +}); + test("multiple tool_search_call items interspersed with messages are skipped in order", () => { const body = { model: "test-model",