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.
This commit is contained in:
diegosouzapw
2026-07-25 18:44:21 -03:00
parent 043756bc02
commit b343d44512
3 changed files with 99 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

@@ -44,3 +44,69 @@ test("upstream error passthrough", async (t) => {
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");
}
);
});