mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user