mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
327 lines
9.5 KiB
TypeScript
327 lines
9.5 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";
|
|
|
|
/**
|
|
* Build OpenAI-compatible error response body
|
|
* @param {number} statusCode - HTTP status code
|
|
* @param {string} message - Error message
|
|
* @returns {object} Error response object
|
|
*/
|
|
export function buildErrorBody(statusCode, message) {
|
|
const errorInfo = getErrorInfo(statusCode);
|
|
|
|
return {
|
|
error: {
|
|
message: message || getDefaultErrorMessage(statusCode),
|
|
type: errorInfo.type,
|
|
code: errorInfo.code,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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, message) {
|
|
return new Response(JSON.stringify(buildErrorBody(statusCode, 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, statusCode, message) {
|
|
const errorBody = buildErrorBody(statusCode, 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) {
|
|
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, provider = null) {
|
|
let message = "";
|
|
let retryAfterMs = null;
|
|
let responseBody = null;
|
|
|
|
try {
|
|
const text = await response.text();
|
|
responseBody = normalizePayloadForLog(text);
|
|
|
|
// Try parse as JSON
|
|
try {
|
|
const json = JSON.parse(text);
|
|
message = json.error?.message || json.message || json.error || text;
|
|
} 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;
|
|
}
|
|
|
|
return {
|
|
statusCode: response.status,
|
|
message: messageStr,
|
|
retryAfterMs,
|
|
responseBody,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
) {
|
|
const result: {
|
|
success: false;
|
|
status: number;
|
|
error: string;
|
|
response: Response;
|
|
retryAfterMs?: number;
|
|
} = {
|
|
success: false,
|
|
status: statusCode,
|
|
error: message,
|
|
response: errorResponse(statusCode, message),
|
|
};
|
|
|
|
// 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),
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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, provider, model, statusCode) {
|
|
const code = statusCode || error.code || "FETCH_FAILED";
|
|
const message = error.message || "Unknown error";
|
|
return `[${code}]: ${message}`;
|
|
}
|