From 043756bc024afbd77aba7dbccfd5a8b86da5d6d9 Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:27:58 -0300 Subject: [PATCH] feat(sse): selective upstream 4xx error passthrough util --- open-sse/utils/upstreamErrorPassthrough.ts | 38 +++++++++++++++ tests/unit/upstream-error-passthrough.test.ts | 46 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 open-sse/utils/upstreamErrorPassthrough.ts create mode 100644 tests/unit/upstream-error-passthrough.test.ts diff --git a/open-sse/utils/upstreamErrorPassthrough.ts b/open-sse/utils/upstreamErrorPassthrough.ts new file mode 100644 index 0000000000..651aa2f77a --- /dev/null +++ b/open-sse/utils/upstreamErrorPassthrough.ts @@ -0,0 +1,38 @@ +/** + * Selective upstream 4xx error passthrough (Claude Code auto-recover contract). + * + * Claude Code matches the upstream error WORDING to auto-disable capabilities + * (thinking / output_config) for the rest of the conversation. Wrapping the body + * via buildErrorBody() truncates the message and breaks that recovery. For + * upstream-originated 4xx errors the body is the provider's public API message — + * not our internals — so it is safe and required to relay it verbatim. + * OmniRoute-generated errors MUST keep using buildErrorBody() (Hard Rule #12). + */ +const PASSTHROUGH_MIN = 400; +const PASSTHROUGH_MAX = 499; +// 401/403/407: auth-adjacent — our own credential context may leak via provider +// echoes; keep those sanitized. 400/404/408/413/422/429 carry the capability and +// quota wording the client needs. +const EXCLUDED_STATUSES = new Set([401, 403, 407]); +const INTERNAL_LEAK_RE = /\sat\s\/|node_modules|omniroute\//i; + +export function shouldPassthroughUpstreamError(statusCode: number, upstreamBody: unknown): boolean { + if (statusCode < PASSTHROUGH_MIN || statusCode > PASSTHROUGH_MAX) return false; + if (EXCLUDED_STATUSES.has(statusCode)) return false; + if (!upstreamBody || typeof upstreamBody !== "object") return false; + const text = JSON.stringify(upstreamBody); + if (INTERNAL_LEAK_RE.test(text)) return false; + return true; +} + +export function buildPassthroughErrorResponse( + statusCode: number, + upstreamBody: unknown, + headers?: Record +): Response | null { + if (!shouldPassthroughUpstreamError(statusCode, upstreamBody)) return null; + return new Response(JSON.stringify(upstreamBody), { + status: statusCode, + headers: { "Content-Type": "application/json", ...(headers || {}) }, + }); +} diff --git a/tests/unit/upstream-error-passthrough.test.ts b/tests/unit/upstream-error-passthrough.test.ts new file mode 100644 index 0000000000..cedfdfb9ea --- /dev/null +++ b/tests/unit/upstream-error-passthrough.test.ts @@ -0,0 +1,46 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + shouldPassthroughUpstreamError, + buildPassthroughErrorResponse, +} from "../../open-sse/utils/upstreamErrorPassthrough.ts"; + +test("upstream error passthrough", async (t) => { + await t.test("4xx com corpo JSON de erro do provider é elegível", () => { + const body = { + type: "error", + error: { type: "invalid_request_error", message: "thinking.type: adaptive is not supported" }, + }; + assert.equal(shouldPassthroughUpstreamError(400, body), true); + }); + await t.test("5xx NÃO é elegível (segue sanitizado)", () => { + assert.equal(shouldPassthroughUpstreamError(500, { error: { message: "x" } }), false); + }); + await t.test("corpo com cara de vazamento interno (stack trace) NÃO é elegível", () => { + assert.equal( + shouldPassthroughUpstreamError(400, { + error: { message: "Error\n at /usr/lib/node_modules/omniroute/x.js:1" }, + }), + false + ); + }); + await t.test( + "401/407 NÃO são elegíveis (credencial nossa pode vazar em www-authenticate)", + () => { + assert.equal(shouldPassthroughUpstreamError(401, { error: { message: "bad key" } }), false); + } + ); + await t.test("buildPassthroughErrorResponse preserva corpo byte-a-byte", async () => { + const body = { + type: "error", + error: { type: "invalid_request_error", message: "thinking.type: nope" }, + }; + const res = buildPassthroughErrorResponse(400, body); + assert.ok(res); + assert.equal(res.status, 400); + assert.deepEqual(await res.json(), body); + }); + await t.test("retorna null quando inelegível", () => { + assert.equal(buildPassthroughErrorResponse(500, {}), null); + }); +});