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",