Compare commits

...

2 Commits

Author SHA1 Message Date
diegosouzapw
b343d44512 feat(sse): relay upstream 4xx error bodies verbatim on the Anthropic request path
createErrorResult() gains an opt-in 7th param opts.passthrough; when set and
the upstream body is eligible (per shouldPassthroughUpstreamError), the
returned response body is the upstream 4xx body verbatim instead of the
sanitized wrapper. Internal classification fields (error/rawMessage/
errorType/errorCode) are never affected.

Wired into chatCore.ts's 7 upstream-error createErrorResult call sites
(model_unavailable / context_overflow / generic upstream-error branches),
gated on sourceFormat === FORMATS.CLAUDE so only /v1/messages requests get
the verbatim body — OpenAI-format paths are unchanged.
2026-07-25 18:44:21 -03:00
diegosouzapw
043756bc02 feat(sse): selective upstream 4xx error passthrough util 2026-07-25 18:35:08 -03:00
4 changed files with 183 additions and 8 deletions

View File

@@ -3766,7 +3766,8 @@ export async function handleChatCore({
retryAfterMs,
upstreamErrorCode,
upstreamErrorType,
upstreamErrorBody
upstreamErrorBody,
{ passthrough: sourceFormat === FORMATS.CLAUDE }
);
}
} catch {
@@ -3785,7 +3786,8 @@ export async function handleChatCore({
retryAfterMs,
upstreamErrorCode,
upstreamErrorType,
upstreamErrorBody
upstreamErrorBody,
{ passthrough: sourceFormat === FORMATS.CLAUDE }
);
}
} else {
@@ -3804,7 +3806,8 @@ export async function handleChatCore({
retryAfterMs,
upstreamErrorCode,
upstreamErrorType,
upstreamErrorBody
upstreamErrorBody,
{ passthrough: sourceFormat === FORMATS.CLAUDE }
);
}
} else if (isContextOverflowError(statusCode, message)) {
@@ -3852,7 +3855,8 @@ export async function handleChatCore({
retryAfterMs,
upstreamErrorCode,
upstreamErrorType,
upstreamErrorBody
upstreamErrorBody,
{ passthrough: sourceFormat === FORMATS.CLAUDE }
);
}
} catch {
@@ -3871,7 +3875,8 @@ export async function handleChatCore({
retryAfterMs,
upstreamErrorCode,
upstreamErrorType,
upstreamErrorBody
upstreamErrorBody,
{ passthrough: sourceFormat === FORMATS.CLAUDE }
);
}
} else {
@@ -3890,7 +3895,8 @@ export async function handleChatCore({
retryAfterMs,
upstreamErrorCode,
upstreamErrorType,
upstreamErrorBody
upstreamErrorBody,
{ passthrough: sourceFormat === FORMATS.CLAUDE }
);
}
} else {
@@ -3916,7 +3922,8 @@ export async function handleChatCore({
retryAfterMs,
upstreamErrorCode,
upstreamErrorType,
upstreamErrorBody
upstreamErrorBody,
{ passthrough: sourceFormat === FORMATS.CLAUDE }
);
}
// ── End T5 ───────────────────────────────────────────────────────────────

View File

@@ -3,6 +3,7 @@ import { unwrapClinepassEnvelope } from "./clinepassEnvelope.ts";
import { getDefaultErrorMessage, getErrorInfo } from "../config/errorConfig.ts";
import { normalizePayloadForLog } from "@/lib/logPayloads";
import type { ModelCooldownErrorPayload } from "@/types";
import { buildPassthroughErrorResponse } from "./upstreamErrorPassthrough.ts";
/**
* Sanitize an error message to prevent stack trace exposure in API responses.
@@ -520,7 +521,8 @@ export function createErrorResult(
retryAfterMs: number | null = null,
errorCode?: string,
errorType?: string,
upstreamDetails?: unknown
upstreamDetails?: unknown,
opts?: { passthrough?: boolean }
) {
const body = buildErrorBody(statusCode, message, upstreamDetails);
if (errorCode) {
@@ -568,6 +570,22 @@ export function createErrorResult(
result.retryAfterMs = retryAfterMs;
}
// Opt-in relay of the verbatim upstream error body (Claude Code auto-recover
// contract — see upstreamErrorPassthrough.ts). Only swaps `result.response`;
// `result.error`/`rawMessage`/`errorType`/`errorCode` stay untouched so
// server-side classification (checkFallbackError, combo retry logic, etc.)
// never sees a different value depending on this flag.
if (opts?.passthrough) {
const passthroughResponse = buildPassthroughErrorResponse(
statusCode,
upstreamDetails,
retryAfterMs ? { "Retry-After": String(Math.ceil(retryAfterMs / 1000)) } : undefined
);
if (passthroughResponse) {
result.response = passthroughResponse;
}
}
return result;
}

View File

@@ -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<string, string>
): Response | null {
if (!shouldPassthroughUpstreamError(statusCode, upstreamBody)) return null;
return new Response(JSON.stringify(upstreamBody), {
status: statusCode,
headers: { "Content-Type": "application/json", ...(headers || {}) },
});
}

View File

@@ -0,0 +1,112 @@
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);
});
});
test("createErrorResult opt-in passthrough (opts.passthrough)", async (t) => {
await t.test(
"com opts.passthrough e corpo elegível, result.response é o corpo upstream verbatim",
async () => {
const { createErrorResult } = await import("../../open-sse/utils/error.ts");
const upstreamBody = {
type: "error",
error: {
type: "invalid_request_error",
message: "thinking.type: adaptive is not supported",
},
};
const result = createErrorResult(400, "msg", null, "code", "type", upstreamBody, {
passthrough: true,
});
assert.deepEqual(await result.response.json(), upstreamBody);
assert.equal(result.status, 400);
// Internal classification fields must never be affected by passthrough.
assert.equal(typeof result.error, "string");
assert.notEqual(result.error, JSON.stringify(upstreamBody));
}
);
await t.test("sem opts, comportamento atual (corpo sanitizado) é preservado", async () => {
const { createErrorResult } = await import("../../open-sse/utils/error.ts");
const upstreamBody = {
type: "error",
error: { type: "invalid_request_error", message: "thinking.type: adaptive is not supported" },
};
const result = createErrorResult(400, "msg", null, "code", "type", upstreamBody);
const body = (await result.response.json()) as { error?: { message?: string } };
assert.ok(body.error?.message, "sanitized body keeps the wrapped error.message shape");
assert.ok(
!JSON.stringify(body).includes(" at /"),
"sanitized body never leaks stack-trace-like text"
);
});
await t.test("com retryAfterMs e passthrough elegível, header Retry-After é setado", async () => {
const { createErrorResult } = await import("../../open-sse/utils/error.ts");
const upstreamBody = {
type: "error",
error: { type: "rate_limit_error", message: "slow down" },
};
const result = createErrorResult(429, "msg", 5000, "code", "type", upstreamBody, {
passthrough: true,
});
assert.equal(result.response.headers.get("Retry-After"), "5");
assert.deepEqual(await result.response.json(), upstreamBody);
});
await t.test(
"opts.passthrough true mas corpo inelegível (401) cai no corpo sanitizado atual",
async () => {
const { createErrorResult } = await import("../../open-sse/utils/error.ts");
const upstreamBody = { error: { message: "bad key" } };
const result = createErrorResult(401, "unauthorized", null, "code", "type", upstreamBody, {
passthrough: true,
});
const body = (await result.response.json()) as { error?: { message?: string } };
assert.notDeepEqual(body, upstreamBody);
assert.ok(body.error?.message, "sanitized body keeps the wrapped error.message shape");
}
);
});