From 9c9ad6bbdef961cee708c17c601369bb117276a2 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 18 Sep 2026 11:35:50 -0300 Subject: [PATCH] fix: land #13161 and #13304 by cherry-pick (locked organization fork) (#14090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(resilience): treat a Cloudflare managed challenge as a fingerprint rejection, not a ban A Cloudflare managed/JS challenge served in front of an upstream provider is the same class of block as a Cloudflare 1010 — the edge refused the CLIENT's signature — but it is a different product surface and carries none of the 1010 markers isCloudflareFingerprintRejection() looks for. It therefore fell through the entire 403 ladder in classifyProviderError() to the terminal default FORBIDDEN, which chatCore persists via writeTerminalStatus() as testStatus=banned / isActive=false. That state never auto-recovers, so a single challenge takes the whole provider offline until an operator reconnects in the dashboard. Observed on POST chatgpt.com/backend-api/codex/responses/input_tokens for a healthy Codex OAuth account: the response carried cf-mitigated: challenge, server: cloudflare and a ~12KB text/html interstitial with window._cf_chl_opt = {... cType: 'managed', cZone: 'chatgpt.com' ...}. The same connection refreshed its OAuth token successfully in the same second and served normal /responses traffic seconds before and after, so the account was never banned upstream. Classify the interstitial as FINGERPRINT_REJECTION, reusing the existing non-terminal precedent from #9929: authTerminalStatus already treats that type as non-terminal, so the request falls through to the next combo target and the account state stays untouched. The markers are matched as full, distinctive Cloudflare-internal strings (_cf_chl_opt, cdn-cgi/challenge-platform, the challenge-error-text span id including its escaped-quote nested form) and never as the loose word "challenge", so provider bodies discussing a challenge in prose are unaffected. Tests cover the full interstitial, the gateway-nested error.message form, each marker individually, prose false-positive guards, and regression guards proving a genuine permission 403 and the ChatGPT Web Sentinel/Turnstile 403 (#8813) both still classify as FORBIDDEN. (cherry picked from commit 8204da668a845af678e3059c0b85b47f4bec94e3) * fix(translator): skip replayed web_search_call metadata in Responses-to-Chat OmniRoute's web-search fallback emits a native web_search_call output item alongside function_call/function_call_output. Responses clients keep that item in conversation history and replay it in the next request's input. When the follow-up turn routes to a Chat Completions target (Claude), the translator hit its default unsupported-feature branch and returned a deterministic HTTP 400: Unsupported Responses API feature: input item type 'web_search_call' cannot be represented in Chat Completions Skip the replayed metadata next to tool_search_call/tool_search_result. The paired function_call_output still carries the search results, so no context is lost and the sources are not duplicated into assistant history. (cherry picked from commit 2b3e63a470d0bc35f608819f9b4480c9fc7b587f) * chore(changelog): credit the locked-fork landings of #13161 and #13304 Both PRs come from an organization fork (azox-ai) that refuses maintainer pushes even with maintainerCanModify=true, so they cannot be re-synced in place and are landed here by cherry-pick with the contributor's authorship preserved (merge-gates §6). GitHub may not mark the PRs Merged, hence the explicit credit in the fragments. --------- Co-authored-by: anhth2 --- ...loudflare-managed-challenge-fingerprint.md | 1 + .../fixes/responses-web-search-call-replay.md | 1 + open-sse/services/errorClassifier.ts | 34 +++++- .../translator/request/openai-responses.ts | 18 +-- tests/unit/error-classifier.test.ts | 110 ++++++++++++++++++ .../responses-chat-translation-gaps.test.ts | 1 - .../responses-tool-search-call-skip.test.ts | 45 +++++++ 7 files changed, 200 insertions(+), 10 deletions(-) create mode 100644 changelog.d/fixes/13161-cloudflare-managed-challenge-fingerprint.md create mode 100644 changelog.d/fixes/responses-web-search-call-replay.md 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",