From b669b6ffc1670ef03a199195a8da607eac84552b Mon Sep 17 00:00:00 2001 From: decolua Date: Wed, 15 Apr 2026 10:45:46 +0700 Subject: [PATCH] Refactor error handling to config-driven approach with centralized error rules Made-with: Cursor --- open-sse/config/errorConfig.js | 82 +++ open-sse/config/providerModels.js | 60 +- open-sse/config/runtimeConfig.js | 49 +- open-sse/config/ttsModels.js | 109 ++++ open-sse/executors/opencode.js | 2 +- open-sse/handlers/chatCore.js | 7 +- open-sse/handlers/embeddingsCore.js | 2 +- open-sse/handlers/ttsCore.js | 84 +++ open-sse/services/accountFallback.js | 87 +-- open-sse/utils/error.js | 71 +-- .../media-providers/[kind]/[id]/page.js | 258 +++++++- .../dashboard/providers/[id]/page.js | 3 +- .../api/providers/suggested-models/route.js | 49 ++ src/lib/localDb.js | 544 +++-------------- src/lib/usageDb.js | 553 ++++++++++-------- src/shared/components/Sidebar.js | 1 + src/shared/constants/providers.js | 47 +- src/shared/constants/ttsProviders.js | 12 +- src/shared/utils/providerModelsFetcher.js | 25 +- src/sse/handlers/tts.js | 2 +- 20 files changed, 1056 insertions(+), 991 deletions(-) create mode 100644 open-sse/config/errorConfig.js create mode 100644 open-sse/config/ttsModels.js create mode 100644 src/app/api/providers/suggested-models/route.js diff --git a/open-sse/config/errorConfig.js b/open-sse/config/errorConfig.js new file mode 100644 index 0000000..4c13b5d --- /dev/null +++ b/open-sse/config/errorConfig.js @@ -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, +}; diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index c95ee24..121c515 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -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 diff --git a/open-sse/config/runtimeConfig.js b/open-sse/config/runtimeConfig.js index 0ba964f..4ab7092 100644 --- a/open-sse/config/runtimeConfig.js +++ b/open-sse/config/runtimeConfig.js @@ -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:" diff --git a/open-sse/config/ttsModels.js b/open-sse/config/ttsModels.js new file mode 100644 index 0000000..b9dd539 --- /dev/null +++ b/open-sse/config/ttsModels.js @@ -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; +} diff --git a/open-sse/executors/opencode.js b/open-sse/executors/opencode.js index 5e77e12..173869e 100644 --- a/open-sse/executors/opencode.js +++ b/open-sse/executors/opencode.js @@ -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() { diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 4bbe9a1..c8254ae 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -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 }; diff --git a/open-sse/handlers/embeddingsCore.js b/open-sse/handlers/embeddingsCore.js index 8e724fb..453f74a 100644 --- a/open-sse/handlers/embeddingsCore.js +++ b/open-sse/handlers/embeddingsCore.js @@ -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); diff --git a/open-sse/handlers/ttsCore.js b/open-sse/handlers/ttsCore.js index 3883101..9618b7a 100644 --- a/open-sse/handlers/ttsCore.js +++ b/open-sse/handlers/ttsCore.js @@ -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 ─────────────────────────────────────────────── diff --git a/open-sse/services/accountFallback.js b/open-sse/services/accountFallback.js index 957655c..766d69e 100644 --- a/open-sse/services/accountFallback.js +++ b/open-sse/services/accountFallback.js @@ -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 }; } /** diff --git a/open-sse/utils/error.js b/open-sse/utils/error.js index cf63787..b82dead 100644 --- a/open-sse/utils/error.js +++ b/open-sse/utils/error.js @@ -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; } /** diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js index d59fbc2..4ddfa5f 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js @@ -11,6 +11,7 @@ import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import ConnectionsCard from "@/app/(dashboard)/dashboard/providers/components/ConnectionsCard"; import ModelsCard from "@/app/(dashboard)/dashboard/providers/components/ModelsCard"; import { TTS_PROVIDER_CONFIG } from "@/shared/constants/ttsProviders"; +import { getTtsVoicesForModel } from "open-sse/config/ttsModels.js"; // Shared row layout — defined outside components to avoid re-mount on re-render function Row({ label, children }) { @@ -40,6 +41,60 @@ const DEFAULT_RESPONSE_EXAMPLE = `{ "usage": { "prompt_tokens": 9, "total_tokens": 9 } }`; +// Config-driven example defaults per kind +const KIND_EXAMPLE_CONFIG = { + webSearch: { + inputLabel: "Query", + inputPlaceholder: "What is the latest news about AI?", + defaultInput: "What is the latest news about AI?", + bodyKey: "query", + defaultResponse: `{\n "results": [\n { "title": "...", "url": "...", "snippet": "..." }\n ]\n}`, + }, + webFetch: { + inputLabel: "URL", + inputPlaceholder: "https://example.com", + defaultInput: "https://example.com", + bodyKey: "url", + defaultResponse: `{\n "content": "...",\n "title": "...",\n "url": "..."\n}`, + }, + image: { + inputLabel: "Prompt", + inputPlaceholder: "A cute cat wearing a hat", + defaultInput: "A cute cat wearing a hat", + bodyKey: "prompt", + defaultResponse: `{\n "data": [\n { "url": "...", "b64_json": "..." }\n ]\n}`, + }, + imageToText: { + inputLabel: "Image URL", + inputPlaceholder: "https://example.com/image.png", + defaultInput: "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/1200px-Cat03.jpg", + bodyKey: "url", + extraBody: { prompt: "Describe this image in detail" }, + defaultResponse: `{\n "text": "A cat sitting on a windowsill...",\n "model": "..."\n}`, + }, + stt: { + inputLabel: "Audio URL", + inputPlaceholder: "https://example.com/audio.mp3", + defaultInput: "", + bodyKey: "url", + defaultResponse: `{\n "text": "Hello world...",\n "model": "..."\n}`, + }, + video: { + inputLabel: "Prompt", + inputPlaceholder: "A serene lake at sunset", + defaultInput: "A serene lake at sunset", + bodyKey: "prompt", + defaultResponse: `{\n "data": [\n { "url": "..." }\n ]\n}`, + }, + music: { + inputLabel: "Prompt", + inputPlaceholder: "A calm piano melody", + defaultInput: "A calm piano melody", + bodyKey: "prompt", + defaultResponse: `{\n "data": [\n { "url": "...", "format": "mp3" }\n ]\n}`, + }, +}; + // EmbeddingExampleCard function EmbeddingExampleCard({ providerId }) { const providerAlias = getProviderAlias(providerId); @@ -300,8 +355,13 @@ function TtsExampleCard({ providerId }) { // Pre-select default voice based on provider config if (config.voiceSource === "hardcoded") { - const voiceKey = config.voiceKey || providerId; - const voices = getModelsByProviderId(voiceKey).filter((m) => m.type === "tts"); + const defaultModel = config.hasModelSelector && config.modelKey + ? (getModelsByProviderId(config.modelKey)?.[0]?.id || "") + : ""; + // Use per-model voices if available, else flat list + const voices = (config.voicesPerModel && defaultModel) + ? (getTtsVoicesForModel(providerId, defaultModel) || []) + : getModelsByProviderId(config.voiceKey || providerId).filter((m) => m.type === "tts"); if (voices.length) { if (config.hasBrowseButton) { // Google TTS: pre-select "en" (English) as default, show as single voice chip @@ -311,7 +371,7 @@ function TtsExampleCard({ providerId }) { setSelectedVoiceName(defaultVoice.name); setCountryVoices([{ id: defaultVoice.id, name: defaultVoice.name }]); } else { - // OpenAI: set voice chips directly (no language picker) + // OpenAI/OpenRouter: set voice chips directly (no language picker) setCountryVoices(voices); setSelectedVoice(voices[0].id); setSelectedVoiceName(voices[0].name || voices[0].id); @@ -321,6 +381,17 @@ function TtsExampleCard({ providerId }) { // api-language (edge-tts, local-device, elevenlabs): NO default load, wait for user to pick language }, [providerId]); + // Update voices when model changes (voicesPerModel providers) + useEffect(() => { + if (!config.voicesPerModel || !selectedModel) return; + const voices = getTtsVoicesForModel(providerId, selectedModel) || []; + setCountryVoices(voices); + if (voices.length) { + setSelectedVoice(voices[0].id); + setSelectedVoiceName(voices[0].name || voices[0].id); + } + }, [selectedModel]); + // Open modal — load language list const openModal = async () => { setModalOpen(true); @@ -745,6 +816,186 @@ function TtsExampleCard({ providerId }) { ); } +// Generic Example Card — config-driven for webSearch, webFetch, image, imageToText, stt, video, music +function GenericExampleCard({ providerId, kind }) { + const providerAlias = getProviderAlias(providerId); + const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind); + const exConfig = KIND_EXAMPLE_CONFIG[kind]; + if (!kindConfig || !exConfig) return null; + + const [input, setInput] = useState(exConfig.defaultInput); + const [apiKey, setApiKey] = useState(""); + const [useTunnel, setUseTunnel] = useState(false); + const [localEndpoint, setLocalEndpoint] = useState(""); + const [tunnelEndpoint, setTunnelEndpoint] = useState(""); + const [result, setResult] = useState(null); + const [running, setRunning] = useState(false); + const [error, setError] = useState(""); + const { copied: copiedCurl, copy: copyCurl } = useCopyToClipboard(); + const { copied: copiedRes, copy: copyRes } = useCopyToClipboard(); + + useEffect(() => { + setLocalEndpoint(window.location.origin); + fetch("/api/keys") + .then((r) => r.json()) + .then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); }) + .catch(() => {}); + fetch("/api/tunnel/status") + .then((r) => r.json()) + .then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); }) + .catch(() => {}); + }, []); + + const endpoint = useTunnel ? tunnelEndpoint : localEndpoint; + const apiPath = kindConfig.endpoint.path; + + const requestBody = { + model: `${providerAlias}/model-name`, + [exConfig.bodyKey]: input, + ...exConfig.extraBody, + }; + + const curlSnippet = `curl -X ${kindConfig.endpoint.method} ${endpoint}${apiPath} \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\ + -d '${JSON.stringify(requestBody)}'`; + + const handleRun = async () => { + if (!input.trim()) return; + setRunning(true); + setError(""); + setResult(null); + const start = Date.now(); + try { + const headers = { "Content-Type": "application/json" }; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + const body = { ...requestBody, model: `${providerAlias}/model-name` }; + const res = await fetch(`/api${apiPath}`, { + method: kindConfig.endpoint.method, + headers, + body: JSON.stringify(body), + }); + const latencyMs = Date.now() - start; + const data = await res.json(); + if (!res.ok) { setError(data?.error?.message || data?.error || `HTTP ${res.status}`); return; } + setResult({ data, latencyMs }); + } catch (e) { + setError(e.message || "Network error"); + } finally { + setRunning(false); + } + }; + + const resultJson = result ? JSON.stringify(result.data, null, 2) : ""; + + return ( + +

Example

