mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
* chore(release): open v3.8.20 development cycle * fix(images): prefer bare combos over image aliases (#3527) Integrated into release/v3.8.20 * fix(translator): map Codex local_shell tool (#3534) Integrated into release/v3.8.20 * fix(usage): make opencode-go quota fetcher fail-open instead of throwing 500 (#3522) Integrated into release/v3.8.20 * Fix Runtime page breaker state rendering (#3533) Integrated into release/v3.8.20 * Expose provider breaker degradation threshold setting (#3535) Integrated into release/v3.8.20 * fix(executor): strip provider prefix from versioned built-in tool model field (#3532) Integrated into release/v3.8.20 * feat(providers): add Claude Fable 5 support (#3524) Integrated into release/v3.8.20 * feat(resilience): add global provider cooldown tracking to prevent combo re-walking (#3556) Integrated into release/v3.8.20 (default OFF, opt-in) * fix(translator): scope thoughtSignature bypass to Antigravity/CLI only (#3560) Integrated into release/v3.8.20. Co-authored-by: Six7Day <six7day@gmail.com> * fix(routing): normalize thinking:disabled for combo-substituted models that reject it (#3554) (#3563) Integrated into release/v3.8.20 * fix(usage): accept 0/empty budget limits so the dashboard can save and clear (#3537) (#3564) Integrated into release/v3.8.20 * docs(changelog): credit @Six7Day for #3560 thoughtSignature fix (#3414) The #3560 squash co-author trailer landed inline (unparsed by GitHub), so add an explicit CHANGELOG credit ensuring @Six7Day (original #3414) and @oyi77 are on the public record for the Gemini thoughtSignature fix. * fix(gamification): dedup badge unlock via user_badges so events don't re-fire every request (#3472) (#3565) Integrated into release/v3.8.20 * fix(routing): pass through 'auto' keyword on codex /v1/responses instead of rewriting to codex/auto (#3509) (#3566) Integrated into release/v3.8.20 * fix(cli-tools): normalize apiKey null in guide-settings schema so cloud-mode config saves (#3552) (#3567) Integrated into release/v3.8.20 * fix(catalog): reclassify PublicAI from keyless to one-time-initial (requires API key) (#3558) (#3568) Integrated into release/v3.8.20 * fix(gemini-web): surface missing Playwright browser as actionable 503 + cooldown hint, not a retryable 500 loop (#3516) (#3570) Integrated into release/v3.8.20 * fix(security): sanitize raw err.message in web executors + embeddings/search response bodies (Rule #12) (#3494, #3495) (#3573) Integrated into release/v3.8.20 * fix(dashboard): point CustomHostsManager + FeatureFlagsGrid at real routes (#3486, #3487) (#3574) Integrated into release/v3.8.20 * chore(providers): remove dead krutrim entry (#3483) + docs(api): fix agent-bridge per-agent state route (#3489) (#3575) Integrated into release/v3.8.20 * docs(api): correct API_REFERENCE.md paths for skills/plugins/admin/cache/acp/system-info (#3497) (#3577) Integrated into release/v3.8.20 * fix(proxy): drive SOCKS5 UI option from runtime ENABLE_SOCKS5_PROXY, not build-time NEXT_PUBLIC (#3508) (#3579) Integrated into release/v3.8.20 * fix(playground): filter playground models by node prefix so custom-endpoint models appear (#3505) (#3581) Integrated into release/v3.8.20 * fix(usage): show an informative message instead of a blank Kiro quota card when no usage breakdown (#3506) (#3582) Integrated into release/v3.8.20 * docs(changelog): add the #3506 Kiro quota entry (missed in #3582 due to a stale-base CHANGELOG anchor) (#3583) Integrated into release/v3.8.20 * fix(auto-update): use stable PROJECT_ROOT walker, not frozen process.cwd() (#3561) Integrated into release/v3.8.20. Auto-update PROJECT_ROOT now uses a stable __dirname-anchored upward walker instead of the no-op process.cwd() resolver. * fix: address PR #3518 review comments (lifecycle hooks, regex, indentation, route params) (#3562) Integrated into release/v3.8.20. Addresses #3518 review: regex literals, logs/[id] route params (Next 16), indentation, and wires plugin lifecycle hooks (onInstall/onActivate/onDeactivate/onUninstall) in the loader so manager.ts can register them. Adds Rule #18 regression test. * docs(changelog): credit @ViFigueiredo (#3423) for PROJECT_ROOT + log #3561/#3562 (v3.8.20) * fix: openai to gemini incorrectly translates historical tool calls into text (#3569) Integrated into release/v3.8.20. Standard Gemini direct path now maps historical tool calls to native functionCall/functionResponse parts (signaturelessToolCallMode: native) instead of inert text — validated against the real Gemini API (gemini-2.5-flash returns 200 for signatureless native functionCall, even with tools+thinking; Hard Rule #18). Eliminates the text-serialization leak. Antigravity/CLI sentinel path (#3560) untouched. * docs(changelog)+test: reconcile standard-Gemini native mode (#3569) — update round-2 rationale comment + log VPS validation * docs(changelog): reconcile v3.8.20 — add 9 missing bullets + move [Unreleased] to versioned section * docs(changelog): complete v3.8.20 reconciliation — 27 bullets, 11 contributors --------- Co-authored-by: Alexander Averyanov <alex@averyan.ru> Co-authored-by: Hakan Kurşun <bykamaka@gmail.com> Co-authored-by: Wilson <pedbookmed@gmail.com> Co-authored-by: Randi <55005611+rdself@users.noreply.github.com> Co-authored-by: Giorgos Giakoumettis <giorgos@yiakoumettis.gr> Co-authored-by: PizzaV <103120356+pizzav-xyz@users.noreply.github.com> Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com> Co-authored-by: Markus Hartung <mail@hartmark.se>
500 lines
16 KiB
TypeScript
500 lines
16 KiB
TypeScript
import { CORS_HEADERS } from "./cors.ts";
|
|
import { getDefaultErrorMessage, getErrorInfo } from "../config/errorConfig.ts";
|
|
import { normalizePayloadForLog } from "@/lib/logPayloads";
|
|
import type { ModelCooldownErrorPayload } from "@/types";
|
|
|
|
/**
|
|
* Sanitize an error message to prevent stack trace exposure in API responses.
|
|
* Strips stack traces, file paths, and absolute Windows/POSIX paths from
|
|
* error messages before they reach the client.
|
|
*/
|
|
interface ErrorResponseBody {
|
|
error: {
|
|
message: string;
|
|
type?: string;
|
|
code?: string;
|
|
};
|
|
upstream_details?: Record<string, unknown> | null; // sanitized upstream provider body
|
|
}
|
|
|
|
// Length cap protects against pathological inputs even before tokenization.
|
|
const MAX_ERROR_LEN = 4096;
|
|
const SOURCE_EXT = ["ts", "tsx", "js", "jsx", "mjs", "cjs"] as const;
|
|
|
|
function looksLikeAbsolutePath(tok: string): boolean {
|
|
// POSIX: "/<...>.ts" (optionally followed by :line[:col]).
|
|
// Windows: "C:\<...>.ts" or "C:/<...>.ts".
|
|
if (tok.length < 4 || tok.length > 2048) return false;
|
|
const isPosix = tok.charCodeAt(0) === 0x2f; // '/'
|
|
const isWindows = tok.length > 2 && tok.charCodeAt(1) === 0x3a && /[A-Za-z]/.test(tok[0]);
|
|
if (!isPosix && !isWindows) return false;
|
|
const dot = tok.lastIndexOf(".");
|
|
if (dot <= 0 || dot === tok.length - 1) return false;
|
|
const ext = tok
|
|
.slice(dot + 1)
|
|
.split(":", 1)[0]
|
|
.toLowerCase();
|
|
return (SOURCE_EXT as readonly string[]).includes(ext);
|
|
}
|
|
|
|
/**
|
|
* Strip stack-trace tail and absolute source paths from error messages.
|
|
*
|
|
* Implemented via simple whitespace tokenization (linear time) instead of a
|
|
* single complex regex, so CodeQL `js/polynomial-redos` stays clean even when
|
|
* the runtime error message is attacker-controlled.
|
|
*/
|
|
export function sanitizeErrorMessage(message: unknown): string {
|
|
let str = typeof message === "string" ? message : String(message ?? "");
|
|
if (str.length > MAX_ERROR_LEN) str = str.slice(0, MAX_ERROR_LEN);
|
|
const nl = str.indexOf("\n");
|
|
const firstLine = nl >= 0 ? str.slice(0, nl) : str;
|
|
// Preserve original whitespace by splitting on captured separator.
|
|
const parts = firstLine.split(/(\s+)/);
|
|
for (let i = 0; i < parts.length; i++) {
|
|
if (looksLikeAbsolutePath(parts[i])) parts[i] = "<path>";
|
|
}
|
|
return parts.join("");
|
|
}
|
|
|
|
const BLOCKED_KEYS = /stack|trace|path|file|cwd|dir|password|secret|token|key/i;
|
|
const MAX_DEPTH = 4;
|
|
|
|
/**
|
|
* Recursively sanitize an arbitrary JSON value from an upstream provider body.
|
|
* - Strings: run through sanitizeErrorMessage (strips stacks + absolute paths).
|
|
* - Keys matching BLOCKED_KEYS are dropped (credential/path guards).
|
|
* - Depth capped at MAX_DEPTH to prevent pathological nesting.
|
|
* - Arrays capped at 32 elements.
|
|
* - Returns null for null/undefined/non-JSON-serializable values.
|
|
*/
|
|
export function sanitizeUpstreamDetails(value: unknown, depth = 0): unknown {
|
|
if (depth > MAX_DEPTH) return "[truncated]";
|
|
if (value === null || value === undefined) return null;
|
|
if (typeof value === "string") return sanitizeErrorMessage(value);
|
|
if (typeof value === "number" || typeof value === "boolean") return value;
|
|
if (Array.isArray(value)) {
|
|
return value.slice(0, 32).map((v) => sanitizeUpstreamDetails(v, depth + 1));
|
|
}
|
|
if (typeof value === "object") {
|
|
const out: Record<string, unknown> = {};
|
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
if (BLOCKED_KEYS.test(k)) continue;
|
|
out[k] = sanitizeUpstreamDetails(v, depth + 1);
|
|
}
|
|
return out;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Build OpenAI-compatible error response body. Message is always sanitized
|
|
* so callers do not need to remember to strip stack traces themselves.
|
|
* Optional third argument `upstreamDetails` (raw parsed provider body) is
|
|
* sanitized by sanitizeUpstreamDetails before inclusion as `upstream_details`.
|
|
*/
|
|
export function buildErrorBody(
|
|
statusCode: number,
|
|
message: string,
|
|
upstreamDetails?: unknown
|
|
): ErrorResponseBody {
|
|
const errorInfo = getErrorInfo(statusCode);
|
|
const safeMessage = sanitizeErrorMessage(message) || getDefaultErrorMessage(statusCode);
|
|
|
|
const body: ErrorResponseBody = {
|
|
error: {
|
|
message: safeMessage,
|
|
type: errorInfo.type,
|
|
code: errorInfo.code,
|
|
},
|
|
};
|
|
|
|
if (upstreamDetails !== undefined && upstreamDetails !== null) {
|
|
const sanitized = sanitizeUpstreamDetails(upstreamDetails);
|
|
if (sanitized !== null && typeof sanitized === "object" && !Array.isArray(sanitized)) {
|
|
body.upstream_details = sanitized as Record<string, unknown>;
|
|
}
|
|
}
|
|
|
|
return body;
|
|
}
|
|
|
|
/**
|
|
* Create error Response object (for non-streaming)
|
|
* @param {number} statusCode - HTTP status code
|
|
* @param {string} message - Error message
|
|
* @returns {Response} HTTP Response object
|
|
*/
|
|
export function errorResponse(statusCode: number, message: string): Response {
|
|
return new Response(JSON.stringify(buildErrorBody(statusCode, sanitizeErrorMessage(message))), {
|
|
status: statusCode,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Write error to SSE stream (for streaming)
|
|
* @param {WritableStreamDefaultWriter} writer - Stream writer
|
|
* @param {number} statusCode - HTTP status code
|
|
* @param {string} message - Error message
|
|
*/
|
|
export async function writeStreamError(
|
|
writer: WritableStreamDefaultWriter<Uint8Array>,
|
|
statusCode: number,
|
|
message: string
|
|
): Promise<void> {
|
|
const errorBody = buildErrorBody(statusCode, sanitizeErrorMessage(message));
|
|
const encoder = new TextEncoder();
|
|
await writer.write(encoder.encode(`data: ${JSON.stringify(errorBody)}\n\n`));
|
|
}
|
|
|
|
function normalizeRetryAfterSeconds(retryAfter?: string | number | Date | null): number {
|
|
if (typeof retryAfter === "number" && Number.isFinite(retryAfter)) {
|
|
if (retryAfter > 0 && retryAfter < 1_000_000_000) {
|
|
return Math.max(Math.ceil(retryAfter), 1);
|
|
}
|
|
|
|
const retryTimeMs = new Date(retryAfter).getTime();
|
|
if (Number.isFinite(retryTimeMs)) {
|
|
return Math.max(Math.ceil((retryTimeMs - Date.now()) / 1000), 1);
|
|
}
|
|
}
|
|
|
|
if (retryAfter instanceof Date || typeof retryAfter === "string") {
|
|
const retryTimeMs = new Date(retryAfter).getTime();
|
|
if (Number.isFinite(retryTimeMs)) {
|
|
return Math.max(Math.ceil((retryTimeMs - Date.now()) / 1000), 1);
|
|
}
|
|
}
|
|
|
|
return 1;
|
|
}
|
|
|
|
/**
|
|
* Parse Antigravity error message to extract retry time
|
|
* Example: "You have exhausted your capacity on this model. Your quota will reset after 2h7m23s."
|
|
* @param {string} message - Error message
|
|
* @returns {number|null} Retry time in milliseconds, or null if not found
|
|
*/
|
|
export function parseAntigravityRetryTime(message: unknown): number | null {
|
|
if (typeof message !== "string") return null;
|
|
|
|
// Match patterns like: 2h7m23s, 5m30s, 45s, 1h20m, etc.
|
|
const match = message.match(/reset after (\d+h)?(\d+m)?(\d+s)?/i);
|
|
if (!match) return null;
|
|
|
|
let totalMs = 0;
|
|
|
|
// Extract hours
|
|
if (match[1]) {
|
|
const hours = parseInt(match[1]);
|
|
totalMs += hours * 60 * 60 * 1000;
|
|
}
|
|
|
|
// Extract minutes
|
|
if (match[2]) {
|
|
const minutes = parseInt(match[2]);
|
|
totalMs += minutes * 60 * 1000;
|
|
}
|
|
|
|
// Extract seconds
|
|
if (match[3]) {
|
|
const seconds = parseInt(match[3]);
|
|
totalMs += seconds * 1000;
|
|
}
|
|
|
|
return totalMs > 0 ? totalMs : null;
|
|
}
|
|
|
|
/**
|
|
* Parse upstream provider error response
|
|
* @param {Response} response - Fetch response from provider
|
|
* @param {string} provider - Provider name (for Antigravity-specific parsing)
|
|
* @returns {Promise<{statusCode: number, message: string, retryAfterMs: number|null, responseBody: unknown}>}
|
|
*/
|
|
export async function parseUpstreamError(response: Response, provider: string | null = null) {
|
|
let message: unknown = "";
|
|
let retryAfterMs: number | null = null;
|
|
let responseBody: unknown = null;
|
|
let errorCode: unknown = undefined;
|
|
let errorType: unknown = undefined;
|
|
|
|
try {
|
|
const text = await response.text();
|
|
responseBody = normalizePayloadForLog(text);
|
|
|
|
// Try parse as JSON
|
|
try {
|
|
const parsed = JSON.parse(text);
|
|
// Handle array responses (e.g., from some Gemini APIs)
|
|
const json = (Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : parsed) || {};
|
|
message = json.error?.message || json.message || json.error || text;
|
|
errorCode = json.error?.code || json.code;
|
|
errorType = json.error?.type || json.type;
|
|
} catch {
|
|
message = text;
|
|
}
|
|
} catch {
|
|
message = `Upstream error: ${response.status}`;
|
|
responseBody = { _rawText: message };
|
|
}
|
|
|
|
const messageStr = typeof message === "string" ? message : JSON.stringify(message);
|
|
|
|
const retryAfterHeader = response.headers?.get?.("retry-after");
|
|
if (retryAfterHeader && !retryAfterMs) {
|
|
const retryAfterSec = Number.parseInt(retryAfterHeader, 10);
|
|
if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) {
|
|
retryAfterMs = retryAfterSec * 1000;
|
|
} else {
|
|
const retryAfterDate = new Date(retryAfterHeader).getTime();
|
|
if (Number.isFinite(retryAfterDate) && retryAfterDate > Date.now()) {
|
|
retryAfterMs = retryAfterDate - Date.now();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Parse Antigravity-specific retry time from error message
|
|
if (provider === "antigravity" && response.status === 429) {
|
|
retryAfterMs = parseAntigravityRetryTime(messageStr);
|
|
}
|
|
|
|
// Also parse retry time for other providers (Qwen, etc.) with "quota will reset after XhYmZs" format
|
|
if (response.status === 429 && !retryAfterMs) {
|
|
retryAfterMs = parseAntigravityRetryTime(messageStr);
|
|
}
|
|
|
|
// Generic providers: "Please retry after 20s"
|
|
if (response.status === 429 && !retryAfterMs) {
|
|
const retryMatch = messageStr.match(/retry\s+after\s+(\d+)\s*s/i);
|
|
if (retryMatch) {
|
|
retryAfterMs = Number.parseInt(retryMatch[1], 10) * 1000;
|
|
}
|
|
}
|
|
|
|
// Cap maximum retry time at 24 hours to prevent infinite wait
|
|
const MAX_RETRY_MS = 24 * 60 * 60 * 1000;
|
|
if (retryAfterMs && retryAfterMs > MAX_RETRY_MS) {
|
|
retryAfterMs = MAX_RETRY_MS;
|
|
}
|
|
|
|
const responseHeaders: Record<string, string> | null = response.headers
|
|
? Object.fromEntries(response.headers.entries())
|
|
: null;
|
|
|
|
return {
|
|
statusCode: response.status,
|
|
message: messageStr,
|
|
errorCode,
|
|
errorType,
|
|
retryAfterMs,
|
|
responseBody,
|
|
responseHeaders,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create error result for chatCore handler
|
|
* @param {number} statusCode - HTTP status code
|
|
* @param {string} message - Error message
|
|
* @param {number|null} retryAfterMs - Optional retry-after time in milliseconds
|
|
* @returns {{ success: false, status: number, error: string, response: Response, retryAfterMs?: number }}
|
|
*/
|
|
export function createErrorResult(
|
|
statusCode: number,
|
|
message: string,
|
|
retryAfterMs: number | null = null,
|
|
errorCode?: string,
|
|
errorType?: string,
|
|
upstreamDetails?: unknown
|
|
) {
|
|
const body = buildErrorBody(statusCode, message, upstreamDetails);
|
|
if (errorCode) {
|
|
body.error.code = errorCode;
|
|
}
|
|
if (errorType) {
|
|
body.error.type = errorType;
|
|
}
|
|
|
|
const result: {
|
|
success: false;
|
|
status: number;
|
|
error: string;
|
|
errorType?: string;
|
|
errorCode?: string;
|
|
response: Response;
|
|
retryAfterMs?: number;
|
|
} = {
|
|
success: false,
|
|
status: statusCode,
|
|
error: body.error.message,
|
|
errorType,
|
|
errorCode,
|
|
response: new Response(JSON.stringify(body), {
|
|
status: statusCode,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
};
|
|
|
|
// Add retryAfterMs if available (for Antigravity quota errors)
|
|
if (retryAfterMs) {
|
|
result.retryAfterMs = retryAfterMs;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Create unavailable response when all accounts are rate limited
|
|
* @param {number} statusCode - Original error status code
|
|
* @param {string} message - Error message (without retry info)
|
|
* @param {string} retryAfter - ISO timestamp when earliest account becomes available
|
|
* @param {string} retryAfterHuman - Human-readable retry info e.g. "reset after 30s"
|
|
* @returns {Response}
|
|
*/
|
|
export function unavailableResponse(
|
|
statusCode: number,
|
|
message: string,
|
|
retryAfter?: string | number | Date | null,
|
|
retryAfterHuman?: string
|
|
) {
|
|
const retryAfterSec = normalizeRetryAfterSeconds(retryAfter);
|
|
const msg = retryAfterHuman ? `${message} (${retryAfterHuman})` : message;
|
|
return new Response(JSON.stringify({ error: { message: msg } }), {
|
|
status: statusCode,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Retry-After": String(retryAfterSec),
|
|
},
|
|
});
|
|
}
|
|
|
|
export function providerCircuitOpenResponse(
|
|
provider: string,
|
|
retryAfter?: string | number | Date | null
|
|
) {
|
|
const retryAfterSec = normalizeRetryAfterSeconds(retryAfter);
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: {
|
|
message: `Provider ${provider} circuit breaker is open`,
|
|
type: "server_error",
|
|
code: "provider_circuit_open",
|
|
provider,
|
|
retry_after: retryAfterSec,
|
|
},
|
|
}),
|
|
{
|
|
status: 503,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Retry-After": String(retryAfterSec),
|
|
"X-OmniRoute-Provider-Breaker": "open",
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
export function buildModelCooldownBody({
|
|
model,
|
|
retryAfterSec,
|
|
}: {
|
|
model?: string | null;
|
|
retryAfterSec: number;
|
|
}): ModelCooldownErrorPayload {
|
|
const resolvedModel = typeof model === "string" && model.trim().length > 0 ? model.trim() : null;
|
|
|
|
return {
|
|
error: {
|
|
message: resolvedModel
|
|
? `All credentials for model ${resolvedModel} are cooling down`
|
|
: "All credentials for the requested model are cooling down",
|
|
type: "rate_limit_error",
|
|
code: "model_cooldown",
|
|
...(resolvedModel ? { model: resolvedModel } : {}),
|
|
reset_seconds: Math.max(Math.ceil(retryAfterSec), 1),
|
|
},
|
|
};
|
|
}
|
|
|
|
export function modelCooldownResponse({
|
|
model,
|
|
retryAfter,
|
|
}: {
|
|
model?: string | null;
|
|
retryAfter?: string | number | Date | null;
|
|
}) {
|
|
const retryAfterSec = normalizeRetryAfterSeconds(retryAfter);
|
|
return new Response(
|
|
JSON.stringify(
|
|
buildModelCooldownBody({
|
|
model,
|
|
retryAfterSec,
|
|
})
|
|
),
|
|
{
|
|
status: 429,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Retry-After": String(retryAfterSec),
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Build an executor-style error result (response + url + headers + transformedBody).
|
|
* Shared by web-cookie executors that return the `{ response, url, headers, transformedBody }` shape.
|
|
*/
|
|
export function makeExecutorErrorResult(
|
|
status: number,
|
|
message: string,
|
|
body: unknown,
|
|
url: string
|
|
) {
|
|
return {
|
|
response: new Response(
|
|
JSON.stringify({
|
|
error: {
|
|
message: sanitizeErrorMessage(message),
|
|
type: "upstream_error",
|
|
code: `HTTP_${status}`,
|
|
},
|
|
}),
|
|
{ status, headers: { "Content-Type": "application/json" } }
|
|
),
|
|
url,
|
|
headers: {} as Record<string, string>,
|
|
transformedBody: body,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Normalize a cookie string: strip a leading "Cookie:" prefix if present.
|
|
*/
|
|
export function normalizeCookie(raw: string): string {
|
|
return raw?.startsWith("Cookie:") ? raw.slice(7).trim() : raw || "";
|
|
}
|
|
|
|
/**
|
|
* Format provider error with context
|
|
* @param {Error} error - Original error
|
|
* @param {string} provider - Provider name
|
|
* @param {string} model - Model name
|
|
* @param {number|string} statusCode - HTTP status code or error code
|
|
* @returns {string} Formatted error message
|
|
*/
|
|
export function formatProviderError(
|
|
error: { code?: string | number; message?: string } | Error,
|
|
provider: string,
|
|
model: string,
|
|
statusCode?: string | number | null
|
|
): string {
|
|
const providerCode = "code" in error ? error.code : undefined;
|
|
const code = statusCode || providerCode || "FETCH_FAILED";
|
|
const message = error.message || "Unknown error";
|
|
return `[${code}]: ${message}`;
|
|
}
|