diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 21995f8ebb..bfe087e077 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -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 ─────────────────────────────────────────────────────────────── diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts index 99027b872e..c31277f3b6 100644 --- a/open-sse/utils/error.ts +++ b/open-sse/utils/error.ts @@ -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; } diff --git a/tests/unit/upstream-error-passthrough.test.ts b/tests/unit/upstream-error-passthrough.test.ts index cedfdfb9ea..0152775ff4 100644 --- a/tests/unit/upstream-error-passthrough.test.ts +++ b/tests/unit/upstream-error-passthrough.test.ts @@ -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"); + } + ); +});