mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 02:42:24 +03:00
Validado sobre o tip de `release/v3.8.51` depois de reconciliar com o #12620, que entrou primeiro nesta mesma sessão e ataca a mesma classe de problema por outra arquitetura. **A colisão e como foi resolvida.** O #12620 consertou o GHSA-qv45-56jc-4wmj adicionando `RAW_CREDENTIAL_PATTERNS` a `error.ts` e importando-os em `upstreamErrorPassthrough.ts`. Este PR resolve o mesmo problema quebrando `error.ts` em `errorSanitization.ts` + `errorPathRedaction.ts`. Mantive a divisão em módulos deste PR, porque ao comparar os dois vocabulários o dele já era mais amplo: o `STRONG_CREDENTIAL_TOKEN` daqui cobre `sk-`/`sk_` **com lookbehind e uma variante para a forma embutida** (que pega `sk-proj-…`), mais Slack `xox-`, AWS `AKIA`/`ASIA`, `github_pat_`/`ghp_`/`glpat-` e JWT de três segmentos. A única forma que o #12620 carregava e este conjunto não tinha era a chave do Google (`AIza…`) — adicionada aqui, com o mesmo quantificador limitado que os irmãos usam (AGENTS.md → PII §1, já que isso roda sobre corpos upstream não confiáveis). **A verificação não foi por inspeção.** Rodei as suítes do próprio #12620 contra esta estrutura: **48/48** em `error-sanitizer-sk-key-qv45`, `bifrost-relay-response-leak-9m72`, `search-baseurl-client-override-3f8g` e `search-baseurl-ssrf-guard` — incluindo a asserção anti-drift daquela suíte, que é o oráculo certo aqui: *para todo corpo que a camada de passthrough recusa como vazante, o sanitizador de fallback não pode devolvê-lo intacto*. Ela passa, então a propriedade de segurança dos três GHSAs sobrevive à troca de arquitetura. Os 21 arquivos de teste deste PR: **259/259**. `typecheck:core` limpo.
317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
import { FORMATS } from "../translator/formats.ts";
|
|
import { buildErrorBody, sanitizeErrorMessage } from "./error.ts";
|
|
import { projectResponsesFailureOutput } from "./responsesFailureOutput.ts";
|
|
|
|
/**
|
|
* Upstream stream-failure normalization + client-format error framing.
|
|
*
|
|
* Extracted from stream.ts (file-size gate, #9314) — pure functions operating only
|
|
* on plain payload objects, no dependency on the SSE stream/controller state.
|
|
*/
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
|
|
export type StreamFailurePayload = {
|
|
status: number;
|
|
message: string;
|
|
code?: string;
|
|
type?: string;
|
|
};
|
|
|
|
export type ProjectedStreamFailureEvent = {
|
|
internalFailure: StreamFailurePayload;
|
|
publicMessage: string;
|
|
publicPayload: JsonRecord;
|
|
};
|
|
|
|
export type PreparedTranslatedStreamFailure = {
|
|
record: JsonRecord;
|
|
providerPayload: JsonRecord;
|
|
internalFailure: StreamFailurePayload;
|
|
publicMessage: string;
|
|
};
|
|
|
|
export function projectCompletedStreamError(
|
|
failure: StreamFailurePayload | null | undefined
|
|
): JsonRecord | null {
|
|
if (!failure) return null;
|
|
const status = Number.isInteger(failure.status) ? failure.status : 502;
|
|
return buildErrorBody(status, failure.message, undefined, {
|
|
type: failure.type ?? "server_error",
|
|
code: String(failure.status ?? 502),
|
|
}).error;
|
|
}
|
|
|
|
function asRecord(value: unknown): JsonRecord {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
|
}
|
|
|
|
const RESPONSES_FAILURE_SCALAR_FIELDS = [
|
|
"id",
|
|
"object",
|
|
"created_at",
|
|
"completed_at",
|
|
"background",
|
|
"model",
|
|
"max_output_tokens",
|
|
"max_tool_calls",
|
|
"parallel_tool_calls",
|
|
"previous_response_id",
|
|
"service_tier",
|
|
"store",
|
|
"temperature",
|
|
"top_p",
|
|
"truncation",
|
|
] as const;
|
|
|
|
const ABSOLUTE_PATH_SEGMENT =
|
|
/(?:^|[\\/])(?:Users|app|etc|home|opt|private|root|srv|tmp|usr|var|workspace)[\\/]/i;
|
|
|
|
function projectResponsesFailureString(key: string, value: string): string {
|
|
const sanitized = sanitizeErrorMessage(value);
|
|
if (sanitized !== value || ABSOLUTE_PATH_SEGMENT.test(value)) return "[REDACTED]";
|
|
if (
|
|
(key === "id" || key === "previous_response_id") &&
|
|
!/^[A-Za-z0-9][\w.:-]{0,511}$/.test(value)
|
|
) {
|
|
return "[REDACTED]";
|
|
}
|
|
if (key === "model" && !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/.test(value)) {
|
|
return "[REDACTED]";
|
|
}
|
|
return sanitized;
|
|
}
|
|
|
|
function projectResponsesFailureUsage(value: unknown): JsonRecord | null {
|
|
const usage = asRecord(value);
|
|
const projected: JsonRecord = {};
|
|
for (const key of ["input_tokens", "output_tokens", "total_tokens"] as const) {
|
|
if (typeof usage[key] === "number" && Number.isFinite(usage[key])) {
|
|
projected[key] = usage[key];
|
|
}
|
|
}
|
|
const allowedDetailFields = {
|
|
input_tokens_details: new Set(["cached_tokens"]),
|
|
output_tokens_details: new Set([
|
|
"reasoning_tokens",
|
|
"accepted_prediction_tokens",
|
|
"rejected_prediction_tokens",
|
|
]),
|
|
} as const;
|
|
for (const key of ["input_tokens_details", "output_tokens_details"] as const) {
|
|
const details = asRecord(usage[key]);
|
|
const projectedDetails = Object.fromEntries(
|
|
Object.entries(details).filter(
|
|
([detailKey, detail]) =>
|
|
allowedDetailFields[key].has(detailKey) &&
|
|
typeof detail === "number" &&
|
|
Number.isFinite(detail)
|
|
)
|
|
);
|
|
if (Object.keys(projectedDetails).length > 0) projected[key] = projectedDetails;
|
|
}
|
|
return Object.keys(projected).length > 0 ? projected : null;
|
|
}
|
|
|
|
function projectResponsesFailureObject(response: JsonRecord, publicError: JsonRecord): JsonRecord {
|
|
const projected: JsonRecord = { status: "failed", error: publicError };
|
|
|
|
// A failed Responses event is an error boundary, so copy only documented protocol
|
|
// fields with their scalar shapes. Spreading the upstream object would also publish
|
|
// provider-only siblings such as diagnostics, settings, raw messages, or stack traces.
|
|
for (const key of RESPONSES_FAILURE_SCALAR_FIELDS) {
|
|
const value = response[key];
|
|
if (typeof value === "string") projected[key] = projectResponsesFailureString(key, value);
|
|
else if (value === null || typeof value === "number" || typeof value === "boolean")
|
|
projected[key] = value;
|
|
}
|
|
if (Array.isArray(response.output)) {
|
|
projected.output = projectResponsesFailureOutput(
|
|
response.output,
|
|
projectResponsesFailureString
|
|
);
|
|
}
|
|
const usage = projectResponsesFailureUsage(response.usage);
|
|
if (usage) projected.usage = usage;
|
|
if ("last_error" in response) projected.last_error = publicError;
|
|
return projected;
|
|
}
|
|
|
|
function toStreamFailureStatus(value: unknown): number | null {
|
|
if (typeof value === "number" && Number.isInteger(value) && value >= 400 && value <= 599) {
|
|
return value;
|
|
}
|
|
if (typeof value === "string" && /^\d{3}$/.test(value.trim())) {
|
|
const parsed = Number(value.trim());
|
|
return parsed >= 400 && parsed <= 599 ? parsed : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function looksLikeStreamRateLimit(code: string, type: string, message: string): boolean {
|
|
const haystack = `${code} ${type} ${message}`.toLowerCase();
|
|
return (
|
|
haystack.includes("usage_limit_reached") ||
|
|
haystack.includes("rate_limit") ||
|
|
haystack.includes("rate limit") ||
|
|
haystack.includes("quota") ||
|
|
haystack.includes("too many requests") ||
|
|
haystack.includes("limit reached") ||
|
|
haystack.includes("limit has been reached")
|
|
);
|
|
}
|
|
|
|
export function normalizeStreamFailurePayload(payload: unknown): StreamFailurePayload | null {
|
|
const record = payload && typeof payload === "object" ? (payload as JsonRecord) : {};
|
|
const response = asRecord(record.response);
|
|
const responseError = response.error;
|
|
const responseLastError = response.last_error;
|
|
const rootError = record.error;
|
|
const error = Object.keys(asRecord(responseError)).length
|
|
? asRecord(responseError)
|
|
: Object.keys(asRecord(responseLastError)).length
|
|
? asRecord(responseLastError)
|
|
: Object.keys(asRecord(rootError)).length
|
|
? asRecord(rootError)
|
|
: record;
|
|
const code = typeof error.code === "string" ? error.code : "upstream_error";
|
|
const type = typeof error.type === "string" ? error.type : undefined;
|
|
const message =
|
|
typeof error.message === "string" && error.message.trim()
|
|
? error.message
|
|
: typeof responseError === "string" && responseError.trim()
|
|
? responseError
|
|
: typeof responseLastError === "string" && responseLastError.trim()
|
|
? responseLastError
|
|
: typeof rootError === "string" && rootError.trim()
|
|
? rootError
|
|
: typeof record.message === "string" && record.message.trim()
|
|
? record.message
|
|
: "Upstream failure";
|
|
const status =
|
|
toStreamFailureStatus(error.status_code) ??
|
|
toStreamFailureStatus(error.status) ??
|
|
toStreamFailureStatus(response.status_code) ??
|
|
toStreamFailureStatus(response.status) ??
|
|
toStreamFailureStatus(record.status_code) ??
|
|
toStreamFailureStatus(record.status) ??
|
|
(looksLikeStreamRateLimit(code, type || "", message) ? 429 : 502);
|
|
|
|
return {
|
|
status,
|
|
message,
|
|
code,
|
|
...(type ? { type } : {}),
|
|
};
|
|
}
|
|
|
|
export function prepareTranslatedStreamFailure(
|
|
payload: unknown
|
|
): PreparedTranslatedStreamFailure | null {
|
|
const record = asRecord(payload);
|
|
const projected = projectStreamFailureEvent(record);
|
|
if (!projected && !record.error) return null;
|
|
return {
|
|
record,
|
|
providerPayload: projected?.publicPayload ?? record,
|
|
internalFailure: projected?.internalFailure ??
|
|
normalizeStreamFailurePayload(record) ?? {
|
|
status: 502,
|
|
message: "Upstream failure",
|
|
code: "stream_error",
|
|
type: "server_error",
|
|
},
|
|
publicMessage: projected?.publicMessage || "Upstream failure",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Project same-format upstream failure events before they cross the client/log boundary.
|
|
*
|
|
* `internalFailure` intentionally retains the raw provider wording: account fallback uses it
|
|
* to classify quota/reset hints before the persistence seam sanitizes the stored message.
|
|
* `publicPayload` is a separate protocol-preserving object whose failure subtrees are rebuilt by
|
|
* the canonical public boundary. Callers must never forward the raw payload for these events.
|
|
*/
|
|
export function projectStreamFailureEvent(payload: unknown): ProjectedStreamFailureEvent | null {
|
|
const record = asRecord(payload);
|
|
const response = asRecord(record.response);
|
|
const hasRootError =
|
|
Object.keys(asRecord(record.error)).length > 0 ||
|
|
(typeof record.error === "string" && record.error.trim().length > 0);
|
|
const isResponsesFailure =
|
|
record.type === "response.failed" ||
|
|
(record.type === "response.completed" && response.status === "failed");
|
|
const isClaudeFailure = record.type === "error";
|
|
if (!isResponsesFailure && !isClaudeFailure && !hasRootError) return null;
|
|
|
|
const internalFailure = normalizeStreamFailurePayload(record);
|
|
if (!internalFailure) return null;
|
|
|
|
const publicError = buildErrorBody(internalFailure.status, internalFailure.message, undefined, {
|
|
type: internalFailure.type ?? "server_error",
|
|
code: internalFailure.code ?? "stream_error",
|
|
}).error;
|
|
let publicPayload: JsonRecord;
|
|
if (isResponsesFailure) {
|
|
// Preserve protocol metadata and partial `output[].content[]` without passing output
|
|
// through a bounded-depth details sanitizer, while excluding arbitrary diagnostic siblings.
|
|
const publicResponse = projectResponsesFailureObject(response, publicError);
|
|
publicPayload = {
|
|
type: record.type,
|
|
response: publicResponse,
|
|
...(typeof record.sequence_number === "number"
|
|
? { sequence_number: record.sequence_number }
|
|
: {}),
|
|
};
|
|
} else if (isClaudeFailure) {
|
|
publicPayload = { type: "error", error: publicError };
|
|
} else {
|
|
// OpenAI-compatible HTTP-200 streams commonly emit a bare `{ error: ... }` frame.
|
|
// Rebuild the complete public envelope so provider-only fields cannot cross the wire.
|
|
publicPayload = { error: publicError };
|
|
}
|
|
|
|
return {
|
|
internalFailure,
|
|
publicMessage: publicError.message,
|
|
publicPayload,
|
|
};
|
|
}
|
|
|
|
export function formatTranslatedStreamError(payload: unknown, sourceFormat?: string): string {
|
|
const failure = normalizeStreamFailurePayload(payload) ?? {
|
|
status: 502,
|
|
message: "Upstream stream error",
|
|
code: "stream_error",
|
|
type: "server_error",
|
|
};
|
|
const errorBody = buildErrorBody(failure.status, failure.message, undefined, {
|
|
type: failure.type ?? "server_error",
|
|
code: failure.code ?? "stream_error",
|
|
});
|
|
|
|
if (sourceFormat === FORMATS.OPENAI_RESPONSES) {
|
|
const failed = {
|
|
type: "response.failed",
|
|
response: {
|
|
id: `resp_error_${Date.now()}`,
|
|
object: "response",
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
status: "failed",
|
|
background: false,
|
|
error: errorBody.error,
|
|
output: [],
|
|
},
|
|
sequence_number: 0,
|
|
};
|
|
return `event: response.failed\ndata: ${JSON.stringify(failed)}\n\n`;
|
|
}
|
|
|
|
if (sourceFormat === FORMATS.CLAUDE) {
|
|
return `event: error\ndata: ${JSON.stringify({ type: "error", error: errorBody.error })}\n\n`;
|
|
}
|
|
|
|
return `data: ${JSON.stringify(errorBody)}\n\ndata: [DONE]\n\n`;
|
|
}
|