+
+ {/* Endpoint */} + +
+ + {endpoint}{apiPath} + + {tunnelEndpoint && ( + + )} +
+
+ + {/* API Key */} + + + {apiKey ? `${apiKey.slice(0, 8)}${"\u2022".repeat(Math.min(20, apiKey.length - 8))}` : No key configured} + + + + {/* Input */} + +
+ setInput(e.target.value)} + placeholder={exConfig.inputPlaceholder} + className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> + {input && ( + + )} +
+
+ + {/* Curl + Run */} +
+
+ Request +
+ + +
+
+
{curlSnippet}
+
+ + {/* Error */} + {error &&

{error}

} + + {/* Response */} +
+
+ + Response {result && ⚡ {result.latencyMs}ms} + + {result && ( + + )} +
+
+            {result ? resultJson : exConfig.defaultResponse}
+          
+
+
+
+ ); +} + // MediaProviderDetailPage export default function MediaProviderDetailPage() { const { kind, id } = useParams(); @@ -817,6 +1068,7 @@ export default function MediaProviderDetailPage() { {/* Example — per kind */} {kind === "embedding" && } {kind === "tts" && } + {KIND_EXAMPLE_CONFIG[kind] && } ); } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 19b970e..7e12f47 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -665,8 +665,9 @@ export default function ProviderDetailPage() { {/* Suggested models from provider API — show only models not yet added */} {suggestedModels.length > 0 && (() => { const addedFullModels = new Set(Object.values(modelAliases)); + const hardcodedIds = new Set(models.map((m) => m.id)); const notAdded = suggestedModels.filter( - (m) => !addedFullModels.has(`${providerStorageAlias}/${m.id}`) + (m) => !addedFullModels.has(`${providerStorageAlias}/${m.id}`) && !hardcodedIds.has(m.id) ); if (notAdded.length === 0) return null; return ( diff --git a/src/app/api/providers/suggested-models/route.js b/src/app/api/providers/suggested-models/route.js new file mode 100644 index 0000000..140e3f8 --- /dev/null +++ b/src/app/api/providers/suggested-models/route.js @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +const FILTERS = { + "openrouter-free": (models) => + models + .filter( + (m) => + m.pricing?.prompt === "0" && + m.pricing?.completion === "0" && + m.context_length >= 200000 + ) + .map((m) => ({ id: m.id, name: m.name, contextLength: m.context_length })) + .sort((a, b) => b.contextLength - a.contextLength), + + "opencode-free": (models) => + models + .filter((m) => m.id?.endsWith("-free")) + .map((m) => ({ id: m.id, name: m.id })), +}; + +export async function GET(request) { + const { searchParams } = new URL(request.url); + const url = searchParams.get("url"); + const type = searchParams.get("type"); + + if (!url || !type) { + return NextResponse.json({ error: "Missing url or type" }, { status: 400 }); + } + + const filter = FILTERS[type]; + if (!filter) { + return NextResponse.json({ error: "Unknown filter type" }, { status: 400 }); + } + + try { + const res = await fetch(url); + if (!res.ok) { + return NextResponse.json({ data: [] }); + } + const json = await res.json(); + const raw = json.data ?? json.models ?? json; + const data = filter(Array.isArray(raw) ? raw : []); + return NextResponse.json({ data }); + } catch { + return NextResponse.json({ data: [] }); + } +} diff --git a/src/lib/localDb.js b/src/lib/localDb.js index 0884808..bb89702 100644 --- a/src/lib/localDb.js +++ b/src/lib/localDb.js @@ -7,18 +7,14 @@ import fs from "node:fs"; import lockfile from "proper-lockfile"; const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128"; - const isCloud = typeof caches !== 'undefined' || typeof caches === 'object'; -// Get app name - fixed constant to avoid Windows path issues in standalone build function getAppName() { return "9router"; } -// Get user data directory based on platform function getUserDataDir() { - if (isCloud) return "/tmp"; // Fallback for Workers - + if (isCloud) return "/tmp"; if (process.env.DATA_DIR) return process.env.DATA_DIR; const platform = process.platform; @@ -27,60 +23,41 @@ function getUserDataDir() { if (platform === "win32") { return path.join(process.env.APPDATA || path.join(homeDir, "AppData", "Roaming"), appName); - } else { - // macOS & Linux: ~/.{appName} - return path.join(homeDir, `.${appName}`); } + return path.join(homeDir, `.${appName}`); } -// Data file path - stored in user home directory const DATA_DIR = getUserDataDir(); const DB_FILE = isCloud ? null : path.join(DATA_DIR, "db.json"); -// Ensure data directory exists if (!isCloud && !fs.existsSync(DATA_DIR)) { fs.mkdirSync(DATA_DIR, { recursive: true }); } -// Default data structure -const defaultData = { - providerConnections: [], - providerNodes: [], - proxyPools: [], - modelAliases: {}, - mitmAlias: {}, - combos: [], - apiKeys: [], - settings: { - cloudEnabled: false, - tunnelEnabled: false, - tunnelUrl: "", - tailscaleEnabled: false, - tailscaleUrl: "", - stickyRoundRobinLimit: 3, - providerStrategies: {}, - comboStrategy: "fallback", - comboStrategies: {}, - requireLogin: true, - tunnelDashboardAccess: true, - observabilityEnabled: true, - observabilityMaxRecords: 1000, - observabilityBatchSize: 20, - observabilityFlushIntervalMs: 5000, - observabilityMaxJsonSize: 1024, - outboundProxyEnabled: false, - outboundProxyUrl: "", - outboundNoProxy: "", - mitmRouterBaseUrl: DEFAULT_MITM_ROUTER_BASE, - }, - pricing: {} // NEW: pricing configuration +const DEFAULT_SETTINGS = { + cloudEnabled: false, + tunnelEnabled: false, + tunnelUrl: "", + tunnelProvider: "cloudflare", + tailscaleEnabled: false, + tailscaleUrl: "", + stickyRoundRobinLimit: 3, + providerStrategies: {}, + comboStrategy: "fallback", + comboStrategies: {}, + requireLogin: true, + tunnelDashboardAccess: true, + observabilityEnabled: true, + observabilityMaxRecords: 1000, + observabilityBatchSize: 20, + observabilityFlushIntervalMs: 5000, + observabilityMaxJsonSize: 1024, + outboundProxyEnabled: false, + outboundProxyUrl: "", + outboundNoProxy: "", + mitmRouterBaseUrl: DEFAULT_MITM_ROUTER_BASE, }; -// Seed db.json with defaults on first run so proper-lockfile never hits ENOENT -if (!isCloud && DB_FILE && !fs.existsSync(DB_FILE)) { - fs.writeFileSync(DB_FILE, JSON.stringify(defaultData, null, 2)); -} - function cloneDefaultData() { return { providerConnections: [], @@ -90,31 +67,15 @@ function cloneDefaultData() { mitmAlias: {}, combos: [], apiKeys: [], - settings: { - cloudEnabled: false, - tunnelEnabled: false, - tunnelUrl: "", - tunnelProvider: "cloudflare", - stickyRoundRobinLimit: 3, - providerStrategies: {}, - comboStrategy: "fallback", - comboStrategies: {}, - requireLogin: true, - tunnelDashboardAccess: true, - observabilityEnabled: true, - observabilityMaxRecords: 1000, - observabilityBatchSize: 20, - observabilityFlushIntervalMs: 5000, - observabilityMaxJsonSize: 1024, - outboundProxyEnabled: false, - outboundProxyUrl: "", - outboundNoProxy: "", - mitmRouterBaseUrl: DEFAULT_MITM_ROUTER_BASE, - }, + settings: { ...DEFAULT_SETTINGS }, pricing: {}, }; } +if (!isCloud && DB_FILE && !fs.existsSync(DB_FILE)) { + fs.writeFileSync(DB_FILE, JSON.stringify(cloneDefaultData(), null, 2)); +} + function ensureDbShape(data) { const defaults = cloneDefaultData(); const next = data && typeof data === "object" ? data : {}; @@ -127,24 +88,16 @@ function ensureDbShape(data) { continue; } - if ( - key === "settings" && - (typeof next.settings !== "object" || Array.isArray(next.settings)) - ) { + if (key === "settings" && (typeof next.settings !== "object" || Array.isArray(next.settings))) { next.settings = { ...defaultValue }; changed = true; continue; } - if ( - key === "settings" && - typeof next.settings === "object" && - !Array.isArray(next.settings) - ) { + if (key === "settings" && typeof next.settings === "object" && !Array.isArray(next.settings)) { for (const [settingKey, settingDefault] of Object.entries(defaultValue)) { if (next.settings[settingKey] === undefined) { - // Backward-compat: if users previously saved a proxy URL, - // default to enabled so behavior doesn't silently change. + // Backward-compat: if proxy URL was saved, default outboundProxyEnabled to true if ( settingKey === "outboundProxyEnabled" && typeof next.settings.outboundProxyUrl === "string" && @@ -173,21 +126,13 @@ function ensureDbShape(data) { return { data: next, changed }; } -// Singleton instance let dbInstance = null; -// Lock options for proper-lockfile (increased retries for multi-process robustness) const LOCK_OPTIONS = { - retries: { - retries: 15, - minTimeout: 50, - maxTimeout: 3000, - }, - stale: 10000, // Consider lock stale after 10s + retries: { retries: 15, minTimeout: 50, maxTimeout: 3000 }, + stale: 10000, }; -// In-process mutex to serialize DB access within the same process -// This prevents ELOCKED when concurrent requests in the same process try to acquire the file lock class LocalMutex { constructor() { this._queue = []; @@ -206,93 +151,47 @@ class LocalMutex { _release() { const next = this._queue.shift(); - if (next) { - next(); - } else { - this._locked = false; - } + if (next) next(); + else this._locked = false; } } -// Singleton local mutex for in-process serialization const localMutex = new LocalMutex(); -/** - * Safely read database with file locking - * Uses local mutex first to serialize within-process access, then file lock for cross-process - */ +async function withFileLock(db, operation) { + if (isCloud) { + await operation(); + return; + } + + const releaseLocal = await localMutex.acquire(); + let release = null; + try { + release = await lockfile.lock(DB_FILE, LOCK_OPTIONS); + await operation(); + } catch (error) { + if (error.code === "ELOCKED") { + console.warn(`[DB] File is locked, retrying...`); + } + throw error; + } finally { + if (release) { + try { await release(); } catch (_) { } + } + releaseLocal(); + } +} + async function safeRead(db) { - if (isCloud) { - await db.read(); - return; - } - - // Acquire local mutex first (in-process serialization) - const releaseLocal = await localMutex.acquire(); - let release = null; - try { - // Acquire file lock (cross-process serialization) - release = await lockfile.lock(DB_FILE, LOCK_OPTIONS); - await db.read(); - } catch (error) { - if (error.code === "ELOCKED") { - console.warn("[DB] File is locked, retrying read..."); - throw error; - } - throw error; - } finally { - if (release) { - try { - await release(); - } catch (err) { - // Ignore unlock errors - } - } - releaseLocal(); - } + await withFileLock(db, () => db.read()); } -/** - * Safely write database with file locking - * Uses local mutex first to serialize within-process access, then file lock for cross-process - */ async function safeWrite(db) { - if (isCloud) { - await db.write(); - return; - } - - // Acquire local mutex first (in-process serialization) - const releaseLocal = await localMutex.acquire(); - let release = null; - try { - // Acquire file lock (cross-process serialization) - release = await lockfile.lock(DB_FILE, LOCK_OPTIONS); - await db.write(); - } catch (error) { - if (error.code === "ELOCKED") { - console.warn("[DB] File is locked, retrying write..."); - throw error; - } - throw error; - } finally { - if (release) { - try { - await release(); - } catch (err) { - // Ignore unlock errors - } - } - releaseLocal(); - } + await withFileLock(db, () => db.write()); } -/** - * Get database instance (singleton) - */ export async function getDb() { if (isCloud) { - // Return in-memory DB for Workers if (!dbInstance) { const data = cloneDefaultData(); dbInstance = new Low({ read: async () => { }, write: async () => { } }, data); @@ -302,11 +201,9 @@ export async function getDb() { } if (!dbInstance) { - const adapter = new JSONFile(DB_FILE); - dbInstance = new Low(adapter, cloneDefaultData()); + dbInstance = new Low(new JSONFile(DB_FILE), cloneDefaultData()); } - // Always read latest disk state to avoid stale singleton data across route workers. try { await safeRead(dbInstance); } catch (error) { @@ -319,80 +216,46 @@ export async function getDb() { } } - // Initialize/migrate missing keys for older DB schema versions. if (!dbInstance.data) { dbInstance.data = cloneDefaultData(); await safeWrite(dbInstance); } else { const { data, changed } = ensureDbShape(dbInstance.data); dbInstance.data = data; - if (changed) { - await safeWrite(dbInstance); - } + if (changed) await safeWrite(dbInstance); } return dbInstance; } -// ============ Provider Connections ============ - -/** - * Get all provider connections - */ export async function getProviderConnections(filter = {}) { const db = await getDb(); let connections = db.data.providerConnections || []; - if (filter.provider) { - connections = connections.filter(c => c.provider === filter.provider); - } - if (filter.isActive !== undefined) { - connections = connections.filter(c => c.isActive === filter.isActive); - } + if (filter.provider) connections = connections.filter(c => c.provider === filter.provider); + if (filter.isActive !== undefined) connections = connections.filter(c => c.isActive === filter.isActive); - // Sort by priority (lower = higher priority) connections.sort((a, b) => (a.priority || 999) - (b.priority || 999)); - return connections; } -// ============ Provider Nodes ============ - -/** - * Get provider nodes - */ export async function getProviderNodes(filter = {}) { const db = await getDb(); let nodes = db.data.providerNodes || []; - - if (filter.type) { - nodes = nodes.filter((node) => node.type === filter.type); - } - + if (filter.type) nodes = nodes.filter((node) => node.type === filter.type); return nodes; } -/** - * Get provider node by ID - */ export async function getProviderNodeById(id) { const db = await getDb(); return db.data.providerNodes.find((node) => node.id === id) || null; } -/** - * Create provider node - */ export async function createProviderNode(data) { const db = await getDb(); - - // Initialize providerNodes if undefined (backward compatibility) - if (!db.data.providerNodes) { - db.data.providerNodes = []; - } + if (!db.data.providerNodes) db.data.providerNodes = []; const now = new Date().toISOString(); - const node = { id: data.id || uuidv4(), type: data.type, @@ -406,21 +269,14 @@ export async function createProviderNode(data) { db.data.providerNodes.push(node); await safeWrite(db); - return node; } -/** - * Update provider node - */ export async function updateProviderNode(id, data) { const db = await getDb(); - if (!db.data.providerNodes) { - db.data.providerNodes = []; - } + if (!db.data.providerNodes) db.data.providerNodes = []; const index = db.data.providerNodes.findIndex((node) => node.id === id); - if (index === -1) return null; db.data.providerNodes[index] = { @@ -430,65 +286,39 @@ export async function updateProviderNode(id, data) { }; await safeWrite(db); - return db.data.providerNodes[index]; } -/** - * Delete provider node - */ export async function deleteProviderNode(id) { const db = await getDb(); - if (!db.data.providerNodes) { - db.data.providerNodes = []; - } + if (!db.data.providerNodes) db.data.providerNodes = []; const index = db.data.providerNodes.findIndex((node) => node.id === id); - if (index === -1) return null; const [removed] = db.data.providerNodes.splice(index, 1); await safeWrite(db); - return removed; } -// ============ Proxy Pools ============ - -/** - * Get proxy pools - */ export async function getProxyPools(filter = {}) { const db = await getDb(); let pools = db.data.proxyPools || []; - if (filter.isActive !== undefined) { - pools = pools.filter((pool) => pool.isActive === filter.isActive); - } - - if (filter.testStatus) { - pools = pools.filter((pool) => pool.testStatus === filter.testStatus); - } + if (filter.isActive !== undefined) pools = pools.filter((pool) => pool.isActive === filter.isActive); + if (filter.testStatus) pools = pools.filter((pool) => pool.testStatus === filter.testStatus); return pools.sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0)); } -/** - * Get proxy pool by ID - */ export async function getProxyPoolById(id) { const db = await getDb(); return (db.data.proxyPools || []).find((pool) => pool.id === id) || null; } -/** - * Create proxy pool - */ export async function createProxyPool(data) { const db = await getDb(); - if (!db.data.proxyPools) { - db.data.proxyPools = []; - } + if (!db.data.proxyPools) db.data.proxyPools = []; const now = new Date().toISOString(); const pool = { @@ -508,18 +338,12 @@ export async function createProxyPool(data) { db.data.proxyPools.push(pool); await safeWrite(db); - return pool; } -/** - * Update proxy pool - */ export async function updateProxyPool(id, data) { const db = await getDb(); - if (!db.data.proxyPools) { - db.data.proxyPools = []; - } + if (!db.data.proxyPools) db.data.proxyPools = []; const index = db.data.proxyPools.findIndex((pool) => pool.id === id); if (index === -1) return null; @@ -534,27 +358,18 @@ export async function updateProxyPool(id, data) { return db.data.proxyPools[index]; } -/** - * Delete proxy pool - */ export async function deleteProxyPool(id) { const db = await getDb(); - if (!db.data.proxyPools) { - db.data.proxyPools = []; - } + if (!db.data.proxyPools) db.data.proxyPools = []; const index = db.data.proxyPools.findIndex((pool) => pool.id === id); if (index === -1) return null; const [removed] = db.data.proxyPools.splice(index, 1); await safeWrite(db); - return removed; } -/** - * Delete all provider connections by provider ID - */ export async function deleteProviderConnectionsByProvider(providerId) { const db = await getDb(); const beforeCount = db.data.providerConnections.length; @@ -566,23 +381,16 @@ export async function deleteProviderConnectionsByProvider(providerId) { return deletedCount; } -/** - * Get provider connection by ID - */ export async function getProviderConnectionById(id) { const db = await getDb(); return db.data.providerConnections.find(c => c.id === id) || null; } -/** - * Create or update provider connection (upsert by provider + email/name) - */ export async function createProviderConnection(data) { const db = await getDb(); const now = new Date().toISOString(); - // Check for existing connection with same provider and email (for OAuth) - // or same provider and name (for API key) + // Upsert: check existing by provider + email (oauth) or provider + name (apikey) let existingIndex = -1; if (data.authType === "oauth" && data.email) { existingIndex = db.data.providerConnections.findIndex( @@ -594,7 +402,6 @@ export async function createProviderConnection(data) { ); } - // If exists, update instead of create if (existingIndex !== -1) { db.data.providerConnections[existingIndex] = { ...db.data.providerConnections[existingIndex], @@ -605,13 +412,11 @@ export async function createProviderConnection(data) { return db.data.providerConnections[existingIndex]; } - // Generate name for OAuth if not provided let connectionName = data.name || null; if (!connectionName && data.authType === "oauth") { if (data.email) { connectionName = data.email; } else { - // Count existing connections for this provider to generate index const existingCount = db.data.providerConnections.filter( c => c.provider === data.provider ).length; @@ -619,17 +424,13 @@ export async function createProviderConnection(data) { } } - // Auto-increment priority if not provided let connectionPriority = data.priority; if (!connectionPriority) { - const providerConnections = db.data.providerConnections.filter( - c => c.provider === data.provider - ); + const providerConnections = db.data.providerConnections.filter(c => c.provider === data.provider); const maxPriority = providerConnections.reduce((max, c) => Math.max(max, c.priority || 0), 0); connectionPriority = maxPriority + 1; } - // Create new connection - only save fields with actual values const connection = { id: uuidv4(), provider: data.provider, @@ -641,7 +442,6 @@ export async function createProviderConnection(data) { updatedAt: now, }; - // Only add optional fields if they have values const optionalFields = [ "displayName", "email", "globalPriority", "defaultModel", "accessToken", "refreshToken", "expiresAt", "tokenType", @@ -656,27 +456,20 @@ export async function createProviderConnection(data) { } } - // Only add providerSpecificData if it has content if (data.providerSpecificData && Object.keys(data.providerSpecificData).length > 0) { connection.providerSpecificData = data.providerSpecificData; } db.data.providerConnections.push(connection); await safeWrite(db); - - // Reorder to ensure consistency await reorderProviderConnections(data.provider); return connection; } -/** - * Update provider connection - */ export async function updateProviderConnection(id, data) { const db = await getDb(); const index = db.data.providerConnections.findIndex(c => c.id === id); - if (index === -1) return null; const providerId = db.data.providerConnections[index].provider; @@ -688,38 +481,24 @@ export async function updateProviderConnection(id, data) { }; await safeWrite(db); - - // Reorder if priority was changed - if (data.priority !== undefined) { - await reorderProviderConnections(providerId); - } + if (data.priority !== undefined) await reorderProviderConnections(providerId); return db.data.providerConnections[index]; } -/** - * Delete provider connection - */ export async function deleteProviderConnection(id) { const db = await getDb(); const index = db.data.providerConnections.findIndex(c => c.id === id); - if (index === -1) return false; const providerId = db.data.providerConnections[index].provider; - db.data.providerConnections.splice(index, 1); await safeWrite(db); - - // Reorder to fill gaps await reorderProviderConnections(providerId); return true; } -/** - * Reorder provider connections to ensure unique, sequential priorities - */ export async function reorderProviderConnections(providerId) { const db = await getDb(); if (!db.data.providerConnections) return; @@ -727,14 +506,11 @@ export async function reorderProviderConnections(providerId) { const providerConnections = db.data.providerConnections .filter(c => c.provider === providerId) .sort((a, b) => { - // Sort by priority first const pDiff = (a.priority || 0) - (b.priority || 0); if (pDiff !== 0) return pDiff; - // Use updatedAt as tie-breaker (newer first) return new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0); }); - // Re-assign sequential priorities providerConnections.forEach((conn, index) => { conn.priority = index + 1; }); @@ -742,36 +518,23 @@ export async function reorderProviderConnections(providerId) { await safeWrite(db); } -// ============ Model Aliases ============ - -/** - * Get all model aliases - */ export async function getModelAliases() { const db = await getDb(); return db.data.modelAliases || {}; } -/** - * Set model alias - */ export async function setModelAlias(alias, model) { const db = await getDb(); db.data.modelAliases[alias] = model; await safeWrite(db); } -/** - * Delete model alias - */ export async function deleteModelAlias(alias) { const db = await getDb(); delete db.data.modelAliases[alias]; await safeWrite(db); } -// ============ MITM Alias ============ - export async function getMitmAlias(toolName) { const db = await getDb(); const all = db.data.mitmAlias || {}; @@ -786,35 +549,21 @@ export async function setMitmAliasAll(toolName, mappings) { await safeWrite(db); } -// ============ Combos ============ - -/** - * Get all combos - */ export async function getCombos() { const db = await getDb(); return db.data.combos || []; } -/** - * Get combo by ID - */ export async function getComboById(id) { const db = await getDb(); return (db.data.combos || []).find(c => c.id === id) || null; } -/** - * Get combo by name - */ export async function getComboByName(name) { const db = await getDb(); return (db.data.combos || []).find(c => c.name === name) || null; } -/** - * Create combo - */ export async function createCombo(data) { const db = await getDb(); if (!db.data.combos) db.data.combos = []; @@ -833,9 +582,6 @@ export async function createCombo(data) { return combo; } -/** - * Update combo - */ export async function updateCombo(id, data) { const db = await getDb(); if (!db.data.combos) db.data.combos = []; @@ -853,9 +599,6 @@ export async function updateCombo(id, data) { return db.data.combos[index]; } -/** - * Delete combo - */ export async function deleteCombo(id) { const db = await getDb(); if (!db.data.combos) return false; @@ -868,19 +611,11 @@ export async function deleteCombo(id) { return true; } -// ============ API Keys ============ - -/** - * Get all API keys - */ export async function getApiKeys() { const db = await getDb(); return db.data.apiKeys || []; } -/** - * Generate short random key (8 chars) - */ function generateShortKey() { const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; let result = ""; @@ -890,20 +625,12 @@ function generateShortKey() { return result; } -/** - * Create API key - * @param {string} name - Key name - * @param {string} machineId - MachineId (required) - */ export async function createApiKey(name, machineId) { - if (!machineId) { - throw new Error("machineId is required"); - } + if (!machineId) throw new Error("machineId is required"); const db = await getDb(); const now = new Date().toISOString(); - // Always use new format: sk-{machineId}-{keyId}-{crc8} const { generateApiKeyWithMachine } = await import("@/shared/utils/apiKey"); const result = generateApiKeyWithMachine(machineId); @@ -918,62 +645,39 @@ export async function createApiKey(name, machineId) { db.data.apiKeys.push(apiKey); await safeWrite(db); - return apiKey; } -/** - * Delete API key - */ export async function deleteApiKey(id) { const db = await getDb(); const index = db.data.apiKeys.findIndex(k => k.id === id); - if (index === -1) return false; db.data.apiKeys.splice(index, 1); await safeWrite(db); - return true; } -/** - * Get API key by ID - */ export async function getApiKeyById(id) { const db = await getDb(); return db.data.apiKeys.find(k => k.id === id) || null; } -/** - * Update API key - */ export async function updateApiKey(id, data) { const db = await getDb(); const index = db.data.apiKeys.findIndex(k => k.id === id); if (index === -1) return null; - db.data.apiKeys[index] = { - ...db.data.apiKeys[index], - ...data, - }; + db.data.apiKeys[index] = { ...db.data.apiKeys[index], ...data }; await safeWrite(db); return db.data.apiKeys[index]; } -/** - * Validate API key - */ export async function validateApiKey(key) { const db = await getDb(); const found = db.data.apiKeys.find(k => k.key === key); return found && found.isActive !== false; } -// ============ Data Cleanup ============ - -/** - * Remove null/empty fields from all provider connections to reduce db size - */ export async function cleanupProviderConnections() { const db = await getDb(); const fieldsToCheck = [ @@ -992,53 +696,33 @@ export async function cleanupProviderConnections() { cleaned++; } } - // Remove empty providerSpecificData if (connection.providerSpecificData && Object.keys(connection.providerSpecificData).length === 0) { delete connection.providerSpecificData; cleaned++; } } - if (cleaned > 0) { - await safeWrite(db); - } + if (cleaned > 0) await safeWrite(db); return cleaned; } -// ============ Settings ============ - -/** - * Get settings - */ export async function getSettings() { const db = await getDb(); return db.data.settings || { cloudEnabled: false }; } -/** - * Update settings - */ export async function updateSettings(updates) { const db = await getDb(); - db.data.settings = { - ...db.data.settings, - ...updates - }; + db.data.settings = { ...db.data.settings, ...updates }; await safeWrite(db); return db.data.settings; } -/** - * Export full database payload - */ export async function exportDb() { const db = await getDb(); return db.data || cloneDefaultData(); } -/** - * Import full database payload - */ export async function importDb(payload) { if (!payload || typeof payload !== "object" || Array.isArray(payload)) { throw new Error("Invalid database payload"); @@ -1059,42 +743,24 @@ export async function importDb(payload) { const db = await getDb(); db.data = normalized; await safeWrite(db); - return db.data; } -/** - * Check if cloud is enabled - */ export async function isCloudEnabled() { const settings = await getSettings(); return settings.cloudEnabled === true; } -/** - * Get cloud URL (UI config > env > default) - */ export async function getCloudUrl() { const settings = await getSettings(); - return settings.cloudUrl - || process.env.CLOUD_URL - || process.env.NEXT_PUBLIC_CLOUD_URL - || ""; + return settings.cloudUrl || process.env.CLOUD_URL || process.env.NEXT_PUBLIC_CLOUD_URL || ""; } -// ============ Pricing ============ - -/** - * Get pricing configuration - * Returns merged: PROVIDER_PRICING defaults + user overrides - */ export async function getPricing() { const db = await getDb(); const userPricing = db.data.pricing || {}; - const { PROVIDER_PRICING } = await import("@/shared/constants/pricing.js"); - // Deep merge PROVIDER_PRICING + user overrides const merged = {}; for (const [provider, models] of Object.entries(PROVIDER_PRICING)) { @@ -1108,7 +774,6 @@ export async function getPricing() { } } - // User-only providers not in PROVIDER_PRICING for (const [provider, models] of Object.entries(userPricing)) { if (!merged[provider]) { merged[provider] = { ...models }; @@ -1122,49 +787,26 @@ export async function getPricing() { return merged; } -/** - * Get pricing for a specific provider and model. - * Delegates to getPricingForModel in pricing.js which handles the full fallback chain: - * 1. PROVIDER_PRICING[provider][model] — provider-specific override - * 2. MODEL_PRICING[model] — canonical model price - * 3. PATTERN_PRICING — glob pattern match - * - * Also checks user DB overrides first. - */ export async function getPricingForModel(provider, model) { if (!model) return null; const db = await getDb(); const userPricing = db.data.pricing || {}; - // User override takes top priority if (provider && userPricing[provider]?.[model]) { return userPricing[provider][model]; } - // Delegate to constants fallback chain const { getPricingForModel: resolve } = await import("@/shared/constants/pricing.js"); return resolve(provider, model); } -/** - * Update pricing configuration - * @param {object} pricingData - New pricing data to merge - */ export async function updatePricing(pricingData) { const db = await getDb(); + if (!db.data.pricing) db.data.pricing = {}; - // Ensure pricing object exists - if (!db.data.pricing) { - db.data.pricing = {}; - } - - // Merge new pricing data for (const [provider, models] of Object.entries(pricingData)) { - if (!db.data.pricing[provider]) { - db.data.pricing[provider] = {}; - } - + if (!db.data.pricing[provider]) db.data.pricing[provider] = {}; for (const [model, pricing] of Object.entries(models)) { db.data.pricing[provider][model] = pricing; } @@ -1174,29 +816,18 @@ export async function updatePricing(pricingData) { return db.data.pricing; } -/** - * Reset pricing to defaults for specific provider/model - * @param {string} provider - Provider ID - * @param {string} model - Model ID (optional, if not provided resets entire provider) - */ export async function resetPricing(provider, model) { const db = await getDb(); - - if (!db.data.pricing) { - db.data.pricing = {}; - } + if (!db.data.pricing) db.data.pricing = {}; if (model) { - // Reset specific model if (db.data.pricing[provider]) { delete db.data.pricing[provider][model]; - // Clean up empty provider objects if (Object.keys(db.data.pricing[provider]).length === 0) { delete db.data.pricing[provider]; } } } else { - // Reset entire provider delete db.data.pricing[provider]; } @@ -1204,9 +835,6 @@ export async function resetPricing(provider, model) { return db.data.pricing; } -/** - * Reset all pricing to defaults - */ export async function resetAllPricing() { const db = await getDb(); db.data.pricing = {}; diff --git a/src/lib/usageDb.js b/src/lib/usageDb.js index f6d24bc..0b763d7 100644 --- a/src/lib/usageDb.js +++ b/src/lib/usageDb.js @@ -64,12 +64,74 @@ if (!isCloud && fs && typeof fs.existsSync === "function") { } } -// Default data structure const defaultData = { history: [], - totalRequestsLifetime: 0 + totalRequestsLifetime: 0, + dailySummary: {}, }; +function getLocalDateKey(timestamp) { + const d = timestamp ? new Date(timestamp) : new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} + +function addToCounter(target, key, values) { + if (!target[key]) target[key] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0 }; + target[key].requests += values.requests || 1; + target[key].promptTokens += values.promptTokens || 0; + target[key].completionTokens += values.completionTokens || 0; + target[key].cost += values.cost || 0; + if (values.meta) Object.assign(target[key], values.meta); +} + +function aggregateEntryToDailySummary(dailySummary, entry) { + const dateKey = getLocalDateKey(entry.timestamp); + if (!dailySummary[dateKey]) { + dailySummary[dateKey] = { + requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, + byProvider: {}, byModel: {}, byAccount: {}, byApiKey: {}, byEndpoint: {}, + }; + } + const day = dailySummary[dateKey]; + const promptTokens = entry.tokens?.prompt_tokens || entry.tokens?.input_tokens || 0; + const completionTokens = entry.tokens?.completion_tokens || entry.tokens?.output_tokens || 0; + const cost = entry.cost || 0; + const vals = { promptTokens, completionTokens, cost }; + + day.requests += 1; + day.promptTokens += promptTokens; + day.completionTokens += completionTokens; + day.cost += cost; + + if (entry.provider) addToCounter(day.byProvider, entry.provider, vals); + + const modelKey = entry.provider ? `${entry.model}|${entry.provider}` : entry.model; + addToCounter(day.byModel, modelKey, { ...vals, meta: { rawModel: entry.model, provider: entry.provider } }); + + if (entry.connectionId) { + addToCounter(day.byAccount, entry.connectionId, { ...vals, meta: { rawModel: entry.model, provider: entry.provider } }); + } + + const apiKeyVal = entry.apiKey && typeof entry.apiKey === "string" ? entry.apiKey : "local-no-key"; + const akModelKey = `${apiKeyVal}|${entry.model}|${entry.provider || "unknown"}`; + addToCounter(day.byApiKey, akModelKey, { ...vals, meta: { rawModel: entry.model, provider: entry.provider, apiKey: entry.apiKey || null } }); + + const endpoint = entry.endpoint || "Unknown"; + const epKey = `${endpoint}|${entry.model}|${entry.provider || "unknown"}`; + addToCounter(day.byEndpoint, epKey, { ...vals, meta: { endpoint, rawModel: entry.model, provider: entry.provider } }); +} + +function migrateHistoryToDailySummary(db) { + const history = db.data.history || []; + if (!history.length) return false; + db.data.dailySummary = {}; + for (const entry of history) { + aggregateEntryToDailySummary(db.data.dailySummary, entry); + } + console.log(`[usageDb] Migrated ${history.length} history entries to dailySummary (${Object.keys(db.data.dailySummary).length} days)`); + return true; +} + // Singleton instance let dbInstance = null; @@ -238,11 +300,19 @@ export async function getUsageDb() { } } - // Initialize with default data if empty if (!dbInstance.data) { - dbInstance.data = defaultData; + dbInstance.data = { ...defaultData }; await dbInstance.write(); } + + // Migration: build dailySummary from existing history (one-time) + if (!dbInstance.data.dailySummary) { + if (migrateHistoryToDailySummary(dbInstance)) { + await dbInstance.write(); + } else { + dbInstance.data.dailySummary = {}; + } + } } return dbInstance; } @@ -275,7 +345,9 @@ export async function saveRequestUsage(entry) { db.data.history.push(entry); db.data.totalRequestsLifetime += 1; - // Cap history to prevent unbounded memory/disk growth + if (!db.data.dailySummary) db.data.dailySummary = {}; + aggregateEntryToDailySummary(db.data.dailySummary, entry); + const MAX_HISTORY = 10000; if (db.data.history.length > MAX_HISTORY) { db.data.history.splice(0, db.data.history.length - MAX_HISTORY); @@ -470,33 +542,18 @@ const PERIOD_MS = { "24h": 86400000, "7d": 604800000, "30d": 2592000000, "60d": */ export async function getUsageStats(period = "all") { const db = await getUsageDb(); - let history = db.data.history || []; + const history = db.data.history || []; + const dailySummary = db.data.dailySummary || {}; - // Filter history by period - if (period && PERIOD_MS[period]) { - const cutoff = Date.now() - PERIOD_MS[period]; - history = history.filter((e) => new Date(e.timestamp).getTime() >= cutoff); - } - - // Import localDb to get provider connection names and API keys const { getProviderConnections, getApiKeys, getProviderNodes } = await import("@/lib/localDb.js"); - // Fetch all provider connections to get account names let allConnections = []; - try { - allConnections = await getProviderConnections(); - } catch (error) { - // If localDb is not available (e.g., in some environments), continue without account names - console.warn("Could not fetch provider connections for usage stats:", error.message); - } - - // Create a map from connectionId to account name + try { allConnections = await getProviderConnections(); } catch {} const connectionMap = {}; for (const conn of allConnections) { connectionMap[conn.id] = conn.name || conn.email || conn.id; } - // Build map from compatible provider ID → friendly name (from providerNodes) const providerNodeNameMap = {}; try { const nodes = await getProviderNodes(); @@ -505,44 +562,28 @@ export async function getUsageStats(period = "all") { } } catch {} - // Fetch all API keys to get key names let allApiKeys = []; - try { - allApiKeys = await getApiKeys(); - } catch (error) { - console.warn("Could not fetch API keys for usage stats:", error.message); - } - - // Create a map from API key to key info + try { allApiKeys = await getApiKeys(); } catch {} const apiKeyMap = {}; for (const key of allApiKeys) { - apiKeyMap[key.key] = { - name: key.name, - id: key.id, - createdAt: key.createdAt - }; + apiKeyMap[key.key] = { name: key.name, id: key.id, createdAt: key.createdAt }; } - // 20 most recent requests from history (always in sync with SSE emit) + // Recent requests (always from live history) const seen = new Set(); const recentRequests = [...history] .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)) .map((e) => { const t = e.tokens || {}; - const promptTokens = t.prompt_tokens || t.input_tokens || 0; - const completionTokens = t.completion_tokens || t.output_tokens || 0; return { - timestamp: e.timestamp, - model: e.model, - provider: e.provider || "", - promptTokens, - completionTokens, + timestamp: e.timestamp, model: e.model, provider: e.provider || "", + promptTokens: t.prompt_tokens || t.input_tokens || 0, + completionTokens: t.completion_tokens || t.output_tokens || 0, status: e.status || "ok", }; }) .filter((e) => { if (e.promptTokens === 0 && e.completionTokens === 0) return false; - // Deduplicate: same model+provider+tokens within same minute const minute = e.timestamp ? e.timestamp.slice(0, 16) : ""; const key = `${e.model}|${e.provider}|${e.promptTokens}|${e.completionTokens}|${minute}`; if (seen.has(key)) return false; @@ -557,14 +598,8 @@ export async function getUsageStats(period = "all") { const stats = { totalRequests: lifetimeTotalRequests, - totalPromptTokens: 0, - totalCompletionTokens: 0, - totalCost: 0, - byProvider: {}, - byModel: {}, - byAccount: {}, - byApiKey: {}, - byEndpoint: {}, + totalPromptTokens: 0, totalCompletionTokens: 0, totalCost: 0, + byProvider: {}, byModel: {}, byAccount: {}, byApiKey: {}, byEndpoint: {}, last10Minutes: [], pending: pendingRequests, activeRequests: [], @@ -572,218 +607,221 @@ export async function getUsageStats(period = "all") { errorProvider: (Date.now() - lastErrorProvider.ts < 10000) ? lastErrorProvider.provider : "", }; - // Build active requests list from pending counts + // Active requests from pending for (const [connectionId, models] of Object.entries(pendingRequests.byAccount)) { for (const [modelKey, count] of Object.entries(models)) { if (count > 0) { const accountName = connectionMap[connectionId] || `Account ${connectionId.slice(0, 8)}...`; - // modelKey is "model (provider)" const match = modelKey.match(/^(.*) \((.*)\)$/); - const modelName = match ? match[1] : modelKey; - const providerName = match ? match[2] : "unknown"; - stats.activeRequests.push({ - model: modelName, - provider: providerName, - account: accountName, - count + model: match ? match[1] : modelKey, + provider: match ? match[2] : "unknown", + account: accountName, count, }); } } } - // Initialize 10-minute buckets using stable minute boundaries + // last10Minutes — always from live history const now = new Date(); - // Floor to the start of the current minute const currentMinuteStart = new Date(Math.floor(now.getTime() / 60000) * 60000); const tenMinutesAgo = new Date(currentMinuteStart.getTime() - 9 * 60 * 1000); - - // Create buckets keyed by minute timestamp for stable lookups const bucketMap = {}; for (let i = 0; i < 10; i++) { - const bucketTime = new Date(currentMinuteStart.getTime() - (9 - i) * 60 * 1000); - const bucketKey = bucketTime.getTime(); - bucketMap[bucketKey] = { - requests: 0, - promptTokens: 0, - completionTokens: 0, - cost: 0 - }; + const bucketKey = currentMinuteStart.getTime() - (9 - i) * 60 * 1000; + bucketMap[bucketKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0 }; stats.last10Minutes.push(bucketMap[bucketKey]); } - for (const entry of history) { - const promptTokens = entry.tokens?.prompt_tokens || 0; - const completionTokens = entry.tokens?.completion_tokens || 0; const entryTime = new Date(entry.timestamp); - - // Use pre-stored cost (saved at request time), avoid recalculating - const entryCost = entry.cost || 0; - - stats.totalPromptTokens += promptTokens; - stats.totalCompletionTokens += completionTokens; - stats.totalCost += entryCost; - - // Last 10 minutes aggregation - floor entry time to its minute if (entryTime >= tenMinutesAgo && entryTime <= now) { const entryMinuteStart = Math.floor(entryTime.getTime() / 60000) * 60000; if (bucketMap[entryMinuteStart]) { + const pt = entry.tokens?.prompt_tokens || 0; + const ct = entry.tokens?.completion_tokens || 0; bucketMap[entryMinuteStart].requests++; - bucketMap[entryMinuteStart].promptTokens += promptTokens; - bucketMap[entryMinuteStart].completionTokens += completionTokens; - bucketMap[entryMinuteStart].cost += entryCost; + bucketMap[entryMinuteStart].promptTokens += pt; + bucketMap[entryMinuteStart].completionTokens += ct; + bucketMap[entryMinuteStart].cost += entry.cost || 0; } } + } - // By Provider - if (!stats.byProvider[entry.provider]) { - stats.byProvider[entry.provider] = { - requests: 0, - promptTokens: 0, - completionTokens: 0, - cost: 0 - }; - } - stats.byProvider[entry.provider].requests++; - stats.byProvider[entry.provider].promptTokens += promptTokens; - stats.byProvider[entry.provider].completionTokens += completionTokens; - stats.byProvider[entry.provider].cost += entryCost; + // Determine if we use dailySummary (7d/30d/60d/all) or live history (24h) + const useDailySummary = period !== "24h"; - // By Model - // Format: "modelName (provider)" if provider is known - const modelKey = entry.provider ? `${entry.model} (${entry.provider})` : entry.model; - // Resolve friendly name for compatible providers - const providerDisplayName = providerNodeNameMap[entry.provider] || entry.provider; + if (useDailySummary) { + // Collect relevant date keys + const periodDays = { "7d": 7, "30d": 30, "60d": 60 }; + const maxDays = periodDays[period] || null; // null = all + const today = new Date(); + const dateKeys = Object.keys(dailySummary).filter((dateKey) => { + if (!maxDays) return true; + const parts = dateKey.split("-"); + const d = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])); + const diffDays = Math.floor((today.getTime() - d.getTime()) / 86400000); + return diffDays < maxDays; + }); - if (!stats.byModel[modelKey]) { - stats.byModel[modelKey] = { - requests: 0, - promptTokens: 0, - completionTokens: 0, - cost: 0, - rawModel: entry.model, - provider: providerDisplayName, - lastUsed: entry.timestamp - }; - } - stats.byModel[modelKey].requests++; - stats.byModel[modelKey].promptTokens += promptTokens; - stats.byModel[modelKey].completionTokens += completionTokens; - stats.byModel[modelKey].cost += entryCost; - if (new Date(entry.timestamp) > new Date(stats.byModel[modelKey].lastUsed)) { - stats.byModel[modelKey].lastUsed = entry.timestamp; - } + for (const dateKey of dateKeys) { + const day = dailySummary[dateKey]; + stats.totalPromptTokens += day.promptTokens || 0; + stats.totalCompletionTokens += day.completionTokens || 0; + stats.totalCost += day.cost || 0; - // By Account (model + oauth account) - // Use connectionId if available, otherwise fallback to provider name - if (entry.connectionId) { - const accountName = connectionMap[entry.connectionId] || `Account ${entry.connectionId.slice(0, 8)}...`; - const accountKey = `${entry.model} (${entry.provider} - ${accountName})`; - - if (!stats.byAccount[accountKey]) { - stats.byAccount[accountKey] = { - requests: 0, - promptTokens: 0, - completionTokens: 0, - cost: 0, - rawModel: entry.model, - provider: providerDisplayName, - connectionId: entry.connectionId, - accountName: accountName, - lastUsed: entry.timestamp - }; + // Merge byProvider + for (const [prov, pData] of Object.entries(day.byProvider || {})) { + if (!stats.byProvider[prov]) stats.byProvider[prov] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0 }; + stats.byProvider[prov].requests += pData.requests || 0; + stats.byProvider[prov].promptTokens += pData.promptTokens || 0; + stats.byProvider[prov].completionTokens += pData.completionTokens || 0; + stats.byProvider[prov].cost += pData.cost || 0; } - stats.byAccount[accountKey].requests++; - stats.byAccount[accountKey].promptTokens += promptTokens; - stats.byAccount[accountKey].completionTokens += completionTokens; - stats.byAccount[accountKey].cost += entryCost; - if (new Date(entry.timestamp) > new Date(stats.byAccount[accountKey].lastUsed)) { - stats.byAccount[accountKey].lastUsed = entry.timestamp; + + // Merge byModel (dailySummary key: "model|provider" → stats key: "model (provider)") + for (const [mk, mData] of Object.entries(day.byModel || {})) { + const rawModel = mData.rawModel || mk.split("|")[0]; + const provider = mData.provider || mk.split("|")[1] || ""; + const statsKey = provider ? `${rawModel} (${provider})` : rawModel; + const providerDisplayName = providerNodeNameMap[provider] || provider; + if (!stats.byModel[statsKey]) { + stats.byModel[statsKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel, provider: providerDisplayName, lastUsed: dateKey }; + } + stats.byModel[statsKey].requests += mData.requests || 0; + stats.byModel[statsKey].promptTokens += mData.promptTokens || 0; + stats.byModel[statsKey].completionTokens += mData.completionTokens || 0; + stats.byModel[statsKey].cost += mData.cost || 0; + if (dateKey > (stats.byModel[statsKey].lastUsed || "")) stats.byModel[statsKey].lastUsed = dateKey; + } + + // Merge byAccount + for (const [connId, aData] of Object.entries(day.byAccount || {})) { + const accountName = connectionMap[connId] || `Account ${connId.slice(0, 8)}...`; + const rawModel = aData.rawModel || ""; + const provider = aData.provider || ""; + const providerDisplayName = providerNodeNameMap[provider] || provider; + const accountKey = `${rawModel} (${provider} - ${accountName})`; + if (!stats.byAccount[accountKey]) { + stats.byAccount[accountKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel, provider: providerDisplayName, connectionId: connId, accountName, lastUsed: dateKey }; + } + stats.byAccount[accountKey].requests += aData.requests || 0; + stats.byAccount[accountKey].promptTokens += aData.promptTokens || 0; + stats.byAccount[accountKey].completionTokens += aData.completionTokens || 0; + stats.byAccount[accountKey].cost += aData.cost || 0; + if (dateKey > (stats.byAccount[accountKey].lastUsed || "")) stats.byAccount[accountKey].lastUsed = dateKey; + } + + // Merge byApiKey + for (const [akKey, akData] of Object.entries(day.byApiKey || {})) { + const rawModel = akData.rawModel || ""; + const provider = akData.provider || ""; + const providerDisplayName = providerNodeNameMap[provider] || provider; + const apiKeyVal = akData.apiKey; + const keyInfo = apiKeyVal ? apiKeyMap[apiKeyVal] : null; + const keyName = keyInfo?.name || (apiKeyVal ? apiKeyVal.slice(0, 8) + "..." : "Local (No API Key)"); + const apiKeyKey = apiKeyVal || "local-no-key"; + if (!stats.byApiKey[akKey]) { + stats.byApiKey[akKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel, provider: providerDisplayName, apiKey: apiKeyVal, keyName, apiKeyKey, lastUsed: dateKey }; + } + stats.byApiKey[akKey].requests += akData.requests || 0; + stats.byApiKey[akKey].promptTokens += akData.promptTokens || 0; + stats.byApiKey[akKey].completionTokens += akData.completionTokens || 0; + stats.byApiKey[akKey].cost += akData.cost || 0; + if (dateKey > (stats.byApiKey[akKey].lastUsed || "")) stats.byApiKey[akKey].lastUsed = dateKey; + } + + // Merge byEndpoint + for (const [epKey, epData] of Object.entries(day.byEndpoint || {})) { + const endpoint = epData.endpoint || epKey.split("|")[0] || "Unknown"; + const rawModel = epData.rawModel || ""; + const provider = epData.provider || ""; + const providerDisplayName = providerNodeNameMap[provider] || provider; + if (!stats.byEndpoint[epKey]) { + stats.byEndpoint[epKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, endpoint, rawModel, provider: providerDisplayName, lastUsed: dateKey }; + } + stats.byEndpoint[epKey].requests += epData.requests || 0; + stats.byEndpoint[epKey].promptTokens += epData.promptTokens || 0; + stats.byEndpoint[epKey].completionTokens += epData.completionTokens || 0; + stats.byEndpoint[epKey].cost += epData.cost || 0; + if (dateKey > (stats.byEndpoint[epKey].lastUsed || "")) stats.byEndpoint[epKey].lastUsed = dateKey; } } + } else { + // 24h: use live history (original logic) + const cutoff = Date.now() - PERIOD_MS["24h"]; + const filtered = history.filter((e) => new Date(e.timestamp).getTime() >= cutoff); - // Handle requests with API key - if (entry.apiKey && typeof entry.apiKey === "string") { - const keyInfo = apiKeyMap[entry.apiKey]; - const keyName = keyInfo?.name || entry.apiKey.slice(0, 8) + "..."; - // Use full API key to avoid collisions (keys with same prefix) - const apiKeyKey = entry.apiKey; - // Group by API Key + Model + Provider combination to track different models used with the same key - const apiKeyModelKey = `${apiKeyKey}|${entry.model}|${entry.provider || 'unknown'}`; + for (const entry of filtered) { + const promptTokens = entry.tokens?.prompt_tokens || 0; + const completionTokens = entry.tokens?.completion_tokens || 0; + const entryCost = entry.cost || 0; + const providerDisplayName = providerNodeNameMap[entry.provider] || entry.provider; - if (!stats.byApiKey[apiKeyModelKey]) { - stats.byApiKey[apiKeyModelKey] = { - requests: 0, - promptTokens: 0, - completionTokens: 0, - cost: 0, - rawModel: entry.model, - provider: providerDisplayName, - apiKey: entry.apiKey, - keyName: keyName, - apiKeyKey: apiKeyKey, - lastUsed: entry.timestamp - }; + stats.totalPromptTokens += promptTokens; + stats.totalCompletionTokens += completionTokens; + stats.totalCost += entryCost; + + // byProvider + if (!stats.byProvider[entry.provider]) stats.byProvider[entry.provider] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0 }; + stats.byProvider[entry.provider].requests++; + stats.byProvider[entry.provider].promptTokens += promptTokens; + stats.byProvider[entry.provider].completionTokens += completionTokens; + stats.byProvider[entry.provider].cost += entryCost; + + // byModel + const modelKey = entry.provider ? `${entry.model} (${entry.provider})` : entry.model; + if (!stats.byModel[modelKey]) { + stats.byModel[modelKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel: entry.model, provider: providerDisplayName, lastUsed: entry.timestamp }; } - const apiKeyEntry = stats.byApiKey[apiKeyModelKey]; - apiKeyEntry.requests++; - apiKeyEntry.promptTokens += promptTokens; - apiKeyEntry.completionTokens += completionTokens; - apiKeyEntry.cost += entryCost; - if (new Date(entry.timestamp) > new Date(apiKeyEntry.lastUsed)) { - apiKeyEntry.lastUsed = entry.timestamp; - } - } else { - const apiKeyKey = "local-no-key"; - const keyName = "Local (No API Key)"; + stats.byModel[modelKey].requests++; + stats.byModel[modelKey].promptTokens += promptTokens; + stats.byModel[modelKey].completionTokens += completionTokens; + stats.byModel[modelKey].cost += entryCost; + if (new Date(entry.timestamp) > new Date(stats.byModel[modelKey].lastUsed)) stats.byModel[modelKey].lastUsed = entry.timestamp; - if (!stats.byApiKey[apiKeyKey]) { - stats.byApiKey[apiKeyKey] = { - requests: 0, - promptTokens: 0, - completionTokens: 0, - cost: 0, - rawModel: entry.model, - provider: providerDisplayName, - apiKey: null, - keyName: keyName, - apiKeyKey: apiKeyKey, - lastUsed: entry.timestamp - }; + // byAccount + if (entry.connectionId) { + const accountName = connectionMap[entry.connectionId] || `Account ${entry.connectionId.slice(0, 8)}...`; + const accountKey = `${entry.model} (${entry.provider} - ${accountName})`; + if (!stats.byAccount[accountKey]) { + stats.byAccount[accountKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel: entry.model, provider: providerDisplayName, connectionId: entry.connectionId, accountName, lastUsed: entry.timestamp }; + } + stats.byAccount[accountKey].requests++; + stats.byAccount[accountKey].promptTokens += promptTokens; + stats.byAccount[accountKey].completionTokens += completionTokens; + stats.byAccount[accountKey].cost += entryCost; + if (new Date(entry.timestamp) > new Date(stats.byAccount[accountKey].lastUsed)) stats.byAccount[accountKey].lastUsed = entry.timestamp; } - const apiKeyEntry = stats.byApiKey[apiKeyKey]; - apiKeyEntry.requests++; - apiKeyEntry.promptTokens += promptTokens; - apiKeyEntry.completionTokens += completionTokens; - apiKeyEntry.cost += entryCost; - if (new Date(entry.timestamp) > new Date(apiKeyEntry.lastUsed)) { - apiKeyEntry.lastUsed = entry.timestamp; + + // byApiKey + if (entry.apiKey && typeof entry.apiKey === "string") { + const keyInfo = apiKeyMap[entry.apiKey]; + const keyName = keyInfo?.name || entry.apiKey.slice(0, 8) + "..."; + const apiKeyModelKey = `${entry.apiKey}|${entry.model}|${entry.provider || "unknown"}`; + if (!stats.byApiKey[apiKeyModelKey]) { + stats.byApiKey[apiKeyModelKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel: entry.model, provider: providerDisplayName, apiKey: entry.apiKey, keyName, apiKeyKey: entry.apiKey, lastUsed: entry.timestamp }; + } + const ake = stats.byApiKey[apiKeyModelKey]; + ake.requests++; ake.promptTokens += promptTokens; ake.completionTokens += completionTokens; ake.cost += entryCost; + if (new Date(entry.timestamp) > new Date(ake.lastUsed)) ake.lastUsed = entry.timestamp; + } else { + if (!stats.byApiKey["local-no-key"]) { + stats.byApiKey["local-no-key"] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel: entry.model, provider: providerDisplayName, apiKey: null, keyName: "Local (No API Key)", apiKeyKey: "local-no-key", lastUsed: entry.timestamp }; + } + const ake = stats.byApiKey["local-no-key"]; + ake.requests++; ake.promptTokens += promptTokens; ake.completionTokens += completionTokens; ake.cost += entryCost; + if (new Date(entry.timestamp) > new Date(ake.lastUsed)) ake.lastUsed = entry.timestamp; } - } - // By Endpoint (endpoint + model + provider combination) - const endpoint = entry.endpoint || "Unknown"; - const endpointModelKey = `${endpoint}|${entry.model}|${entry.provider || 'unknown'}`; - - if (!stats.byEndpoint[endpointModelKey]) { - stats.byEndpoint[endpointModelKey] = { - requests: 0, - promptTokens: 0, - completionTokens: 0, - cost: 0, - endpoint: endpoint, - rawModel: entry.model, - provider: providerDisplayName, - lastUsed: entry.timestamp - }; - } - const endpointEntry = stats.byEndpoint[endpointModelKey]; - endpointEntry.requests++; - endpointEntry.promptTokens += promptTokens; - endpointEntry.completionTokens += completionTokens; - endpointEntry.cost += entryCost; - if (new Date(entry.timestamp) > new Date(endpointEntry.lastUsed)) { - endpointEntry.lastUsed = entry.timestamp; + // byEndpoint + const endpoint = entry.endpoint || "Unknown"; + const endpointModelKey = `${endpoint}|${entry.model}|${entry.provider || "unknown"}`; + if (!stats.byEndpoint[endpointModelKey]) { + stats.byEndpoint[endpointModelKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, endpoint, rawModel: entry.model, provider: providerDisplayName, lastUsed: entry.timestamp }; + } + const epe = stats.byEndpoint[endpointModelKey]; + epe.requests++; epe.promptTokens += promptTokens; epe.completionTokens += completionTokens; epe.cost += entryCost; + if (new Date(entry.timestamp) > new Date(epe.lastUsed)) epe.lastUsed = entry.timestamp; } } @@ -798,45 +836,48 @@ export async function getUsageStats(period = "all") { export async function getChartData(period = "7d") { const db = await getUsageDb(); const history = db.data.history || []; + const dailySummary = db.data.dailySummary || {}; const now = Date.now(); - let bucketCount, bucketMs, labelFn; + // 24h: bucket by hour from live history if (period === "24h") { - bucketCount = 24; - bucketMs = 3600000; // 1 hour - labelFn = (ts) => new Date(ts).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }); - } else if (period === "7d") { - bucketCount = 7; - bucketMs = 86400000; - labelFn = (ts) => new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" }); - } else if (period === "30d") { - bucketCount = 30; - bucketMs = 86400000; - labelFn = (ts) => new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" }); - } else { - bucketCount = 60; - bucketMs = 86400000; - labelFn = (ts) => new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" }); + const bucketCount = 24; + const bucketMs = 3600000; + const labelFn = (ts) => new Date(ts).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }); + const startTime = now - bucketCount * bucketMs; + const buckets = Array.from({ length: bucketCount }, (_, i) => { + const ts = startTime + i * bucketMs; + return { label: labelFn(ts), tokens: 0, cost: 0 }; + }); + + for (const entry of history) { + const entryTime = new Date(entry.timestamp).getTime(); + if (entryTime < startTime || entryTime > now) continue; + const idx = Math.min(Math.floor((entryTime - startTime) / bucketMs), bucketCount - 1); + buckets[idx].tokens += (entry.tokens?.prompt_tokens || 0) + (entry.tokens?.completion_tokens || 0); + buckets[idx].cost += entry.cost || 0; + } + return buckets; } - const startTime = now - bucketCount * bucketMs; + // 7d/30d/60d: bucket by day from dailySummary (local dates) + const bucketCount = period === "7d" ? 7 : period === "30d" ? 30 : 60; + const today = new Date(); + const labelFn = (d) => d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + const buckets = Array.from({ length: bucketCount }, (_, i) => { - const ts = startTime + i * bucketMs; - return { label: labelFn(ts), tokens: 0, cost: 0, _ts: ts }; + const d = new Date(today); + d.setDate(d.getDate() - (bucketCount - 1 - i)); + const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + const dayData = dailySummary[dateKey]; + return { + label: labelFn(d), + tokens: dayData ? (dayData.promptTokens || 0) + (dayData.completionTokens || 0) : 0, + cost: dayData ? (dayData.cost || 0) : 0, + }; }); - for (const entry of history) { - const entryTime = new Date(entry.timestamp).getTime(); - if (entryTime < startTime || entryTime > now) continue; - const idx = Math.min(Math.floor((entryTime - startTime) / bucketMs), bucketCount - 1); - const promptTokens = entry.tokens?.prompt_tokens || 0; - const completionTokens = entry.tokens?.completion_tokens || 0; - buckets[idx].tokens += promptTokens + completionTokens; - // Use pre-stored cost if available, else 0 - buckets[idx].cost += entry.cost || 0; - } - - return buckets.map(({ label, tokens, cost }) => ({ label, tokens, cost })); + return buckets; } // Re-export request details functions from new SQLite-based module diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js index 0dceaa9..4258a50 100644 --- a/src/shared/components/Sidebar.js +++ b/src/shared/components/Sidebar.js @@ -10,6 +10,7 @@ import { MEDIA_PROVIDER_KINDS } from "@/shared/constants/providers"; import Button from "./Button"; import { ConfirmModal } from "./Modal"; +// const VISIBLE_MEDIA_KINDS = ["embedding", "image", "imageToText", "tts", "stt", "webSearch", "webFetch", "video", "music"]; const VISIBLE_MEDIA_KINDS = ["embedding", "tts"]; const navItems = [ diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js index 439a805..d344ca7 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -9,16 +9,16 @@ export const FREE_PROVIDERS = { // codebuddy: { id: "codebuddy", alias: "cb", name: "CodeBuddy", icon: "smart_toy", color: "#006EFF" }, // qoder: { id: "qoder", alias: "qd", name: "Qoder AI", icon: "water_drop", color: "#EC4899" }, iflow: { id: "iflow", alias: "if", name: "iFlow AI", icon: "water_drop", color: "#6366F1" }, - opencode: { id: "opencode", alias: "oc", name: "OpenCode", icon: "terminal", color: "#E87040", textIcon: "OC", noAuth: true }, + opencode: { id: "opencode", alias: "oc", name: "OpenCode", icon: "terminal", color: "#E87040", textIcon: "OC", noAuth: true, passthroughModels: true, modelsFetcher: { url: "https://opencode.ai/zen/v1/models", type: "opencode-free" } }, }; // Free Tier Providers (has free access but may require account/API key) export const FREE_TIER_PROVIDERS = { - openrouter: { id: "openrouter", alias: "openrouter", name: "OpenRouter", icon: "router", color: "#F97316", textIcon: "OR", website: "https://openrouter.ai", notice: { text: "Free tier: 27+ free models, no credit card needed, 200 req/day. After $10 credit: 1,000 req/day.", apiKeyUrl: "https://openrouter.ai/settings/keys" }, modelsFetcher: { url: "https://openrouter.ai/api/v1/models", type: "openrouter-free" }, passthroughModels: true, serviceKinds: ["llm", "embedding"] }, + openrouter: { id: "openrouter", alias: "openrouter", name: "OpenRouter", icon: "router", color: "#F97316", textIcon: "OR", website: "https://openrouter.ai", notice: { text: "Free tier: 27+ free models, no credit card needed, 200 req/day. After $10 credit: 1,000 req/day.", apiKeyUrl: "https://openrouter.ai/settings/keys" }, modelsFetcher: { url: "https://openrouter.ai/api/v1/models", type: "openrouter-free" }, passthroughModels: true, serviceKinds: ["llm", "embedding", "tts", "imageToText"] }, nvidia: { id: "nvidia", alias: "nvidia", name: "NVIDIA NIM", icon: "developer_board", color: "#76B900", textIcon: "NV", website: "https://developer.nvidia.com/nim", notice: { text: "Free access for NVIDIA Developer Program members (prototyping & testing).", apiKeyUrl: "https://build.nvidia.com/settings/api-keys" } }, ollama: { id: "ollama", alias: "ollama", name: "Ollama Cloud", icon: "cloud", color: "#ffffffff", textIcon: "OL", website: "https://ollama.com", notice: { text: "Free tier: light usage, 1 cloud model at a time (limits reset every 5h & 7d). Pro $20/mo · Max $100/mo.", apiKeyUrl: "https://ollama.com/settings/keys" } }, vertex: { id: "vertex", alias: "vx", name: "Vertex AI", icon: "cloud", color: "#4285F4", textIcon: "VX", website: "https://cloud.google.com/vertex-ai", notice: { text: "New Google Cloud accounts get $300 free credits. Requires GCP project + Service Account with Vertex AI API enabled.", apiKeyUrl: "https://console.cloud.google.com/iam-admin/serviceaccounts" } }, - gemini: { id: "gemini", alias: "gemini", name: "Gemini", icon: "diamond", color: "#4285F4", textIcon: "GE", website: "https://ai.google.dev", serviceKinds: ["llm", "embedding"] }, + gemini: { id: "gemini", alias: "gemini", name: "Gemini", icon: "diamond", color: "#4285F4", textIcon: "GE", website: "https://ai.google.dev", serviceKinds: ["llm", "embedding", "image", "imageToText", "webSearch"] }, }; // Thinking config definitions @@ -54,20 +54,20 @@ export const OAUTH_PROVIDERS = { export const APIKEY_PROVIDERS = { glm: { id: "glm", alias: "glm", name: "GLM Coding", icon: "code", color: "#2563EB", textIcon: "GL", website: "https://open.bigmodel.cn" }, "glm-cn": { id: "glm-cn", alias: "glm-cn", name: "GLM (China)", icon: "code", color: "#DC2626", textIcon: "GC", website: "https://open.bigmodel.cn" }, - kimi: { id: "kimi", alias: "kimi", name: "Kimi", icon: "psychology", color: "#1E3A8A", textIcon: "KM", website: "https://kimi.moonshot.cn" }, - minimax: { id: "minimax", alias: "minimax", name: "Minimax Coding", icon: "memory", color: "#7C3AED", textIcon: "MM", website: "https://www.minimaxi.com" }, + kimi: { id: "kimi", alias: "kimi", name: "Kimi", icon: "psychology", color: "#1E3A8A", textIcon: "KM", website: "https://kimi.moonshot.cn", serviceKinds: ["llm", "webSearch"] }, + minimax: { id: "minimax", alias: "minimax", name: "Minimax Coding", icon: "memory", color: "#7C3AED", textIcon: "MM", website: "https://www.minimaxi.com", serviceKinds: ["llm", "image", "imageToText", "webSearch"] }, "minimax-cn": { id: "minimax-cn", alias: "minimax-cn", name: "Minimax (China)", icon: "memory", color: "#DC2626", textIcon: "MC", website: "https://www.minimaxi.com" }, alicode: { id: "alicode", alias: "alicode", name: "Alibaba", icon: "cloud", color: "#FF6A00", textIcon: "ALi" }, "alicode-intl": { id: "alicode-intl", alias: "alicode-intl", name: "Alibaba Intl", icon: "cloud", color: "#FF6A00", textIcon: "ALi" }, - openai: { id: "openai", alias: "openai", name: "OpenAI", icon: "auto_awesome", color: "#10A37F", textIcon: "OA", website: "https://platform.openai.com", serviceKinds: ["llm", "embedding", "tts"], thinkingConfig: THINKING_CONFIG.effort }, - anthropic: { id: "anthropic", alias: "anthropic", name: "Anthropic", icon: "smart_toy", color: "#D97757", textIcon: "AN", website: "https://console.anthropic.com", serviceKinds: ["llm"] }, + openai: { id: "openai", alias: "openai", name: "OpenAI", icon: "auto_awesome", color: "#10A37F", textIcon: "OA", website: "https://platform.openai.com", serviceKinds: ["llm", "embedding", "tts", "image", "imageToText", "webSearch"], thinkingConfig: THINKING_CONFIG.effort }, + anthropic: { id: "anthropic", alias: "anthropic", name: "Anthropic", icon: "smart_toy", color: "#D97757", textIcon: "AN", website: "https://console.anthropic.com", serviceKinds: ["llm", "imageToText"] }, deepseek: { id: "deepseek", alias: "ds", name: "DeepSeek", icon: "bolt", color: "#4D6BFE", textIcon: "DS", website: "https://deepseek.com" }, - groq: { id: "groq", alias: "groq", name: "Groq", icon: "speed", color: "#F55036", textIcon: "GQ", website: "https://groq.com" }, - xai: { id: "xai", alias: "xai", name: "xAI (Grok)", icon: "auto_awesome", color: "#1DA1F2", textIcon: "XA", website: "https://x.ai" }, - mistral: { id: "mistral", alias: "mistral", name: "Mistral", icon: "air", color: "#FF7000", textIcon: "MI", website: "https://mistral.ai" }, - perplexity: { id: "perplexity", alias: "pplx", name: "Perplexity", icon: "search", color: "#20808D", textIcon: "PP", website: "https://www.perplexity.ai" }, + groq: { id: "groq", alias: "groq", name: "Groq", icon: "speed", color: "#F55036", textIcon: "GQ", website: "https://groq.com", serviceKinds: ["llm", "imageToText"] }, + xai: { id: "xai", alias: "xai", name: "xAI (Grok)", icon: "auto_awesome", color: "#1DA1F2", textIcon: "XA", website: "https://x.ai", serviceKinds: ["llm", "imageToText", "webSearch"] }, + mistral: { id: "mistral", alias: "mistral", name: "Mistral", icon: "air", color: "#FF7000", textIcon: "MI", website: "https://mistral.ai", serviceKinds: ["llm", "imageToText"] }, + perplexity: { id: "perplexity", alias: "pplx", name: "Perplexity", icon: "search", color: "#20808D", textIcon: "PP", website: "https://www.perplexity.ai", serviceKinds: ["llm", "webSearch"] }, together: { id: "together", alias: "together", name: "Together AI", icon: "group_work", color: "#0F6FFF", textIcon: "TG", website: "https://www.together.ai" }, fireworks: { id: "fireworks", alias: "fireworks", name: "Fireworks AI", icon: "local_fire_department", color: "#7B2EF2", textIcon: "FW", website: "https://fireworks.ai" }, cerebras: { id: "cerebras", alias: "cerebras", name: "Cerebras", icon: "memory", color: "#FF4F00", textIcon: "CB", website: "https://www.cerebras.ai" }, @@ -75,7 +75,7 @@ export const APIKEY_PROVIDERS = { nebius: { id: "nebius", alias: "nebius", name: "Nebius AI", icon: "cloud", color: "#6C5CE7", textIcon: "NB", website: "https://nebius.com" }, siliconflow: { id: "siliconflow", alias: "siliconflow", name: "SiliconFlow", icon: "cloud_queue", color: "#5B6EF5", textIcon: "SF", website: "https://cloud.siliconflow.com" }, hyperbolic: { id: "hyperbolic", alias: "hyp", name: "Hyperbolic", icon: "bolt", color: "#00D4FF", textIcon: "HY", website: "https://hyperbolic.xyz" }, - deepgram: { id: "deepgram", alias: "dg", name: "Deepgram", icon: "mic", color: "#13EF93", textIcon: "DG", website: "https://deepgram.com", serviceKinds: ["stt"] }, + deepgram: { id: "deepgram", alias: "dg", name: "Deepgram", icon: "mic", color: "#13EF93", textIcon: "DG", website: "https://deepgram.com", serviceKinds: ["stt", "imageToText"] }, assemblyai: { id: "assemblyai", alias: "aai", name: "AssemblyAI", icon: "record_voice_over", color: "#0062FF", textIcon: "AA", website: "https://assemblyai.com", serviceKinds: ["stt"] }, nanobanana: { id: "nanobanana", alias: "nb", name: "NanoBanana", icon: "image", color: "#FFD700", textIcon: "NB", website: "https://nanobananaapi.ai", serviceKinds: ["image"] }, elevenlabs: { id: "elevenlabs", alias: "el", name: "ElevenLabs", icon: "record_voice_over", color: "#6C47FF", textIcon: "EL", website: "https://elevenlabs.io", serviceKinds: ["tts"] }, @@ -86,20 +86,29 @@ export const APIKEY_PROVIDERS = { "edge-tts": { id: "edge-tts", alias: "edge-tts", name: "Edge TTS", icon: "record_voice_over", color: "#0078D4", textIcon: "ET", serviceKinds: ["tts"], noAuth: true }, sdwebui: { id: "sdwebui", alias: "sdwebui", name: "SD WebUI", icon: "brush", color: "#FF7043", textIcon: "SD", website: "https://github.com/AUTOMATIC1111/stable-diffusion-webui", serviceKinds: ["image"] }, comfyui: { id: "comfyui", alias: "comfyui", name: "ComfyUI", icon: "account_tree", color: "#4CAF50", textIcon: "CF", website: "https://github.com/comfyanonymous/ComfyUI", serviceKinds: ["image"] }, - huggingface: { id: "huggingface", alias: "hf", name: "HuggingFace", icon: "face", color: "#FFD21E", textIcon: "HF", website: "https://huggingface.co", serviceKinds: ["image", "tts"], hiddenKinds: ["tts"] }, + huggingface: { id: "huggingface", alias: "hf", name: "HuggingFace", icon: "face", color: "#FFD21E", textIcon: "HF", website: "https://huggingface.co", serviceKinds: ["image", "imageToText", "tts"], hiddenKinds: ["tts"] }, chutes: { id: "chutes", alias: "ch", name: "Chutes AI", icon: "water_drop", color: "#ffffffff", textIcon: "CH", website: "https://chutes.ai" }, "ollama-local": { id: "ollama-local", alias: "ollama-local", name: "Ollama Local", icon: "cloud", color: "#ffffffff", textIcon: "OL", website: "https://ollama.com" }, "vertex-partner": { id: "vertex-partner", alias: "vxp", name: "Vertex Partner", icon: "cloud", color: "#34A853", textIcon: "VP", website: "https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-partner-models" }, + tavily: { id: "tavily", alias: "tavily", name: "Tavily", icon: "search", color: "#5B21B6", textIcon: "TV", website: "https://tavily.com", serviceKinds: ["webSearch"] }, + "brave-search": { id: "brave-search", alias: "brave", name: "Brave Search", icon: "travel_explore", color: "#FB542B", textIcon: "BR", website: "https://brave.com/search/api", serviceKinds: ["webSearch"] }, + serper: { id: "serper", alias: "serper", name: "Serper", icon: "search", color: "#4F46E5", textIcon: "SP", website: "https://serper.dev", serviceKinds: ["webSearch"] }, + exa: { id: "exa", alias: "exa", name: "Exa", icon: "manage_search", color: "#2563EB", textIcon: "EX", website: "https://exa.ai", serviceKinds: ["webSearch"] }, + searxng: { id: "searxng", alias: "searxng", name: "SearXNG", icon: "saved_search", color: "#3B82F6", textIcon: "SX", website: "https://docs.searxng.org", serviceKinds: ["webSearch"], noAuth: true }, + firecrawl: { id: "firecrawl", alias: "firecrawl", name: "Firecrawl", icon: "local_fire_department", color: "#F59E0B", textIcon: "FC", website: "https://firecrawl.dev", serviceKinds: ["webFetch"] }, }; // Media provider kinds — each kind maps to a route and endpoint config export const MEDIA_PROVIDER_KINDS = [ - { id: "embedding", label: "Embedding", icon: "data_array", endpoint: { method: "POST", path: "/v1/embeddings" } }, - { id: "image", label: "Image", icon: "image", endpoint: { method: "POST", path: "/v1/images/generations" } }, - { id: "tts", label: "Text To Speech", icon: "record_voice_over", endpoint: { method: "POST", path: "/v1/audio/speech" } }, - { id: "stt", label: "STT", icon: "mic", endpoint: { method: "POST", path: "/v1/audio/transcriptions" } }, - { id: "video", label: "Video", icon: "movie", endpoint: { method: "POST", path: "/v1/video/generations" } }, - { id: "music", label: "Music", icon: "music_note", endpoint: { method: "POST", path: "/v1/audio/music" } }, + { id: "embedding", label: "Embedding", icon: "data_array", endpoint: { method: "POST", path: "/v1/embeddings" } }, + { id: "image", label: "Text to Image", icon: "brush", endpoint: { method: "POST", path: "/v1/images/generations" } }, + { id: "imageToText", label: "Image to Text", icon: "image_search", endpoint: { method: "POST", path: "/v1/images/understanding" } }, + { id: "tts", label: "Text To Speech", icon: "record_voice_over", endpoint: { method: "POST", path: "/v1/audio/speech" } }, + { id: "stt", label: "STT", icon: "mic", endpoint: { method: "POST", path: "/v1/audio/transcriptions" } }, + { id: "webSearch", label: "Web Search", icon: "travel_explore", endpoint: { method: "POST", path: "/v1/search" } }, + { id: "webFetch", label: "Web Fetch", icon: "language", endpoint: { method: "POST", path: "/v1/web/fetch" } }, + { id: "video", label: "Video", icon: "movie", endpoint: { method: "POST", path: "/v1/video/generations" } }, + { id: "music", label: "Music", icon: "music_note", endpoint: { method: "POST", path: "/v1/audio/music" } }, ]; export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-"; diff --git a/src/shared/constants/ttsProviders.js b/src/shared/constants/ttsProviders.js index dbe1a13..b75bfe7 100644 --- a/src/shared/constants/ttsProviders.js +++ b/src/shared/constants/ttsProviders.js @@ -13,9 +13,19 @@ export const TTS_PROVIDER_CONFIG = { hasLanguageDropdown: false, hasModelSelector: true, hasBrowseButton: false, - voiceSource: "hardcoded", // from providerModels + voiceSource: "hardcoded", modelKey: "openai-tts-models", voiceKey: "openai-tts-voices", + voicesPerModel: true, + }, + "openrouter": { + hasLanguageDropdown: false, + hasModelSelector: true, + hasBrowseButton: false, + voiceSource: "hardcoded", + modelKey: "openrouter-tts-models", + voiceKey: "openrouter-tts-voices", + voicesPerModel: true, }, "elevenlabs": { hasLanguageDropdown: false, diff --git a/src/shared/utils/providerModelsFetcher.js b/src/shared/utils/providerModelsFetcher.js index 849a934..3ba1bab 100644 --- a/src/shared/utils/providerModelsFetcher.js +++ b/src/shared/utils/providerModelsFetcher.js @@ -1,28 +1,14 @@ // Fetch and cache suggested models for providers that expose a public models API -// Designed to be extensible: add new types in FILTERS below +// Fetches via backend proxy to avoid CORS issues const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes const cache = new Map(); // key: fetcher.url → { data, expiresAt } -const FILTERS = { - // Free models with context >= 200k tokens - "openrouter-free": (models) => - models - .filter( - (m) => - m.pricing?.prompt === "0" && - m.pricing?.completion === "0" && - m.context_length >= 200000 - ) - .map((m) => ({ id: m.id, name: m.name, contextLength: m.context_length })) - .sort((a, b) => b.contextLength - a.contextLength), -}; - /** * Fetch suggested models for a provider using its modelsFetcher config. * Results are cached in-memory for CACHE_TTL_MS. * @param {{ url: string, type: string }} fetcher - * @returns {Promise>} + * @returns {Promise>} */ export async function fetchSuggestedModels(fetcher) { if (!fetcher?.url || !fetcher?.type) return []; @@ -31,12 +17,11 @@ export async function fetchSuggestedModels(fetcher) { if (cached && Date.now() < cached.expiresAt) return cached.data; try { - const res = await fetch(fetcher.url); + const params = new URLSearchParams({ url: fetcher.url, type: fetcher.type }); + const res = await fetch(`/api/providers/suggested-models?${params}`); if (!res.ok) return []; const json = await res.json(); - const raw = json.data ?? json.models ?? json; - const filter = FILTERS[fetcher.type]; - const data = filter ? filter(Array.isArray(raw) ? raw : []) : []; + const data = json.data ?? []; cache.set(fetcher.url, { data, expiresAt: Date.now() + CACHE_TTL_MS }); return data; } catch { diff --git a/src/sse/handlers/tts.js b/src/sse/handlers/tts.js index 18ac5be..f861209 100644 --- a/src/sse/handlers/tts.js +++ b/src/sse/handlers/tts.js @@ -10,7 +10,7 @@ import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js"; import * as log from "../utils/logger.js"; // Providers that require stored credentials (not noAuth) -const CREDENTIALED_PROVIDERS = new Set(["openai", "elevenlabs"]); +const CREDENTIALED_PROVIDERS = new Set(["openai", "elevenlabs", "openrouter"]); export async function handleTts(request) { let body;