Refactor error handling to config-driven approach with centralized error rules
Made-with: Cursor
This commit is contained in:
82
open-sse/config/errorConfig.js
Normal file
82
open-sse/config/errorConfig.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// OpenAI-compatible error types mapping (client-facing)
|
||||
export const ERROR_TYPES = {
|
||||
400: { type: "invalid_request_error", code: "bad_request" },
|
||||
401: { type: "authentication_error", code: "invalid_api_key" },
|
||||
402: { type: "billing_error", code: "payment_required" },
|
||||
403: { type: "permission_error", code: "insufficient_quota" },
|
||||
404: { type: "invalid_request_error", code: "model_not_found" },
|
||||
406: { type: "invalid_request_error", code: "model_not_supported" },
|
||||
429: { type: "rate_limit_error", code: "rate_limit_exceeded" },
|
||||
500: { type: "server_error", code: "internal_server_error" },
|
||||
502: { type: "server_error", code: "bad_gateway" },
|
||||
503: { type: "server_error", code: "service_unavailable" },
|
||||
504: { type: "server_error", code: "gateway_timeout" }
|
||||
};
|
||||
|
||||
// Default error messages per status code (client-facing)
|
||||
export const DEFAULT_ERROR_MESSAGES = {
|
||||
400: "Bad request",
|
||||
401: "Invalid API key provided",
|
||||
402: "Payment required",
|
||||
403: "You exceeded your current quota",
|
||||
404: "Model not found",
|
||||
406: "Model not supported",
|
||||
429: "Rate limit exceeded",
|
||||
500: "Internal server error",
|
||||
502: "Bad gateway - upstream provider error",
|
||||
503: "Service temporarily unavailable",
|
||||
504: "Gateway timeout"
|
||||
};
|
||||
|
||||
// Exponential backoff config for rate limits
|
||||
export const BACKOFF_CONFIG = {
|
||||
base: 1000,
|
||||
max: 3 * 60 * 1000,
|
||||
maxLevel: 15
|
||||
};
|
||||
|
||||
// Default cooldown for transient/unknown errors
|
||||
export const TRANSIENT_COOLDOWN_MS = 30 * 1000;
|
||||
|
||||
// Cooldown durations (ms)
|
||||
const COOLDOWN = {
|
||||
long: 2 * 60 * 1000,
|
||||
short: 5 * 1000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Unified error classification rules.
|
||||
* Checked top-to-bottom: text rules first (by order), then status rules.
|
||||
* Each rule: { text?, status?, cooldownMs?, backoff? }
|
||||
* - text: substring match (case-insensitive) on error message
|
||||
* - status: HTTP status code match
|
||||
* - cooldownMs: fixed cooldown duration
|
||||
* - backoff: true = use exponential backoff (rate limit)
|
||||
*/
|
||||
export const ERROR_RULES = [
|
||||
// --- Text-based rules (checked first, order = priority) ---
|
||||
{ text: "no credentials", cooldownMs: COOLDOWN.long },
|
||||
{ text: "request not allowed", cooldownMs: COOLDOWN.short },
|
||||
{ text: "improperly formed request", cooldownMs: COOLDOWN.long },
|
||||
{ text: "rate limit", backoff: true },
|
||||
{ text: "too many requests", backoff: true },
|
||||
{ text: "quota exceeded", backoff: true },
|
||||
{ text: "capacity", backoff: true },
|
||||
{ text: "overloaded", backoff: true },
|
||||
|
||||
// --- Status-based rules (fallback when text doesn't match) ---
|
||||
{ status: 401, cooldownMs: COOLDOWN.long },
|
||||
{ status: 402, cooldownMs: COOLDOWN.long },
|
||||
{ status: 403, cooldownMs: COOLDOWN.long },
|
||||
{ status: 404, cooldownMs: COOLDOWN.long },
|
||||
{ status: 429, backoff: true },
|
||||
];
|
||||
|
||||
// Backward compat: COOLDOWN_MS object (used by index.js re-export)
|
||||
export const COOLDOWN_MS = {
|
||||
unauthorized: COOLDOWN.long,
|
||||
paymentRequired: COOLDOWN.long,
|
||||
notFound: COOLDOWN.long,
|
||||
transient: TRANSIENT_COOLDOWN_MS,
|
||||
requestNotAllowed: COOLDOWN.short,
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PROVIDERS } from "./providers.js";
|
||||
import { GOOGLE_TTS_LANGUAGES } from "./googleTtsLanguages.js";
|
||||
import { buildTtsProviderModels } from "./ttsModels.js";
|
||||
|
||||
// Provider models - Single source of truth
|
||||
// Key = alias (cc, cx, gc, qw, if, ag, gh for OAuth; id for API Key)
|
||||
@@ -144,10 +144,10 @@ export const PROVIDER_MODELS = {
|
||||
{ id: "deepseek/deepseek-reasoner", name: "DeepSeek Reasoner" },
|
||||
],
|
||||
oc: [ // OpenCode
|
||||
{ id: "nemotron-3-super-free", name: "Nemotron 3 Super" },
|
||||
// { id: "nemotron-3-super-free", name: "Nemotron 3 Super" },
|
||||
// { id: "qwen3.6-plus-free", name: "Qwen 3.6 Plus" },
|
||||
// { id: "big-pickle", name: "Big Pickle", targetFormat: "claude" },
|
||||
{ id: "minimax-m2.5-free", name: "MiniMax M2.5", targetFormat: "claude" },
|
||||
// { id: "minimax-m2.5-free", name: "MiniMax M2.5", targetFormat: "claude" },
|
||||
// { id: "trinity-large-preview-free", name: "Trinity Large Preview" },
|
||||
],
|
||||
|
||||
@@ -230,6 +230,10 @@ export const PROVIDER_MODELS = {
|
||||
{ id: "perplexity/pplx-embed-v1-4b", name: "Perplexity Embed V1 4B", type: "embedding" },
|
||||
{ id: "perplexity/pplx-embed-v1-0.6b", name: "Perplexity Embed V1 0.6B", type: "embedding" },
|
||||
{ id: "nvidia/llama-nemotron-embed-vl-1b-v2:free", name: "NVIDIA Nemotron Embed VL 1B V2 (Free)", type: "embedding" },
|
||||
// TTS models
|
||||
{ id: "openai/gpt-4o-mini-tts", name: "GPT-4o Mini TTS", type: "tts" },
|
||||
{ id: "openai/tts-1-hd", name: "TTS-1 HD", type: "tts" },
|
||||
{ id: "openai/tts-1", name: "TTS-1", type: "tts" },
|
||||
],
|
||||
glm: [
|
||||
{ id: "glm-5.1", name: "GLM 5.1" },
|
||||
@@ -377,54 +381,8 @@ export const PROVIDER_MODELS = {
|
||||
{ id: "zai-org/glm-5-maas", name: "GLM-5 (Vertex)" },
|
||||
],
|
||||
|
||||
// Free/noAuth TTS providers
|
||||
"local-device": [
|
||||
{ id: "default", name: "System Default Voice", type: "tts" },
|
||||
],
|
||||
"google-tts": GOOGLE_TTS_LANGUAGES,
|
||||
// OpenAI TTS voices (hardcoded — no public API to list them)
|
||||
// Used by ttsCore.js when provider = openai
|
||||
"openai-tts-voices": [
|
||||
{ id: "alloy", name: "Alloy", type: "tts" },
|
||||
{ id: "ash", name: "Ash", type: "tts" },
|
||||
{ id: "ballad", name: "Ballad", type: "tts" },
|
||||
{ id: "cedar", name: "Cedar", type: "tts" },
|
||||
{ id: "coral", name: "Coral", type: "tts" },
|
||||
{ id: "echo", name: "Echo", type: "tts" },
|
||||
{ id: "fable", name: "Fable", type: "tts" },
|
||||
{ id: "marin", name: "Marin", type: "tts" },
|
||||
{ id: "nova", name: "Nova", type: "tts" },
|
||||
{ id: "onyx", name: "Onyx", type: "tts" },
|
||||
{ id: "sage", name: "Sage", type: "tts" },
|
||||
{ id: "shimmer", name: "Shimmer", type: "tts" },
|
||||
{ id: "verse", name: "Verse", type: "tts" },
|
||||
],
|
||||
// OpenAI TTS models
|
||||
"openai-tts-models": [
|
||||
{ id: "gpt-4o-mini-tts", name: "GPT-4o Mini TTS", type: "tts" },
|
||||
{ id: "tts-1-hd", name: "TTS-1 HD", type: "tts" },
|
||||
{ id: "tts-1", name: "TTS-1", type: "tts" },
|
||||
],
|
||||
// ElevenLabs TTS models
|
||||
"elevenlabs-tts-models": [
|
||||
{ id: "eleven_flash_v2_5", name: "Flash v2.5 (Fastest)", type: "tts" },
|
||||
{ id: "eleven_turbo_v2_5", name: "Turbo v2.5 (Fast)", type: "tts" },
|
||||
{ id: "eleven_multilingual_v2", name: "Multilingual v2 (Quality)", type: "tts" },
|
||||
{ id: "eleven_monolingual_v1", name: "Monolingual v1 (English)", type: "tts" },
|
||||
],
|
||||
"edge-tts": [
|
||||
{ id: "en-US-AriaNeural", name: "Aria (en-US)", type: "tts" },
|
||||
{ id: "en-US-GuyNeural", name: "Guy (en-US)", type: "tts" },
|
||||
{ id: "en-GB-SoniaNeural", name: "Sonia (en-GB)", type: "tts" },
|
||||
{ id: "vi-VN-HoaiMyNeural", name: "Hoai My (vi-VN)", type: "tts" },
|
||||
{ id: "vi-VN-NamMinhNeural", name: "Nam Minh (vi-VN)", type: "tts" },
|
||||
{ id: "zh-CN-XiaoxiaoNeural", name: "Xiaoxiao (zh-CN)", type: "tts" },
|
||||
{ id: "zh-CN-YunxiNeural", name: "Yunxi (zh-CN)", type: "tts" },
|
||||
{ id: "fr-FR-DeniseNeural", name: "Denise (fr-FR)", type: "tts" },
|
||||
{ id: "de-DE-KatjaNeural", name: "Katja (de-DE)", type: "tts" },
|
||||
{ id: "ja-JP-NanamiNeural", name: "Nanami (ja-JP)", type: "tts" },
|
||||
{ id: "ko-KR-SunHiNeural", name: "SunHi (ko-KR)", type: "tts" },
|
||||
],
|
||||
// TTS entries are loaded from ttsModels.js via buildTtsProviderModels()
|
||||
...buildTtsProviderModels(),
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
|
||||
@@ -14,33 +14,8 @@ export const HTTP_STATUS = {
|
||||
GATEWAY_TIMEOUT: 504
|
||||
};
|
||||
|
||||
// OpenAI-compatible error types mapping
|
||||
export const ERROR_TYPES = {
|
||||
[HTTP_STATUS.BAD_REQUEST]: { type: "invalid_request_error", code: "bad_request" },
|
||||
[HTTP_STATUS.UNAUTHORIZED]: { type: "authentication_error", code: "invalid_api_key" },
|
||||
[HTTP_STATUS.FORBIDDEN]: { type: "permission_error", code: "insufficient_quota" },
|
||||
[HTTP_STATUS.NOT_FOUND]: { type: "invalid_request_error", code: "model_not_found" },
|
||||
[HTTP_STATUS.NOT_ACCEPTABLE]: { type: "invalid_request_error", code: "model_not_supported" },
|
||||
[HTTP_STATUS.RATE_LIMITED]: { type: "rate_limit_error", code: "rate_limit_exceeded" },
|
||||
[HTTP_STATUS.SERVER_ERROR]: { type: "server_error", code: "internal_server_error" },
|
||||
[HTTP_STATUS.BAD_GATEWAY]: { type: "server_error", code: "bad_gateway" },
|
||||
[HTTP_STATUS.SERVICE_UNAVAILABLE]: { type: "server_error", code: "service_unavailable" },
|
||||
[HTTP_STATUS.GATEWAY_TIMEOUT]: { type: "server_error", code: "gateway_timeout" }
|
||||
};
|
||||
|
||||
// Default error messages per status code
|
||||
export const DEFAULT_ERROR_MESSAGES = {
|
||||
[HTTP_STATUS.BAD_REQUEST]: "Bad request",
|
||||
[HTTP_STATUS.UNAUTHORIZED]: "Invalid API key provided",
|
||||
[HTTP_STATUS.FORBIDDEN]: "You exceeded your current quota",
|
||||
[HTTP_STATUS.NOT_FOUND]: "Model not found",
|
||||
[HTTP_STATUS.NOT_ACCEPTABLE]: "Model not supported",
|
||||
[HTTP_STATUS.RATE_LIMITED]: "Rate limit exceeded",
|
||||
[HTTP_STATUS.SERVER_ERROR]: "Internal server error",
|
||||
[HTTP_STATUS.BAD_GATEWAY]: "Bad gateway - upstream provider error",
|
||||
[HTTP_STATUS.SERVICE_UNAVAILABLE]: "Service temporarily unavailable",
|
||||
[HTTP_STATUS.GATEWAY_TIMEOUT]: "Gateway timeout"
|
||||
};
|
||||
// Re-export error config (backward compat)
|
||||
export { ERROR_TYPES, DEFAULT_ERROR_MESSAGES, BACKOFF_CONFIG, COOLDOWN_MS } from "./errorConfig.js";
|
||||
|
||||
// Cache TTLs (seconds)
|
||||
export const CACHE_TTL = {
|
||||
@@ -73,26 +48,6 @@ export const DEFAULT_RETRY_CONFIG = {
|
||||
502: 1 // Bad gateway - retry 1 time (transient)
|
||||
};
|
||||
|
||||
// Exponential backoff config for rate limits
|
||||
export const BACKOFF_CONFIG = {
|
||||
base: 1000,
|
||||
max: 2 * 60 * 1000,
|
||||
maxLevel: 15
|
||||
};
|
||||
|
||||
// Error-based cooldown times
|
||||
export const COOLDOWN_MS = {
|
||||
unauthorized: 2 * 60 * 1000,
|
||||
paymentRequired: 2 * 60 * 1000,
|
||||
notFound: 2 * 60 * 1000,
|
||||
transient: 30 * 1000,
|
||||
requestNotAllowed: 5 * 1000,
|
||||
// Legacy aliases
|
||||
rateLimit: 2 * 60 * 1000,
|
||||
serviceUnavailable: 2 * 1000,
|
||||
authExpired: 2 * 60 * 1000
|
||||
};
|
||||
|
||||
// Requests containing these texts will bypass provider
|
||||
export const SKIP_PATTERNS = [
|
||||
"Please write a 5-10 word title for the following conversation:"
|
||||
|
||||
109
open-sse/config/ttsModels.js
Normal file
109
open-sse/config/ttsModels.js
Normal file
@@ -0,0 +1,109 @@
|
||||
import { GOOGLE_TTS_LANGUAGES } from "./googleTtsLanguages.js";
|
||||
|
||||
// ── Voice definitions (DRY — reused across providers) ──────────────────────
|
||||
const VOICES = {
|
||||
alloy: { id: "alloy", name: "Alloy" },
|
||||
ash: { id: "ash", name: "Ash" },
|
||||
ballad: { id: "ballad", name: "Ballad" },
|
||||
cedar: { id: "cedar", name: "Cedar" },
|
||||
coral: { id: "coral", name: "Coral" },
|
||||
echo: { id: "echo", name: "Echo" },
|
||||
fable: { id: "fable", name: "Fable" },
|
||||
marin: { id: "marin", name: "Marin" },
|
||||
nova: { id: "nova", name: "Nova" },
|
||||
onyx: { id: "onyx", name: "Onyx" },
|
||||
sage: { id: "sage", name: "Sage" },
|
||||
shimmer: { id: "shimmer", name: "Shimmer" },
|
||||
verse: { id: "verse", name: "Verse" },
|
||||
};
|
||||
|
||||
const v = (...keys) => keys.map((k) => ({ ...VOICES[k], type: "tts" }));
|
||||
|
||||
// 9 voices for tts-1 / tts-1-hd
|
||||
const VOICES_STANDARD = v("alloy", "ash", "coral", "echo", "fable", "nova", "onyx", "sage", "shimmer");
|
||||
// 13 voices for gpt-4o-mini-tts
|
||||
const VOICES_FULL = v("alloy", "ash", "ballad", "cedar", "coral", "echo", "fable", "marin", "nova", "onyx", "sage", "shimmer", "verse");
|
||||
|
||||
// ── TTS Config (config-driven, single source of truth) ─────────────────────
|
||||
export const TTS_MODELS_CONFIG = {
|
||||
openai: {
|
||||
models: [
|
||||
{ id: "gpt-4o-mini-tts", name: "GPT-4o Mini TTS", type: "tts" },
|
||||
{ id: "tts-1-hd", name: "TTS-1 HD", type: "tts" },
|
||||
{ id: "tts-1", name: "TTS-1", type: "tts" },
|
||||
],
|
||||
voices: {
|
||||
"gpt-4o-mini-tts": VOICES_FULL,
|
||||
"tts-1": VOICES_STANDARD,
|
||||
"tts-1-hd": VOICES_STANDARD,
|
||||
},
|
||||
// Flat voice list (all unique voices) for backward compat
|
||||
allVoices: VOICES_FULL,
|
||||
},
|
||||
openrouter: {
|
||||
models: [
|
||||
{ id: "openai/gpt-4o-mini-tts", name: "GPT-4o Mini TTS", type: "tts" },
|
||||
{ id: "openai/tts-1-hd", name: "TTS-1 HD", type: "tts" },
|
||||
{ id: "openai/tts-1", name: "TTS-1", type: "tts" },
|
||||
],
|
||||
voices: {
|
||||
"openai/gpt-4o-mini-tts": VOICES_FULL,
|
||||
"openai/tts-1": VOICES_STANDARD,
|
||||
"openai/tts-1-hd": VOICES_STANDARD,
|
||||
},
|
||||
allVoices: VOICES_FULL,
|
||||
},
|
||||
elevenlabs: {
|
||||
models: [
|
||||
{ id: "eleven_flash_v2_5", name: "Flash v2.5 (Fastest)", type: "tts" },
|
||||
{ id: "eleven_turbo_v2_5", name: "Turbo v2.5 (Fast)", type: "tts" },
|
||||
{ id: "eleven_multilingual_v2", name: "Multilingual v2 (Quality)", type: "tts" },
|
||||
{ id: "eleven_monolingual_v1", name: "Monolingual v1 (English)", type: "tts" },
|
||||
],
|
||||
// voices come from API, not hardcoded
|
||||
},
|
||||
"edge-tts": {
|
||||
defaults: [
|
||||
{ id: "en-US-AriaNeural", name: "Aria (en-US)", type: "tts" },
|
||||
{ id: "en-US-GuyNeural", name: "Guy (en-US)", type: "tts" },
|
||||
{ id: "en-GB-SoniaNeural", name: "Sonia (en-GB)", type: "tts" },
|
||||
{ id: "vi-VN-HoaiMyNeural", name: "Hoai My (vi-VN)", type: "tts" },
|
||||
{ id: "vi-VN-NamMinhNeural", name: "Nam Minh (vi-VN)", type: "tts" },
|
||||
{ id: "zh-CN-XiaoxiaoNeural", name: "Xiaoxiao (zh-CN)", type: "tts" },
|
||||
{ id: "zh-CN-YunxiNeural", name: "Yunxi (zh-CN)", type: "tts" },
|
||||
{ id: "fr-FR-DeniseNeural", name: "Denise (fr-FR)", type: "tts" },
|
||||
{ id: "de-DE-KatjaNeural", name: "Katja (de-DE)", type: "tts" },
|
||||
{ id: "ja-JP-NanamiNeural", name: "Nanami (ja-JP)", type: "tts" },
|
||||
{ id: "ko-KR-SunHiNeural", name: "SunHi (ko-KR)", type: "tts" },
|
||||
],
|
||||
},
|
||||
"local-device": {
|
||||
defaults: [
|
||||
{ id: "default", name: "System Default Voice", type: "tts" },
|
||||
],
|
||||
},
|
||||
"google-tts": {
|
||||
defaults: GOOGLE_TTS_LANGUAGES,
|
||||
},
|
||||
};
|
||||
|
||||
// ── Helper: get voices for a specific model ────────────────────────────────
|
||||
export function getTtsVoicesForModel(provider, modelId) {
|
||||
const cfg = TTS_MODELS_CONFIG[provider];
|
||||
if (!cfg?.voices) return null;
|
||||
return cfg.voices[modelId] || cfg.allVoices || null;
|
||||
}
|
||||
|
||||
// ── Build flat entries for PROVIDER_MODELS backward compat ─────────────────
|
||||
export function buildTtsProviderModels() {
|
||||
const entries = {};
|
||||
for (const [provider, cfg] of Object.entries(TTS_MODELS_CONFIG)) {
|
||||
if (cfg.models) entries[`${provider}-tts-models`] = cfg.models;
|
||||
if (cfg.allVoices) entries[`${provider}-tts-voices`] = cfg.allVoices;
|
||||
if (cfg.defaults) entries[provider] = cfg.defaults;
|
||||
}
|
||||
// Keep openai-tts-voices key pointing to full voice list for backward compat
|
||||
entries["openai-tts-voices"] = TTS_MODELS_CONFIG.openai.allVoices;
|
||||
entries["openrouter-tts-voices"] = TTS_MODELS_CONFIG.openrouter.allVoices;
|
||||
return entries;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { BaseExecutor } from "./base.js";
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
|
||||
// Models that use /zen/v1/messages (claude format)
|
||||
const MESSAGES_MODELS = new Set(["big-pickle", "minimax-m2.5-free"]);
|
||||
const MESSAGES_MODELS = new Set(["big-pickle"]);
|
||||
|
||||
export class OpenCodeExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
|
||||
@@ -197,7 +197,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
// Provider returned error
|
||||
if (!providerResponse.ok) {
|
||||
trackPendingRequest(model, provider, connectionId, false, true);
|
||||
const { statusCode, message, retryAfterMs } = await parseUpstreamError(providerResponse, provider);
|
||||
const { statusCode, message } = await parseUpstreamError(providerResponse);
|
||||
appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(() => {});
|
||||
saveRequestDetail(buildRequestDetail({
|
||||
provider, model, connectionId,
|
||||
@@ -211,11 +211,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
|
||||
const errMsg = formatProviderError(new Error(message), provider, model, statusCode);
|
||||
console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
|
||||
if (retryAfterMs && provider === "antigravity") {
|
||||
log?.debug?.("RETRY", `Antigravity quota reset in ${Math.ceil(retryAfterMs / 1000)}s`);
|
||||
}
|
||||
reqLogger.logError(new Error(message), finalBody || translatedBody);
|
||||
return createErrorResult(statusCode, errMsg, retryAfterMs);
|
||||
return createErrorResult(statusCode, errMsg);
|
||||
}
|
||||
|
||||
const sharedCtx = { provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess };
|
||||
|
||||
@@ -270,7 +270,7 @@ export async function handleEmbeddingsCore({
|
||||
}
|
||||
|
||||
if (!providerResponse.ok) {
|
||||
const { statusCode, message } = await parseUpstreamError(providerResponse, provider);
|
||||
const { statusCode, message } = await parseUpstreamError(providerResponse);
|
||||
const errMsg = formatProviderError(new Error(message), provider, model, statusCode);
|
||||
log?.debug?.("EMBEDDINGS", `Provider error: ${errMsg}`);
|
||||
return createErrorResult(statusCode, errMsg);
|
||||
|
||||
@@ -339,6 +339,84 @@ export const VOICE_FETCHERS = {
|
||||
// openai: uses hardcoded voices from providerModels.js
|
||||
};
|
||||
|
||||
// ── OpenRouter TTS (via chat completions + audio modality) ───────────────────
|
||||
async function handleOpenRouterTts({ model, input, credentials, responseFormat = "mp3" }) {
|
||||
if (!credentials?.apiKey) {
|
||||
return createErrorResult(HTTP_STATUS.UNAUTHORIZED, "No OpenRouter API key configured");
|
||||
}
|
||||
|
||||
// model format: "tts-model/voice" e.g. "openai/gpt-4o-mini-tts/alloy"
|
||||
let ttsModel = "openai/gpt-4o-mini-tts";
|
||||
let voice = "alloy";
|
||||
if (model && model.includes("/")) {
|
||||
const lastSlash = model.lastIndexOf("/");
|
||||
const maybVoice = model.slice(lastSlash + 1);
|
||||
const maybeModel = model.slice(0, lastSlash);
|
||||
// voice names are simple lowercase words, model names contain "/"
|
||||
if (maybeModel.includes("/")) {
|
||||
ttsModel = maybeModel;
|
||||
voice = maybVoice;
|
||||
} else {
|
||||
voice = model;
|
||||
}
|
||||
} else if (model) {
|
||||
voice = model;
|
||||
}
|
||||
|
||||
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${credentials.apiKey}`,
|
||||
"HTTP-Referer": "https://endpoint-proxy.local",
|
||||
"X-Title": "Endpoint Proxy",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: ttsModel,
|
||||
modalities: ["text", "audio"],
|
||||
audio: { voice, format: "wav" },
|
||||
stream: true,
|
||||
messages: [{ role: "user", content: input }],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
return createErrorResult(res.status, err?.error?.message || `OpenRouter TTS failed: ${res.status}`);
|
||||
}
|
||||
|
||||
// Parse SSE stream, accumulate base64 audio chunks
|
||||
const chunks = [];
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop();
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ") || line === "data: [DONE]") continue;
|
||||
try {
|
||||
const json = JSON.parse(line.slice(6));
|
||||
const audioData = json.choices?.[0]?.delta?.audio?.data;
|
||||
if (audioData) chunks.push(audioData);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
if (chunks.length === 0) {
|
||||
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "OpenRouter TTS returned no audio data");
|
||||
}
|
||||
|
||||
const base64Audio = chunks.join("");
|
||||
return createTtsResponse(base64Audio, "wav", responseFormat);
|
||||
}
|
||||
|
||||
// ── OpenAI TTS ───────────────────────────────────────────────────────────────
|
||||
async function handleOpenAiTts({ model, input, credentials, responseFormat = "mp3" }) {
|
||||
if (!credentials?.apiKey) {
|
||||
@@ -422,6 +500,12 @@ const TTS_PROVIDERS = {
|
||||
},
|
||||
requiresCredentials: true,
|
||||
},
|
||||
"openrouter": {
|
||||
synthesize: async (text, model, credentials, responseFormat) => {
|
||||
return await handleOpenRouterTts({ model, input: text, credentials, responseFormat });
|
||||
},
|
||||
requiresCredentials: true,
|
||||
},
|
||||
};
|
||||
|
||||
// ── Core handler ───────────────────────────────────────────────
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { COOLDOWN_MS, BACKOFF_CONFIG, HTTP_STATUS } from "../config/runtimeConfig.js";
|
||||
import { ERROR_RULES, BACKOFF_CONFIG, TRANSIENT_COOLDOWN_MS } from "../config/errorConfig.js";
|
||||
|
||||
/**
|
||||
* Calculate exponential backoff cooldown for rate limits (429)
|
||||
@@ -13,82 +13,39 @@ export function getQuotaCooldown(backoffLevel = 0) {
|
||||
|
||||
/**
|
||||
* Check if error should trigger account fallback (switch to next account)
|
||||
* Config-driven: matches ERROR_RULES top-to-bottom (text rules first, then status)
|
||||
* @param {number} status - HTTP status code
|
||||
* @param {string} errorText - Error message text
|
||||
* @param {number} backoffLevel - Current backoff level for exponential backoff
|
||||
* @returns {{ shouldFallback: boolean, cooldownMs: number, newBackoffLevel?: number }}
|
||||
*/
|
||||
export function checkFallbackError(status, errorText, backoffLevel = 0) {
|
||||
// Check error message FIRST - specific patterns take priority over status codes
|
||||
if (errorText) {
|
||||
const errorStr = typeof errorText === "string" ? errorText : JSON.stringify(errorText);
|
||||
const lowerError = errorStr.toLowerCase();
|
||||
const lowerError = errorText
|
||||
? (typeof errorText === "string" ? errorText : JSON.stringify(errorText)).toLowerCase()
|
||||
: "";
|
||||
|
||||
if (lowerError.includes("no credentials")) {
|
||||
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.notFound };
|
||||
for (const rule of ERROR_RULES) {
|
||||
// Text-based rule: match substring in error message
|
||||
if (rule.text && lowerError && lowerError.includes(rule.text)) {
|
||||
if (rule.backoff) {
|
||||
const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel);
|
||||
return { shouldFallback: true, cooldownMs: getQuotaCooldown(backoffLevel), newBackoffLevel: newLevel };
|
||||
}
|
||||
return { shouldFallback: true, cooldownMs: rule.cooldownMs };
|
||||
}
|
||||
|
||||
if (lowerError.includes("request not allowed")) {
|
||||
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.requestNotAllowed };
|
||||
}
|
||||
|
||||
// Kiro: "improperly formed request" = model not available on this account tier
|
||||
// Treat as paymentRequired (long cooldown) so the model is locked and fallback occurs
|
||||
if (lowerError.includes("improperly formed request")) {
|
||||
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.paymentRequired };
|
||||
}
|
||||
|
||||
// Rate limit keywords - exponential backoff
|
||||
if (
|
||||
lowerError.includes("rate limit") ||
|
||||
lowerError.includes("too many requests") ||
|
||||
lowerError.includes("quota exceeded") ||
|
||||
lowerError.includes("capacity") ||
|
||||
lowerError.includes("overloaded")
|
||||
) {
|
||||
const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel);
|
||||
return {
|
||||
shouldFallback: true,
|
||||
cooldownMs: getQuotaCooldown(backoffLevel),
|
||||
newBackoffLevel: newLevel
|
||||
};
|
||||
// Status-based rule: match HTTP status code
|
||||
if (rule.status && rule.status === status) {
|
||||
if (rule.backoff) {
|
||||
const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel);
|
||||
return { shouldFallback: true, cooldownMs: getQuotaCooldown(backoffLevel), newBackoffLevel: newLevel };
|
||||
}
|
||||
return { shouldFallback: true, cooldownMs: rule.cooldownMs };
|
||||
}
|
||||
}
|
||||
|
||||
if (status === HTTP_STATUS.UNAUTHORIZED) {
|
||||
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.unauthorized };
|
||||
}
|
||||
|
||||
if (status === HTTP_STATUS.PAYMENT_REQUIRED || status === HTTP_STATUS.FORBIDDEN) {
|
||||
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.paymentRequired };
|
||||
}
|
||||
|
||||
if (status === HTTP_STATUS.NOT_FOUND) {
|
||||
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.notFound };
|
||||
}
|
||||
|
||||
// 429 - Rate limit with exponential backoff
|
||||
if (status === HTTP_STATUS.RATE_LIMITED) {
|
||||
const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel);
|
||||
return {
|
||||
shouldFallback: true,
|
||||
cooldownMs: getQuotaCooldown(backoffLevel),
|
||||
newBackoffLevel: newLevel
|
||||
};
|
||||
}
|
||||
|
||||
// Transient errors
|
||||
const transientStatuses = [
|
||||
HTTP_STATUS.NOT_ACCEPTABLE, HTTP_STATUS.REQUEST_TIMEOUT,
|
||||
HTTP_STATUS.SERVER_ERROR, HTTP_STATUS.BAD_GATEWAY,
|
||||
HTTP_STATUS.SERVICE_UNAVAILABLE, HTTP_STATUS.GATEWAY_TIMEOUT
|
||||
];
|
||||
if (transientStatuses.includes(status)) {
|
||||
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.transient };
|
||||
}
|
||||
|
||||
// All other errors - fallback with transient cooldown
|
||||
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.transient };
|
||||
// Default: transient cooldown for any unmatched error
|
||||
return { shouldFallback: true, cooldownMs: TRANSIENT_COOLDOWN_MS };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ERROR_TYPES, DEFAULT_ERROR_MESSAGES } from "../config/runtimeConfig.js";
|
||||
import { ERROR_TYPES, DEFAULT_ERROR_MESSAGES } from "../config/errorConfig.js";
|
||||
|
||||
/**
|
||||
* Build OpenAI-compatible error response body
|
||||
@@ -49,56 +49,17 @@ export async function writeStreamError(writer, statusCode, message) {
|
||||
await writer.write(encoder.encode(`data: ${JSON.stringify(errorBody)}\n\n`));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}>}
|
||||
* @returns {Promise<{statusCode: number, message: string}>}
|
||||
*/
|
||||
export async function parseUpstreamError(response, provider = null) {
|
||||
export async function parseUpstreamError(response) {
|
||||
let message = "";
|
||||
let retryAfterMs = null;
|
||||
|
||||
|
||||
try {
|
||||
const text = await response.text();
|
||||
|
||||
// Try parse as JSON
|
||||
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
message = json.error?.message || json.message || json.error || text;
|
||||
@@ -112,15 +73,9 @@ export async function parseUpstreamError(response, provider = null) {
|
||||
const messageStr = typeof message === "string" ? message : JSON.stringify(message);
|
||||
const finalMessage = messageStr || DEFAULT_ERROR_MESSAGES[response.status] || `Upstream error: ${response.status}`;
|
||||
|
||||
// Parse Antigravity-specific retry time from error message
|
||||
if (provider === "antigravity" && response.status === 429) {
|
||||
retryAfterMs = parseAntigravityRetryTime(finalMessage);
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: response.status,
|
||||
message: finalMessage,
|
||||
retryAfterMs
|
||||
message: finalMessage
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,23 +83,15 @@ export async function parseUpstreamError(response, provider = null) {
|
||||
* 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 }}
|
||||
* @returns {{ success: false, status: number, error: string, response: Response }}
|
||||
*/
|
||||
export function createErrorResult(statusCode, message, retryAfterMs = null) {
|
||||
const result = {
|
||||
export function createErrorResult(statusCode, message) {
|
||||
return {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user