mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Compare commits
2 Commits
4053e2314a
...
feat/upstr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b343d44512 | ||
|
|
043756bc02 |
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
38
open-sse/utils/upstreamErrorPassthrough.ts
Normal file
38
open-sse/utils/upstreamErrorPassthrough.ts
Normal 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 || {}) },
|
||||
});
|
||||
}
|
||||
112
tests/unit/upstream-error-passthrough.test.ts
Normal file
112
tests/unit/upstream-error-passthrough.test.ts
Normal 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");
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user