mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-21 22:32:22 +03:00
* 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 8204da668a)
* 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 2b3e63a470)
* 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 <anhth2@vng.com.vn>
This commit is contained in:
committed by
GitHub
parent
217c93d081
commit
9c9ad6bbde
@@ -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)
|
||||
1
changelog.d/fixes/responses-web-search-call-replay.md
Normal file
1
changelog.d/fixes/responses-web-search-call-replay.md
Normal file
@@ -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)
|
||||
@@ -235,12 +235,44 @@ function isGeoBlockEligibleProvider(provider?: string | null): boolean {
|
||||
const CLOUDFLARE_1010_REGEX =
|
||||
/(?<![A-Za-z0-9_-])error[\s_-]?code[\\"':=\s]{0,12}1010(?!\w)|(?<![A-Za-z0-9_-])error[-_]\s?1010(?!\w)\/?/i;
|
||||
|
||||
// A Cloudflare managed/JS challenge is the SAME class of block as a 1010 — the
|
||||
// edge refused the CLIENT's signature and demanded an interactive browser
|
||||
// challenge — but it is a different product surface and carries none of the
|
||||
// 1010 markers. It arrives as a ~12KB text/html interstitial (with header
|
||||
// `cf-mitigated: challenge`), so a body-shape match is the only signal
|
||||
// available to a classifier that sees the body alone.
|
||||
//
|
||||
// Observed verbatim on `POST chatgpt.com/backend-api/codex/responses/input_tokens`
|
||||
// for a HEALTHY Codex OAuth account whose token refreshed successfully in the
|
||||
// same second and which served normal `/responses` traffic seconds before and
|
||||
// after: `window._cf_chl_opt = {... cType: 'managed', cZone: 'chatgpt.com' ...}`.
|
||||
// Without this branch the challenge falls through to FORBIDDEN, and chatCore's
|
||||
// FORBIDDEN handler writes the terminal `banned`/`isActive:false` state that
|
||||
// never auto-recovers — taking the whole provider offline until an operator
|
||||
// reconnects, on a block that says nothing about account health.
|
||||
//
|
||||
// IMPORTANT: these markers are matched as full, distinctive Cloudflare-internal
|
||||
// strings, never as loose words like "challenge" — a provider error body may
|
||||
// legitimately discuss a "challenge" in prose.
|
||||
const CLOUDFLARE_CHALLENGE_MARKERS = [
|
||||
"_cf_chl_opt",
|
||||
"cdn-cgi/challenge-platform",
|
||||
'id="challenge-error-text"',
|
||||
String.raw`id=\"challenge-error-text\"`,
|
||||
] as const;
|
||||
|
||||
export function isCloudflareChallengeInterstitial(errorText: string): boolean {
|
||||
const text = String(errorText || "").toLowerCase();
|
||||
return CLOUDFLARE_CHALLENGE_MARKERS.some((marker) => 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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
'<!DOCTYPE html><html lang="en-US"><head><title>Just a moment...</title></head><body>',
|
||||
'<div class="main-content"><noscript><div class="h2">',
|
||||
'<span id="challenge-error-text">Enable JavaScript and cookies to continue</span>',
|
||||
"</div></noscript></div>",
|
||||
"<script>(function(){window._cf_chl_opt = {cFPWv: 'g',cRay: 'a38b1a06cd3ea3e3',",
|
||||
"cType: 'managed',cZone: 'chatgpt.com',cUPMDTk:\"/backend-api/codex/responses/input_tokens\"};",
|
||||
"var a = document.createElement('script');",
|
||||
"a.src = '/cdn-cgi/challenge-platform/h/g/orchestrate/chl_page/v1?ray=a38b1a06cd3ea3e3';",
|
||||
"})();</script></body></html>",
|
||||
].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]: <html>" 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('<span id="challenge-error-text">'),
|
||||
true,
|
||||
"challenge-error-text span"
|
||||
);
|
||||
assert.equal(
|
||||
isCloudflareChallengeInterstitial(String.raw`<span id=\"challenge-error-text\">`),
|
||||
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", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user