Refactor error handling to config-driven approach with centralized error rules
Made-with: Cursor
This commit is contained in:
82
open-sse/config/errorConfig.js
Normal file
82
open-sse/config/errorConfig.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// OpenAI-compatible error types mapping (client-facing)
|
||||||
|
export const ERROR_TYPES = {
|
||||||
|
400: { type: "invalid_request_error", code: "bad_request" },
|
||||||
|
401: { type: "authentication_error", code: "invalid_api_key" },
|
||||||
|
402: { type: "billing_error", code: "payment_required" },
|
||||||
|
403: { type: "permission_error", code: "insufficient_quota" },
|
||||||
|
404: { type: "invalid_request_error", code: "model_not_found" },
|
||||||
|
406: { type: "invalid_request_error", code: "model_not_supported" },
|
||||||
|
429: { type: "rate_limit_error", code: "rate_limit_exceeded" },
|
||||||
|
500: { type: "server_error", code: "internal_server_error" },
|
||||||
|
502: { type: "server_error", code: "bad_gateway" },
|
||||||
|
503: { type: "server_error", code: "service_unavailable" },
|
||||||
|
504: { type: "server_error", code: "gateway_timeout" }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default error messages per status code (client-facing)
|
||||||
|
export const DEFAULT_ERROR_MESSAGES = {
|
||||||
|
400: "Bad request",
|
||||||
|
401: "Invalid API key provided",
|
||||||
|
402: "Payment required",
|
||||||
|
403: "You exceeded your current quota",
|
||||||
|
404: "Model not found",
|
||||||
|
406: "Model not supported",
|
||||||
|
429: "Rate limit exceeded",
|
||||||
|
500: "Internal server error",
|
||||||
|
502: "Bad gateway - upstream provider error",
|
||||||
|
503: "Service temporarily unavailable",
|
||||||
|
504: "Gateway timeout"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Exponential backoff config for rate limits
|
||||||
|
export const BACKOFF_CONFIG = {
|
||||||
|
base: 1000,
|
||||||
|
max: 3 * 60 * 1000,
|
||||||
|
maxLevel: 15
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default cooldown for transient/unknown errors
|
||||||
|
export const TRANSIENT_COOLDOWN_MS = 30 * 1000;
|
||||||
|
|
||||||
|
// Cooldown durations (ms)
|
||||||
|
const COOLDOWN = {
|
||||||
|
long: 2 * 60 * 1000,
|
||||||
|
short: 5 * 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified error classification rules.
|
||||||
|
* Checked top-to-bottom: text rules first (by order), then status rules.
|
||||||
|
* Each rule: { text?, status?, cooldownMs?, backoff? }
|
||||||
|
* - text: substring match (case-insensitive) on error message
|
||||||
|
* - status: HTTP status code match
|
||||||
|
* - cooldownMs: fixed cooldown duration
|
||||||
|
* - backoff: true = use exponential backoff (rate limit)
|
||||||
|
*/
|
||||||
|
export const ERROR_RULES = [
|
||||||
|
// --- Text-based rules (checked first, order = priority) ---
|
||||||
|
{ text: "no credentials", cooldownMs: COOLDOWN.long },
|
||||||
|
{ text: "request not allowed", cooldownMs: COOLDOWN.short },
|
||||||
|
{ text: "improperly formed request", cooldownMs: COOLDOWN.long },
|
||||||
|
{ text: "rate limit", backoff: true },
|
||||||
|
{ text: "too many requests", backoff: true },
|
||||||
|
{ text: "quota exceeded", backoff: true },
|
||||||
|
{ text: "capacity", backoff: true },
|
||||||
|
{ text: "overloaded", backoff: true },
|
||||||
|
|
||||||
|
// --- Status-based rules (fallback when text doesn't match) ---
|
||||||
|
{ status: 401, cooldownMs: COOLDOWN.long },
|
||||||
|
{ status: 402, cooldownMs: COOLDOWN.long },
|
||||||
|
{ status: 403, cooldownMs: COOLDOWN.long },
|
||||||
|
{ status: 404, cooldownMs: COOLDOWN.long },
|
||||||
|
{ status: 429, backoff: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Backward compat: COOLDOWN_MS object (used by index.js re-export)
|
||||||
|
export const COOLDOWN_MS = {
|
||||||
|
unauthorized: COOLDOWN.long,
|
||||||
|
paymentRequired: COOLDOWN.long,
|
||||||
|
notFound: COOLDOWN.long,
|
||||||
|
transient: TRANSIENT_COOLDOWN_MS,
|
||||||
|
requestNotAllowed: COOLDOWN.short,
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { PROVIDERS } from "./providers.js";
|
import { PROVIDERS } from "./providers.js";
|
||||||
import { GOOGLE_TTS_LANGUAGES } from "./googleTtsLanguages.js";
|
import { buildTtsProviderModels } from "./ttsModels.js";
|
||||||
|
|
||||||
// Provider models - Single source of truth
|
// Provider models - Single source of truth
|
||||||
// Key = alias (cc, cx, gc, qw, if, ag, gh for OAuth; id for API Key)
|
// 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" },
|
{ id: "deepseek/deepseek-reasoner", name: "DeepSeek Reasoner" },
|
||||||
],
|
],
|
||||||
oc: [ // OpenCode
|
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: "qwen3.6-plus-free", name: "Qwen 3.6 Plus" },
|
||||||
// { id: "big-pickle", name: "Big Pickle", targetFormat: "claude" },
|
// { 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" },
|
// { 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-4b", name: "Perplexity Embed V1 4B", type: "embedding" },
|
||||||
{ id: "perplexity/pplx-embed-v1-0.6b", name: "Perplexity Embed V1 0.6B", 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" },
|
{ 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: [
|
glm: [
|
||||||
{ id: "glm-5.1", name: "GLM 5.1" },
|
{ 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)" },
|
{ id: "zai-org/glm-5-maas", name: "GLM-5 (Vertex)" },
|
||||||
],
|
],
|
||||||
|
|
||||||
// Free/noAuth TTS providers
|
// TTS entries are loaded from ttsModels.js via buildTtsProviderModels()
|
||||||
"local-device": [
|
...buildTtsProviderModels(),
|
||||||
{ 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" },
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
|
|||||||
@@ -14,33 +14,8 @@ export const HTTP_STATUS = {
|
|||||||
GATEWAY_TIMEOUT: 504
|
GATEWAY_TIMEOUT: 504
|
||||||
};
|
};
|
||||||
|
|
||||||
// OpenAI-compatible error types mapping
|
// Re-export error config (backward compat)
|
||||||
export const ERROR_TYPES = {
|
export { ERROR_TYPES, DEFAULT_ERROR_MESSAGES, BACKOFF_CONFIG, COOLDOWN_MS } from "./errorConfig.js";
|
||||||
[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"
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cache TTLs (seconds)
|
// Cache TTLs (seconds)
|
||||||
export const CACHE_TTL = {
|
export const CACHE_TTL = {
|
||||||
@@ -73,26 +48,6 @@ export const DEFAULT_RETRY_CONFIG = {
|
|||||||
502: 1 // Bad gateway - retry 1 time (transient)
|
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
|
// Requests containing these texts will bypass provider
|
||||||
export const SKIP_PATTERNS = [
|
export const SKIP_PATTERNS = [
|
||||||
"Please write a 5-10 word title for the following conversation:"
|
"Please write a 5-10 word title for the following conversation:"
|
||||||
|
|||||||
109
open-sse/config/ttsModels.js
Normal file
109
open-sse/config/ttsModels.js
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { GOOGLE_TTS_LANGUAGES } from "./googleTtsLanguages.js";
|
||||||
|
|
||||||
|
// ── Voice definitions (DRY — reused across providers) ──────────────────────
|
||||||
|
const VOICES = {
|
||||||
|
alloy: { id: "alloy", name: "Alloy" },
|
||||||
|
ash: { id: "ash", name: "Ash" },
|
||||||
|
ballad: { id: "ballad", name: "Ballad" },
|
||||||
|
cedar: { id: "cedar", name: "Cedar" },
|
||||||
|
coral: { id: "coral", name: "Coral" },
|
||||||
|
echo: { id: "echo", name: "Echo" },
|
||||||
|
fable: { id: "fable", name: "Fable" },
|
||||||
|
marin: { id: "marin", name: "Marin" },
|
||||||
|
nova: { id: "nova", name: "Nova" },
|
||||||
|
onyx: { id: "onyx", name: "Onyx" },
|
||||||
|
sage: { id: "sage", name: "Sage" },
|
||||||
|
shimmer: { id: "shimmer", name: "Shimmer" },
|
||||||
|
verse: { id: "verse", name: "Verse" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const v = (...keys) => keys.map((k) => ({ ...VOICES[k], type: "tts" }));
|
||||||
|
|
||||||
|
// 9 voices for tts-1 / tts-1-hd
|
||||||
|
const VOICES_STANDARD = v("alloy", "ash", "coral", "echo", "fable", "nova", "onyx", "sage", "shimmer");
|
||||||
|
// 13 voices for gpt-4o-mini-tts
|
||||||
|
const VOICES_FULL = v("alloy", "ash", "ballad", "cedar", "coral", "echo", "fable", "marin", "nova", "onyx", "sage", "shimmer", "verse");
|
||||||
|
|
||||||
|
// ── TTS Config (config-driven, single source of truth) ─────────────────────
|
||||||
|
export const TTS_MODELS_CONFIG = {
|
||||||
|
openai: {
|
||||||
|
models: [
|
||||||
|
{ id: "gpt-4o-mini-tts", name: "GPT-4o Mini TTS", type: "tts" },
|
||||||
|
{ id: "tts-1-hd", name: "TTS-1 HD", type: "tts" },
|
||||||
|
{ id: "tts-1", name: "TTS-1", type: "tts" },
|
||||||
|
],
|
||||||
|
voices: {
|
||||||
|
"gpt-4o-mini-tts": VOICES_FULL,
|
||||||
|
"tts-1": VOICES_STANDARD,
|
||||||
|
"tts-1-hd": VOICES_STANDARD,
|
||||||
|
},
|
||||||
|
// Flat voice list (all unique voices) for backward compat
|
||||||
|
allVoices: VOICES_FULL,
|
||||||
|
},
|
||||||
|
openrouter: {
|
||||||
|
models: [
|
||||||
|
{ id: "openai/gpt-4o-mini-tts", name: "GPT-4o Mini TTS", type: "tts" },
|
||||||
|
{ id: "openai/tts-1-hd", name: "TTS-1 HD", type: "tts" },
|
||||||
|
{ id: "openai/tts-1", name: "TTS-1", type: "tts" },
|
||||||
|
],
|
||||||
|
voices: {
|
||||||
|
"openai/gpt-4o-mini-tts": VOICES_FULL,
|
||||||
|
"openai/tts-1": VOICES_STANDARD,
|
||||||
|
"openai/tts-1-hd": VOICES_STANDARD,
|
||||||
|
},
|
||||||
|
allVoices: VOICES_FULL,
|
||||||
|
},
|
||||||
|
elevenlabs: {
|
||||||
|
models: [
|
||||||
|
{ id: "eleven_flash_v2_5", name: "Flash v2.5 (Fastest)", type: "tts" },
|
||||||
|
{ id: "eleven_turbo_v2_5", name: "Turbo v2.5 (Fast)", type: "tts" },
|
||||||
|
{ id: "eleven_multilingual_v2", name: "Multilingual v2 (Quality)", type: "tts" },
|
||||||
|
{ id: "eleven_monolingual_v1", name: "Monolingual v1 (English)", type: "tts" },
|
||||||
|
],
|
||||||
|
// voices come from API, not hardcoded
|
||||||
|
},
|
||||||
|
"edge-tts": {
|
||||||
|
defaults: [
|
||||||
|
{ id: "en-US-AriaNeural", name: "Aria (en-US)", type: "tts" },
|
||||||
|
{ id: "en-US-GuyNeural", name: "Guy (en-US)", type: "tts" },
|
||||||
|
{ id: "en-GB-SoniaNeural", name: "Sonia (en-GB)", type: "tts" },
|
||||||
|
{ id: "vi-VN-HoaiMyNeural", name: "Hoai My (vi-VN)", type: "tts" },
|
||||||
|
{ id: "vi-VN-NamMinhNeural", name: "Nam Minh (vi-VN)", type: "tts" },
|
||||||
|
{ id: "zh-CN-XiaoxiaoNeural", name: "Xiaoxiao (zh-CN)", type: "tts" },
|
||||||
|
{ id: "zh-CN-YunxiNeural", name: "Yunxi (zh-CN)", type: "tts" },
|
||||||
|
{ id: "fr-FR-DeniseNeural", name: "Denise (fr-FR)", type: "tts" },
|
||||||
|
{ id: "de-DE-KatjaNeural", name: "Katja (de-DE)", type: "tts" },
|
||||||
|
{ id: "ja-JP-NanamiNeural", name: "Nanami (ja-JP)", type: "tts" },
|
||||||
|
{ id: "ko-KR-SunHiNeural", name: "SunHi (ko-KR)", type: "tts" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"local-device": {
|
||||||
|
defaults: [
|
||||||
|
{ id: "default", name: "System Default Voice", type: "tts" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"google-tts": {
|
||||||
|
defaults: GOOGLE_TTS_LANGUAGES,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helper: get voices for a specific model ────────────────────────────────
|
||||||
|
export function getTtsVoicesForModel(provider, modelId) {
|
||||||
|
const cfg = TTS_MODELS_CONFIG[provider];
|
||||||
|
if (!cfg?.voices) return null;
|
||||||
|
return cfg.voices[modelId] || cfg.allVoices || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build flat entries for PROVIDER_MODELS backward compat ─────────────────
|
||||||
|
export function buildTtsProviderModels() {
|
||||||
|
const entries = {};
|
||||||
|
for (const [provider, cfg] of Object.entries(TTS_MODELS_CONFIG)) {
|
||||||
|
if (cfg.models) entries[`${provider}-tts-models`] = cfg.models;
|
||||||
|
if (cfg.allVoices) entries[`${provider}-tts-voices`] = cfg.allVoices;
|
||||||
|
if (cfg.defaults) entries[provider] = cfg.defaults;
|
||||||
|
}
|
||||||
|
// Keep openai-tts-voices key pointing to full voice list for backward compat
|
||||||
|
entries["openai-tts-voices"] = TTS_MODELS_CONFIG.openai.allVoices;
|
||||||
|
entries["openrouter-tts-voices"] = TTS_MODELS_CONFIG.openrouter.allVoices;
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { BaseExecutor } from "./base.js";
|
|||||||
import { PROVIDERS } from "../config/providers.js";
|
import { PROVIDERS } from "../config/providers.js";
|
||||||
|
|
||||||
// Models that use /zen/v1/messages (claude format)
|
// 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 {
|
export class OpenCodeExecutor extends BaseExecutor {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
// Provider returned error
|
// Provider returned error
|
||||||
if (!providerResponse.ok) {
|
if (!providerResponse.ok) {
|
||||||
trackPendingRequest(model, provider, connectionId, false, true);
|
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(() => {});
|
appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(() => {});
|
||||||
saveRequestDetail(buildRequestDetail({
|
saveRequestDetail(buildRequestDetail({
|
||||||
provider, model, connectionId,
|
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);
|
const errMsg = formatProviderError(new Error(message), provider, model, statusCode);
|
||||||
console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
|
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);
|
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 };
|
const sharedCtx = { provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess };
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ export async function handleEmbeddingsCore({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!providerResponse.ok) {
|
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);
|
const errMsg = formatProviderError(new Error(message), provider, model, statusCode);
|
||||||
log?.debug?.("EMBEDDINGS", `Provider error: ${errMsg}`);
|
log?.debug?.("EMBEDDINGS", `Provider error: ${errMsg}`);
|
||||||
return createErrorResult(statusCode, errMsg);
|
return createErrorResult(statusCode, errMsg);
|
||||||
|
|||||||
@@ -339,6 +339,84 @@ export const VOICE_FETCHERS = {
|
|||||||
// openai: uses hardcoded voices from providerModels.js
|
// 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 ───────────────────────────────────────────────────────────────
|
// ── OpenAI TTS ───────────────────────────────────────────────────────────────
|
||||||
async function handleOpenAiTts({ model, input, credentials, responseFormat = "mp3" }) {
|
async function handleOpenAiTts({ model, input, credentials, responseFormat = "mp3" }) {
|
||||||
if (!credentials?.apiKey) {
|
if (!credentials?.apiKey) {
|
||||||
@@ -422,6 +500,12 @@ const TTS_PROVIDERS = {
|
|||||||
},
|
},
|
||||||
requiresCredentials: true,
|
requiresCredentials: true,
|
||||||
},
|
},
|
||||||
|
"openrouter": {
|
||||||
|
synthesize: async (text, model, credentials, responseFormat) => {
|
||||||
|
return await handleOpenRouterTts({ model, input: text, credentials, responseFormat });
|
||||||
|
},
|
||||||
|
requiresCredentials: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Core handler ───────────────────────────────────────────────
|
// ── Core handler ───────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { COOLDOWN_MS, BACKOFF_CONFIG, HTTP_STATUS } from "../config/runtimeConfig.js";
|
import { ERROR_RULES, BACKOFF_CONFIG, TRANSIENT_COOLDOWN_MS } from "../config/errorConfig.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate exponential backoff cooldown for rate limits (429)
|
* 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)
|
* 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 {number} status - HTTP status code
|
||||||
* @param {string} errorText - Error message text
|
* @param {string} errorText - Error message text
|
||||||
* @param {number} backoffLevel - Current backoff level for exponential backoff
|
* @param {number} backoffLevel - Current backoff level for exponential backoff
|
||||||
* @returns {{ shouldFallback: boolean, cooldownMs: number, newBackoffLevel?: number }}
|
* @returns {{ shouldFallback: boolean, cooldownMs: number, newBackoffLevel?: number }}
|
||||||
*/
|
*/
|
||||||
export function checkFallbackError(status, errorText, backoffLevel = 0) {
|
export function checkFallbackError(status, errorText, backoffLevel = 0) {
|
||||||
// Check error message FIRST - specific patterns take priority over status codes
|
const lowerError = errorText
|
||||||
if (errorText) {
|
? (typeof errorText === "string" ? errorText : JSON.stringify(errorText)).toLowerCase()
|
||||||
const errorStr = typeof errorText === "string" ? errorText : JSON.stringify(errorText);
|
: "";
|
||||||
const lowerError = errorStr.toLowerCase();
|
|
||||||
|
|
||||||
if (lowerError.includes("no credentials")) {
|
for (const rule of ERROR_RULES) {
|
||||||
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.notFound };
|
// 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")) {
|
// Status-based rule: match HTTP status code
|
||||||
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.requestNotAllowed };
|
if (rule.status && rule.status === status) {
|
||||||
}
|
if (rule.backoff) {
|
||||||
|
const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel);
|
||||||
// Kiro: "improperly formed request" = model not available on this account tier
|
return { shouldFallback: true, cooldownMs: getQuotaCooldown(backoffLevel), newBackoffLevel: newLevel };
|
||||||
// Treat as paymentRequired (long cooldown) so the model is locked and fallback occurs
|
}
|
||||||
if (lowerError.includes("improperly formed request")) {
|
return { shouldFallback: true, cooldownMs: rule.cooldownMs };
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === HTTP_STATUS.UNAUTHORIZED) {
|
// Default: transient cooldown for any unmatched error
|
||||||
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.unauthorized };
|
return { shouldFallback: true, cooldownMs: TRANSIENT_COOLDOWN_MS };
|
||||||
}
|
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
* 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`));
|
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
|
* Parse upstream provider error response
|
||||||
* @param {Response} response - Fetch response from provider
|
* @param {Response} response - Fetch response from provider
|
||||||
* @param {string} provider - Provider name (for Antigravity-specific parsing)
|
* @returns {Promise<{statusCode: number, message: string}>}
|
||||||
* @returns {Promise<{statusCode: number, message: string, retryAfterMs: number|null}>}
|
|
||||||
*/
|
*/
|
||||||
export async function parseUpstreamError(response, provider = null) {
|
export async function parseUpstreamError(response) {
|
||||||
let message = "";
|
let message = "";
|
||||||
let retryAfterMs = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
|
|
||||||
// Try parse as JSON
|
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(text);
|
const json = JSON.parse(text);
|
||||||
message = json.error?.message || json.message || json.error || 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 messageStr = typeof message === "string" ? message : JSON.stringify(message);
|
||||||
const finalMessage = messageStr || DEFAULT_ERROR_MESSAGES[response.status] || `Upstream error: ${response.status}`;
|
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 {
|
return {
|
||||||
statusCode: response.status,
|
statusCode: response.status,
|
||||||
message: finalMessage,
|
message: finalMessage
|
||||||
retryAfterMs
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,23 +83,15 @@ export async function parseUpstreamError(response, provider = null) {
|
|||||||
* Create error result for chatCore handler
|
* Create error result for chatCore handler
|
||||||
* @param {number} statusCode - HTTP status code
|
* @param {number} statusCode - HTTP status code
|
||||||
* @param {string} message - Error message
|
* @param {string} message - Error message
|
||||||
* @param {number|null} retryAfterMs - Optional retry-after time in milliseconds
|
* @returns {{ success: false, status: number, error: string, response: Response }}
|
||||||
* @returns {{ success: false, status: number, error: string, response: Response, retryAfterMs?: number }}
|
|
||||||
*/
|
*/
|
||||||
export function createErrorResult(statusCode, message, retryAfterMs = null) {
|
export function createErrorResult(statusCode, message) {
|
||||||
const result = {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
status: statusCode,
|
status: statusCode,
|
||||||
error: message,
|
error: message,
|
||||||
response: errorResponse(statusCode, message)
|
response: errorResponse(statusCode, message)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add retryAfterMs if available (for Antigravity quota errors)
|
|
||||||
if (retryAfterMs) {
|
|
||||||
result.retryAfterMs = retryAfterMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
|||||||
import ConnectionsCard from "@/app/(dashboard)/dashboard/providers/components/ConnectionsCard";
|
import ConnectionsCard from "@/app/(dashboard)/dashboard/providers/components/ConnectionsCard";
|
||||||
import ModelsCard from "@/app/(dashboard)/dashboard/providers/components/ModelsCard";
|
import ModelsCard from "@/app/(dashboard)/dashboard/providers/components/ModelsCard";
|
||||||
import { TTS_PROVIDER_CONFIG } from "@/shared/constants/ttsProviders";
|
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
|
// Shared row layout — defined outside components to avoid re-mount on re-render
|
||||||
function Row({ label, children }) {
|
function Row({ label, children }) {
|
||||||
@@ -40,6 +41,60 @@ const DEFAULT_RESPONSE_EXAMPLE = `{
|
|||||||
"usage": { "prompt_tokens": 9, "total_tokens": 9 }
|
"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
|
// EmbeddingExampleCard
|
||||||
function EmbeddingExampleCard({ providerId }) {
|
function EmbeddingExampleCard({ providerId }) {
|
||||||
const providerAlias = getProviderAlias(providerId);
|
const providerAlias = getProviderAlias(providerId);
|
||||||
@@ -300,8 +355,13 @@ function TtsExampleCard({ providerId }) {
|
|||||||
|
|
||||||
// Pre-select default voice based on provider config
|
// Pre-select default voice based on provider config
|
||||||
if (config.voiceSource === "hardcoded") {
|
if (config.voiceSource === "hardcoded") {
|
||||||
const voiceKey = config.voiceKey || providerId;
|
const defaultModel = config.hasModelSelector && config.modelKey
|
||||||
const voices = getModelsByProviderId(voiceKey).filter((m) => m.type === "tts");
|
? (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 (voices.length) {
|
||||||
if (config.hasBrowseButton) {
|
if (config.hasBrowseButton) {
|
||||||
// Google TTS: pre-select "en" (English) as default, show as single voice chip
|
// Google TTS: pre-select "en" (English) as default, show as single voice chip
|
||||||
@@ -311,7 +371,7 @@ function TtsExampleCard({ providerId }) {
|
|||||||
setSelectedVoiceName(defaultVoice.name);
|
setSelectedVoiceName(defaultVoice.name);
|
||||||
setCountryVoices([{ id: defaultVoice.id, name: defaultVoice.name }]);
|
setCountryVoices([{ id: defaultVoice.id, name: defaultVoice.name }]);
|
||||||
} else {
|
} else {
|
||||||
// OpenAI: set voice chips directly (no language picker)
|
// OpenAI/OpenRouter: set voice chips directly (no language picker)
|
||||||
setCountryVoices(voices);
|
setCountryVoices(voices);
|
||||||
setSelectedVoice(voices[0].id);
|
setSelectedVoice(voices[0].id);
|
||||||
setSelectedVoiceName(voices[0].name || 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
|
// api-language (edge-tts, local-device, elevenlabs): NO default load, wait for user to pick language
|
||||||
}, [providerId]);
|
}, [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
|
// Open modal — load language list
|
||||||
const openModal = async () => {
|
const openModal = async () => {
|
||||||
setModalOpen(true);
|
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 (
|
||||||
|
<Card>
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Example</h2>
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
{/* Endpoint */}
|
||||||
|
<Row label="Endpoint">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex-1 px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate">
|
||||||
|
{endpoint}{apiPath}
|
||||||
|
</span>
|
||||||
|
{tunnelEndpoint && (
|
||||||
|
<button
|
||||||
|
onClick={() => setUseTunnel((v) => !v)}
|
||||||
|
title={useTunnel ? "Using tunnel" : "Using local"}
|
||||||
|
className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded-lg border shrink-0 transition-colors ${
|
||||||
|
useTunnel ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[14px]">wifi_tethering</span>
|
||||||
|
Tunnel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* API Key */}
|
||||||
|
<Row label="API Key">
|
||||||
|
<span className="px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate block">
|
||||||
|
{apiKey ? `${apiKey.slice(0, 8)}${"\u2022".repeat(Math.min(20, apiKey.length - 8))}` : <span className="text-text-muted italic">No key configured</span>}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<Row label={exConfig.inputLabel}>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => 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 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setInput("")}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[14px]">close</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Curl + Run */}
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Request</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => copyCurl(curlSnippet)}
|
||||||
|
className="flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[14px]">{copiedCurl ? "check" : "content_copy"}</span>
|
||||||
|
{copiedCurl ? "Copied" : "Copy"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRun}
|
||||||
|
disabled={running || !input.trim()}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1 rounded-lg bg-primary text-white text-xs font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[14px]" style={running ? { animation: "spin 1s linear infinite" } : undefined}>
|
||||||
|
{running ? "progress_activity" : "play_arrow"}
|
||||||
|
</span>
|
||||||
|
{running ? "Running..." : "Run"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre">{curlSnippet}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && <p className="text-xs text-red-500 break-words">{error}</p>}
|
||||||
|
|
||||||
|
{/* Response */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||||
|
Response {result && <span className="font-normal normal-case">⚡ {result.latencyMs}ms</span>}
|
||||||
|
</span>
|
||||||
|
{result && (
|
||||||
|
<button
|
||||||
|
onClick={() => copyRes(resultJson)}
|
||||||
|
className="flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[14px]">{copiedRes ? "check" : "content_copy"}</span>
|
||||||
|
{copiedRes ? "Copied" : "Copy"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre opacity-70">
|
||||||
|
{result ? resultJson : exConfig.defaultResponse}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// MediaProviderDetailPage
|
// MediaProviderDetailPage
|
||||||
export default function MediaProviderDetailPage() {
|
export default function MediaProviderDetailPage() {
|
||||||
const { kind, id } = useParams();
|
const { kind, id } = useParams();
|
||||||
@@ -817,6 +1068,7 @@ export default function MediaProviderDetailPage() {
|
|||||||
{/* Example — per kind */}
|
{/* Example — per kind */}
|
||||||
{kind === "embedding" && <EmbeddingExampleCard providerId={id} />}
|
{kind === "embedding" && <EmbeddingExampleCard providerId={id} />}
|
||||||
{kind === "tts" && <TtsExampleCard providerId={id} />}
|
{kind === "tts" && <TtsExampleCard providerId={id} />}
|
||||||
|
{KIND_EXAMPLE_CONFIG[kind] && <GenericExampleCard providerId={id} kind={kind} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -665,8 +665,9 @@ export default function ProviderDetailPage() {
|
|||||||
{/* Suggested models from provider API — show only models not yet added */}
|
{/* Suggested models from provider API — show only models not yet added */}
|
||||||
{suggestedModels.length > 0 && (() => {
|
{suggestedModels.length > 0 && (() => {
|
||||||
const addedFullModels = new Set(Object.values(modelAliases));
|
const addedFullModels = new Set(Object.values(modelAliases));
|
||||||
|
const hardcodedIds = new Set(models.map((m) => m.id));
|
||||||
const notAdded = suggestedModels.filter(
|
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;
|
if (notAdded.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
|
|||||||
49
src/app/api/providers/suggested-models/route.js
Normal file
49
src/app/api/providers/suggested-models/route.js
Normal file
@@ -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: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -64,12 +64,74 @@ if (!isCloud && fs && typeof fs.existsSync === "function") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default data structure
|
|
||||||
const defaultData = {
|
const defaultData = {
|
||||||
history: [],
|
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
|
// Singleton instance
|
||||||
let dbInstance = null;
|
let dbInstance = null;
|
||||||
|
|
||||||
@@ -238,11 +300,19 @@ export async function getUsageDb() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize with default data if empty
|
|
||||||
if (!dbInstance.data) {
|
if (!dbInstance.data) {
|
||||||
dbInstance.data = defaultData;
|
dbInstance.data = { ...defaultData };
|
||||||
await dbInstance.write();
|
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;
|
return dbInstance;
|
||||||
}
|
}
|
||||||
@@ -275,7 +345,9 @@ export async function saveRequestUsage(entry) {
|
|||||||
db.data.history.push(entry);
|
db.data.history.push(entry);
|
||||||
db.data.totalRequestsLifetime += 1;
|
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;
|
const MAX_HISTORY = 10000;
|
||||||
if (db.data.history.length > MAX_HISTORY) {
|
if (db.data.history.length > MAX_HISTORY) {
|
||||||
db.data.history.splice(0, 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") {
|
export async function getUsageStats(period = "all") {
|
||||||
const db = await getUsageDb();
|
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");
|
const { getProviderConnections, getApiKeys, getProviderNodes } = await import("@/lib/localDb.js");
|
||||||
|
|
||||||
// Fetch all provider connections to get account names
|
|
||||||
let allConnections = [];
|
let allConnections = [];
|
||||||
try {
|
try { allConnections = await getProviderConnections(); } catch {}
|
||||||
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
|
|
||||||
const connectionMap = {};
|
const connectionMap = {};
|
||||||
for (const conn of allConnections) {
|
for (const conn of allConnections) {
|
||||||
connectionMap[conn.id] = conn.name || conn.email || conn.id;
|
connectionMap[conn.id] = conn.name || conn.email || conn.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build map from compatible provider ID → friendly name (from providerNodes)
|
|
||||||
const providerNodeNameMap = {};
|
const providerNodeNameMap = {};
|
||||||
try {
|
try {
|
||||||
const nodes = await getProviderNodes();
|
const nodes = await getProviderNodes();
|
||||||
@@ -505,44 +562,28 @@ export async function getUsageStats(period = "all") {
|
|||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
// Fetch all API keys to get key names
|
|
||||||
let allApiKeys = [];
|
let allApiKeys = [];
|
||||||
try {
|
try { allApiKeys = await getApiKeys(); } catch {}
|
||||||
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
|
|
||||||
const apiKeyMap = {};
|
const apiKeyMap = {};
|
||||||
for (const key of allApiKeys) {
|
for (const key of allApiKeys) {
|
||||||
apiKeyMap[key.key] = {
|
apiKeyMap[key.key] = { name: key.name, id: key.id, createdAt: key.createdAt };
|
||||||
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 seen = new Set();
|
||||||
const recentRequests = [...history]
|
const recentRequests = [...history]
|
||||||
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
|
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
|
||||||
.map((e) => {
|
.map((e) => {
|
||||||
const t = e.tokens || {};
|
const t = e.tokens || {};
|
||||||
const promptTokens = t.prompt_tokens || t.input_tokens || 0;
|
|
||||||
const completionTokens = t.completion_tokens || t.output_tokens || 0;
|
|
||||||
return {
|
return {
|
||||||
timestamp: e.timestamp,
|
timestamp: e.timestamp, model: e.model, provider: e.provider || "",
|
||||||
model: e.model,
|
promptTokens: t.prompt_tokens || t.input_tokens || 0,
|
||||||
provider: e.provider || "",
|
completionTokens: t.completion_tokens || t.output_tokens || 0,
|
||||||
promptTokens,
|
|
||||||
completionTokens,
|
|
||||||
status: e.status || "ok",
|
status: e.status || "ok",
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((e) => {
|
.filter((e) => {
|
||||||
if (e.promptTokens === 0 && e.completionTokens === 0) return false;
|
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 minute = e.timestamp ? e.timestamp.slice(0, 16) : "";
|
||||||
const key = `${e.model}|${e.provider}|${e.promptTokens}|${e.completionTokens}|${minute}`;
|
const key = `${e.model}|${e.provider}|${e.promptTokens}|${e.completionTokens}|${minute}`;
|
||||||
if (seen.has(key)) return false;
|
if (seen.has(key)) return false;
|
||||||
@@ -557,14 +598,8 @@ export async function getUsageStats(period = "all") {
|
|||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
totalRequests: lifetimeTotalRequests,
|
totalRequests: lifetimeTotalRequests,
|
||||||
totalPromptTokens: 0,
|
totalPromptTokens: 0, totalCompletionTokens: 0, totalCost: 0,
|
||||||
totalCompletionTokens: 0,
|
byProvider: {}, byModel: {}, byAccount: {}, byApiKey: {}, byEndpoint: {},
|
||||||
totalCost: 0,
|
|
||||||
byProvider: {},
|
|
||||||
byModel: {},
|
|
||||||
byAccount: {},
|
|
||||||
byApiKey: {},
|
|
||||||
byEndpoint: {},
|
|
||||||
last10Minutes: [],
|
last10Minutes: [],
|
||||||
pending: pendingRequests,
|
pending: pendingRequests,
|
||||||
activeRequests: [],
|
activeRequests: [],
|
||||||
@@ -572,218 +607,221 @@ export async function getUsageStats(period = "all") {
|
|||||||
errorProvider: (Date.now() - lastErrorProvider.ts < 10000) ? lastErrorProvider.provider : "",
|
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 [connectionId, models] of Object.entries(pendingRequests.byAccount)) {
|
||||||
for (const [modelKey, count] of Object.entries(models)) {
|
for (const [modelKey, count] of Object.entries(models)) {
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
const accountName = connectionMap[connectionId] || `Account ${connectionId.slice(0, 8)}...`;
|
const accountName = connectionMap[connectionId] || `Account ${connectionId.slice(0, 8)}...`;
|
||||||
// modelKey is "model (provider)"
|
|
||||||
const match = modelKey.match(/^(.*) \((.*)\)$/);
|
const match = modelKey.match(/^(.*) \((.*)\)$/);
|
||||||
const modelName = match ? match[1] : modelKey;
|
|
||||||
const providerName = match ? match[2] : "unknown";
|
|
||||||
|
|
||||||
stats.activeRequests.push({
|
stats.activeRequests.push({
|
||||||
model: modelName,
|
model: match ? match[1] : modelKey,
|
||||||
provider: providerName,
|
provider: match ? match[2] : "unknown",
|
||||||
account: accountName,
|
account: accountName, count,
|
||||||
count
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize 10-minute buckets using stable minute boundaries
|
// last10Minutes — always from live history
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
// Floor to the start of the current minute
|
|
||||||
const currentMinuteStart = new Date(Math.floor(now.getTime() / 60000) * 60000);
|
const currentMinuteStart = new Date(Math.floor(now.getTime() / 60000) * 60000);
|
||||||
const tenMinutesAgo = new Date(currentMinuteStart.getTime() - 9 * 60 * 1000);
|
const tenMinutesAgo = new Date(currentMinuteStart.getTime() - 9 * 60 * 1000);
|
||||||
|
|
||||||
// Create buckets keyed by minute timestamp for stable lookups
|
|
||||||
const bucketMap = {};
|
const bucketMap = {};
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
const bucketTime = new Date(currentMinuteStart.getTime() - (9 - i) * 60 * 1000);
|
const bucketKey = currentMinuteStart.getTime() - (9 - i) * 60 * 1000;
|
||||||
const bucketKey = bucketTime.getTime();
|
bucketMap[bucketKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0 };
|
||||||
bucketMap[bucketKey] = {
|
|
||||||
requests: 0,
|
|
||||||
promptTokens: 0,
|
|
||||||
completionTokens: 0,
|
|
||||||
cost: 0
|
|
||||||
};
|
|
||||||
stats.last10Minutes.push(bucketMap[bucketKey]);
|
stats.last10Minutes.push(bucketMap[bucketKey]);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of history) {
|
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);
|
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) {
|
if (entryTime >= tenMinutesAgo && entryTime <= now) {
|
||||||
const entryMinuteStart = Math.floor(entryTime.getTime() / 60000) * 60000;
|
const entryMinuteStart = Math.floor(entryTime.getTime() / 60000) * 60000;
|
||||||
if (bucketMap[entryMinuteStart]) {
|
if (bucketMap[entryMinuteStart]) {
|
||||||
|
const pt = entry.tokens?.prompt_tokens || 0;
|
||||||
|
const ct = entry.tokens?.completion_tokens || 0;
|
||||||
bucketMap[entryMinuteStart].requests++;
|
bucketMap[entryMinuteStart].requests++;
|
||||||
bucketMap[entryMinuteStart].promptTokens += promptTokens;
|
bucketMap[entryMinuteStart].promptTokens += pt;
|
||||||
bucketMap[entryMinuteStart].completionTokens += completionTokens;
|
bucketMap[entryMinuteStart].completionTokens += ct;
|
||||||
bucketMap[entryMinuteStart].cost += entryCost;
|
bucketMap[entryMinuteStart].cost += entry.cost || 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// By Provider
|
// Determine if we use dailySummary (7d/30d/60d/all) or live history (24h)
|
||||||
if (!stats.byProvider[entry.provider]) {
|
const useDailySummary = period !== "24h";
|
||||||
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;
|
|
||||||
|
|
||||||
// By Model
|
if (useDailySummary) {
|
||||||
// Format: "modelName (provider)" if provider is known
|
// Collect relevant date keys
|
||||||
const modelKey = entry.provider ? `${entry.model} (${entry.provider})` : entry.model;
|
const periodDays = { "7d": 7, "30d": 30, "60d": 60 };
|
||||||
// Resolve friendly name for compatible providers
|
const maxDays = periodDays[period] || null; // null = all
|
||||||
const providerDisplayName = providerNodeNameMap[entry.provider] || entry.provider;
|
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]) {
|
for (const dateKey of dateKeys) {
|
||||||
stats.byModel[modelKey] = {
|
const day = dailySummary[dateKey];
|
||||||
requests: 0,
|
stats.totalPromptTokens += day.promptTokens || 0;
|
||||||
promptTokens: 0,
|
stats.totalCompletionTokens += day.completionTokens || 0;
|
||||||
completionTokens: 0,
|
stats.totalCost += day.cost || 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// By Account (model + oauth account)
|
// Merge byProvider
|
||||||
// Use connectionId if available, otherwise fallback to provider name
|
for (const [prov, pData] of Object.entries(day.byProvider || {})) {
|
||||||
if (entry.connectionId) {
|
if (!stats.byProvider[prov]) stats.byProvider[prov] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0 };
|
||||||
const accountName = connectionMap[entry.connectionId] || `Account ${entry.connectionId.slice(0, 8)}...`;
|
stats.byProvider[prov].requests += pData.requests || 0;
|
||||||
const accountKey = `${entry.model} (${entry.provider} - ${accountName})`;
|
stats.byProvider[prov].promptTokens += pData.promptTokens || 0;
|
||||||
|
stats.byProvider[prov].completionTokens += pData.completionTokens || 0;
|
||||||
if (!stats.byAccount[accountKey]) {
|
stats.byProvider[prov].cost += pData.cost || 0;
|
||||||
stats.byAccount[accountKey] = {
|
|
||||||
requests: 0,
|
|
||||||
promptTokens: 0,
|
|
||||||
completionTokens: 0,
|
|
||||||
cost: 0,
|
|
||||||
rawModel: entry.model,
|
|
||||||
provider: providerDisplayName,
|
|
||||||
connectionId: entry.connectionId,
|
|
||||||
accountName: accountName,
|
|
||||||
lastUsed: entry.timestamp
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
stats.byAccount[accountKey].requests++;
|
|
||||||
stats.byAccount[accountKey].promptTokens += promptTokens;
|
// Merge byModel (dailySummary key: "model|provider" → stats key: "model (provider)")
|
||||||
stats.byAccount[accountKey].completionTokens += completionTokens;
|
for (const [mk, mData] of Object.entries(day.byModel || {})) {
|
||||||
stats.byAccount[accountKey].cost += entryCost;
|
const rawModel = mData.rawModel || mk.split("|")[0];
|
||||||
if (new Date(entry.timestamp) > new Date(stats.byAccount[accountKey].lastUsed)) {
|
const provider = mData.provider || mk.split("|")[1] || "";
|
||||||
stats.byAccount[accountKey].lastUsed = entry.timestamp;
|
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
|
for (const entry of filtered) {
|
||||||
if (entry.apiKey && typeof entry.apiKey === "string") {
|
const promptTokens = entry.tokens?.prompt_tokens || 0;
|
||||||
const keyInfo = apiKeyMap[entry.apiKey];
|
const completionTokens = entry.tokens?.completion_tokens || 0;
|
||||||
const keyName = keyInfo?.name || entry.apiKey.slice(0, 8) + "...";
|
const entryCost = entry.cost || 0;
|
||||||
// Use full API key to avoid collisions (keys with same prefix)
|
const providerDisplayName = providerNodeNameMap[entry.provider] || entry.provider;
|
||||||
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'}`;
|
|
||||||
|
|
||||||
if (!stats.byApiKey[apiKeyModelKey]) {
|
stats.totalPromptTokens += promptTokens;
|
||||||
stats.byApiKey[apiKeyModelKey] = {
|
stats.totalCompletionTokens += completionTokens;
|
||||||
requests: 0,
|
stats.totalCost += entryCost;
|
||||||
promptTokens: 0,
|
|
||||||
completionTokens: 0,
|
// byProvider
|
||||||
cost: 0,
|
if (!stats.byProvider[entry.provider]) stats.byProvider[entry.provider] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0 };
|
||||||
rawModel: entry.model,
|
stats.byProvider[entry.provider].requests++;
|
||||||
provider: providerDisplayName,
|
stats.byProvider[entry.provider].promptTokens += promptTokens;
|
||||||
apiKey: entry.apiKey,
|
stats.byProvider[entry.provider].completionTokens += completionTokens;
|
||||||
keyName: keyName,
|
stats.byProvider[entry.provider].cost += entryCost;
|
||||||
apiKeyKey: apiKeyKey,
|
|
||||||
lastUsed: entry.timestamp
|
// 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];
|
stats.byModel[modelKey].requests++;
|
||||||
apiKeyEntry.requests++;
|
stats.byModel[modelKey].promptTokens += promptTokens;
|
||||||
apiKeyEntry.promptTokens += promptTokens;
|
stats.byModel[modelKey].completionTokens += completionTokens;
|
||||||
apiKeyEntry.completionTokens += completionTokens;
|
stats.byModel[modelKey].cost += entryCost;
|
||||||
apiKeyEntry.cost += entryCost;
|
if (new Date(entry.timestamp) > new Date(stats.byModel[modelKey].lastUsed)) stats.byModel[modelKey].lastUsed = entry.timestamp;
|
||||||
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)";
|
|
||||||
|
|
||||||
if (!stats.byApiKey[apiKeyKey]) {
|
// byAccount
|
||||||
stats.byApiKey[apiKeyKey] = {
|
if (entry.connectionId) {
|
||||||
requests: 0,
|
const accountName = connectionMap[entry.connectionId] || `Account ${entry.connectionId.slice(0, 8)}...`;
|
||||||
promptTokens: 0,
|
const accountKey = `${entry.model} (${entry.provider} - ${accountName})`;
|
||||||
completionTokens: 0,
|
if (!stats.byAccount[accountKey]) {
|
||||||
cost: 0,
|
stats.byAccount[accountKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel: entry.model, provider: providerDisplayName, connectionId: entry.connectionId, accountName, lastUsed: entry.timestamp };
|
||||||
rawModel: entry.model,
|
}
|
||||||
provider: providerDisplayName,
|
stats.byAccount[accountKey].requests++;
|
||||||
apiKey: null,
|
stats.byAccount[accountKey].promptTokens += promptTokens;
|
||||||
keyName: keyName,
|
stats.byAccount[accountKey].completionTokens += completionTokens;
|
||||||
apiKeyKey: apiKeyKey,
|
stats.byAccount[accountKey].cost += entryCost;
|
||||||
lastUsed: entry.timestamp
|
if (new Date(entry.timestamp) > new Date(stats.byAccount[accountKey].lastUsed)) stats.byAccount[accountKey].lastUsed = entry.timestamp;
|
||||||
};
|
|
||||||
}
|
}
|
||||||
const apiKeyEntry = stats.byApiKey[apiKeyKey];
|
|
||||||
apiKeyEntry.requests++;
|
// byApiKey
|
||||||
apiKeyEntry.promptTokens += promptTokens;
|
if (entry.apiKey && typeof entry.apiKey === "string") {
|
||||||
apiKeyEntry.completionTokens += completionTokens;
|
const keyInfo = apiKeyMap[entry.apiKey];
|
||||||
apiKeyEntry.cost += entryCost;
|
const keyName = keyInfo?.name || entry.apiKey.slice(0, 8) + "...";
|
||||||
if (new Date(entry.timestamp) > new Date(apiKeyEntry.lastUsed)) {
|
const apiKeyModelKey = `${entry.apiKey}|${entry.model}|${entry.provider || "unknown"}`;
|
||||||
apiKeyEntry.lastUsed = entry.timestamp;
|
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)
|
// byEndpoint
|
||||||
const endpoint = entry.endpoint || "Unknown";
|
const endpoint = entry.endpoint || "Unknown";
|
||||||
const endpointModelKey = `${endpoint}|${entry.model}|${entry.provider || 'unknown'}`;
|
const endpointModelKey = `${endpoint}|${entry.model}|${entry.provider || "unknown"}`;
|
||||||
|
if (!stats.byEndpoint[endpointModelKey]) {
|
||||||
if (!stats.byEndpoint[endpointModelKey]) {
|
stats.byEndpoint[endpointModelKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, endpoint, rawModel: entry.model, provider: providerDisplayName, lastUsed: entry.timestamp };
|
||||||
stats.byEndpoint[endpointModelKey] = {
|
}
|
||||||
requests: 0,
|
const epe = stats.byEndpoint[endpointModelKey];
|
||||||
promptTokens: 0,
|
epe.requests++; epe.promptTokens += promptTokens; epe.completionTokens += completionTokens; epe.cost += entryCost;
|
||||||
completionTokens: 0,
|
if (new Date(entry.timestamp) > new Date(epe.lastUsed)) epe.lastUsed = entry.timestamp;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -798,45 +836,48 @@ export async function getUsageStats(period = "all") {
|
|||||||
export async function getChartData(period = "7d") {
|
export async function getChartData(period = "7d") {
|
||||||
const db = await getUsageDb();
|
const db = await getUsageDb();
|
||||||
const history = db.data.history || [];
|
const history = db.data.history || [];
|
||||||
|
const dailySummary = db.data.dailySummary || {};
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
let bucketCount, bucketMs, labelFn;
|
// 24h: bucket by hour from live history
|
||||||
if (period === "24h") {
|
if (period === "24h") {
|
||||||
bucketCount = 24;
|
const bucketCount = 24;
|
||||||
bucketMs = 3600000; // 1 hour
|
const bucketMs = 3600000;
|
||||||
labelFn = (ts) => new Date(ts).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
|
const labelFn = (ts) => new Date(ts).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||||
} else if (period === "7d") {
|
const startTime = now - bucketCount * bucketMs;
|
||||||
bucketCount = 7;
|
const buckets = Array.from({ length: bucketCount }, (_, i) => {
|
||||||
bucketMs = 86400000;
|
const ts = startTime + i * bucketMs;
|
||||||
labelFn = (ts) => new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
return { label: labelFn(ts), tokens: 0, cost: 0 };
|
||||||
} else if (period === "30d") {
|
});
|
||||||
bucketCount = 30;
|
|
||||||
bucketMs = 86400000;
|
for (const entry of history) {
|
||||||
labelFn = (ts) => new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
const entryTime = new Date(entry.timestamp).getTime();
|
||||||
} else {
|
if (entryTime < startTime || entryTime > now) continue;
|
||||||
bucketCount = 60;
|
const idx = Math.min(Math.floor((entryTime - startTime) / bucketMs), bucketCount - 1);
|
||||||
bucketMs = 86400000;
|
buckets[idx].tokens += (entry.tokens?.prompt_tokens || 0) + (entry.tokens?.completion_tokens || 0);
|
||||||
labelFn = (ts) => new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
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 buckets = Array.from({ length: bucketCount }, (_, i) => {
|
||||||
const ts = startTime + i * bucketMs;
|
const d = new Date(today);
|
||||||
return { label: labelFn(ts), tokens: 0, cost: 0, _ts: ts };
|
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) {
|
return buckets;
|
||||||
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 }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export request details functions from new SQLite-based module
|
// Re-export request details functions from new SQLite-based module
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { MEDIA_PROVIDER_KINDS } from "@/shared/constants/providers";
|
|||||||
import Button from "./Button";
|
import Button from "./Button";
|
||||||
import { ConfirmModal } from "./Modal";
|
import { ConfirmModal } from "./Modal";
|
||||||
|
|
||||||
|
// const VISIBLE_MEDIA_KINDS = ["embedding", "image", "imageToText", "tts", "stt", "webSearch", "webFetch", "video", "music"];
|
||||||
const VISIBLE_MEDIA_KINDS = ["embedding", "tts"];
|
const VISIBLE_MEDIA_KINDS = ["embedding", "tts"];
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
|
|||||||
@@ -9,16 +9,16 @@ export const FREE_PROVIDERS = {
|
|||||||
// codebuddy: { id: "codebuddy", alias: "cb", name: "CodeBuddy", icon: "smart_toy", color: "#006EFF" },
|
// 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" },
|
// 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" },
|
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)
|
// Free Tier Providers (has free access but may require account/API key)
|
||||||
export const FREE_TIER_PROVIDERS = {
|
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" } },
|
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" } },
|
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" } },
|
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
|
// Thinking config definitions
|
||||||
@@ -54,20 +54,20 @@ export const OAUTH_PROVIDERS = {
|
|||||||
export const APIKEY_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: { 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" },
|
"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" },
|
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" },
|
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" },
|
"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: { 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" },
|
"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 },
|
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"] },
|
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" },
|
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" },
|
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" },
|
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" },
|
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" },
|
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" },
|
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" },
|
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" },
|
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" },
|
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" },
|
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" },
|
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"] },
|
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"] },
|
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"] },
|
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 },
|
"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"] },
|
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"] },
|
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" },
|
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" },
|
"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" },
|
"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
|
// Media provider kinds — each kind maps to a route and endpoint config
|
||||||
export const MEDIA_PROVIDER_KINDS = [
|
export const MEDIA_PROVIDER_KINDS = [
|
||||||
{ id: "embedding", label: "Embedding", icon: "data_array", endpoint: { method: "POST", path: "/v1/embeddings" } },
|
{ 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: "image", label: "Text to Image", icon: "brush", 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: "imageToText", label: "Image to Text", icon: "image_search", endpoint: { method: "POST", path: "/v1/images/understanding" } },
|
||||||
{ id: "stt", label: "STT", icon: "mic", endpoint: { method: "POST", path: "/v1/audio/transcriptions" } },
|
{ id: "tts", label: "Text To Speech", icon: "record_voice_over", endpoint: { method: "POST", path: "/v1/audio/speech" } },
|
||||||
{ id: "video", label: "Video", icon: "movie", endpoint: { method: "POST", path: "/v1/video/generations" } },
|
{ id: "stt", label: "STT", icon: "mic", endpoint: { method: "POST", path: "/v1/audio/transcriptions" } },
|
||||||
{ id: "music", label: "Music", icon: "music_note", endpoint: { method: "POST", path: "/v1/audio/music" } },
|
{ 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-";
|
export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
|
||||||
|
|||||||
@@ -13,9 +13,19 @@ export const TTS_PROVIDER_CONFIG = {
|
|||||||
hasLanguageDropdown: false,
|
hasLanguageDropdown: false,
|
||||||
hasModelSelector: true,
|
hasModelSelector: true,
|
||||||
hasBrowseButton: false,
|
hasBrowseButton: false,
|
||||||
voiceSource: "hardcoded", // from providerModels
|
voiceSource: "hardcoded",
|
||||||
modelKey: "openai-tts-models",
|
modelKey: "openai-tts-models",
|
||||||
voiceKey: "openai-tts-voices",
|
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": {
|
"elevenlabs": {
|
||||||
hasLanguageDropdown: false,
|
hasLanguageDropdown: false,
|
||||||
|
|||||||
@@ -1,28 +1,14 @@
|
|||||||
// Fetch and cache suggested models for providers that expose a public models API
|
// 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_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
const cache = new Map(); // key: fetcher.url → { data, expiresAt }
|
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.
|
* Fetch suggested models for a provider using its modelsFetcher config.
|
||||||
* Results are cached in-memory for CACHE_TTL_MS.
|
* Results are cached in-memory for CACHE_TTL_MS.
|
||||||
* @param {{ url: string, type: string }} fetcher
|
* @param {{ url: string, type: string }} fetcher
|
||||||
* @returns {Promise<Array<{ id: string, name: string, contextLength: number }>>}
|
* @returns {Promise<Array<{ id: string, name: string, contextLength?: number }>>}
|
||||||
*/
|
*/
|
||||||
export async function fetchSuggestedModels(fetcher) {
|
export async function fetchSuggestedModels(fetcher) {
|
||||||
if (!fetcher?.url || !fetcher?.type) return [];
|
if (!fetcher?.url || !fetcher?.type) return [];
|
||||||
@@ -31,12 +17,11 @@ export async function fetchSuggestedModels(fetcher) {
|
|||||||
if (cached && Date.now() < cached.expiresAt) return cached.data;
|
if (cached && Date.now() < cached.expiresAt) return cached.data;
|
||||||
|
|
||||||
try {
|
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 [];
|
if (!res.ok) return [];
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
const raw = json.data ?? json.models ?? json;
|
const data = json.data ?? [];
|
||||||
const filter = FILTERS[fetcher.type];
|
|
||||||
const data = filter ? filter(Array.isArray(raw) ? raw : []) : [];
|
|
||||||
cache.set(fetcher.url, { data, expiresAt: Date.now() + CACHE_TTL_MS });
|
cache.set(fetcher.url, { data, expiresAt: Date.now() + CACHE_TTL_MS });
|
||||||
return data;
|
return data;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
|
|||||||
import * as log from "../utils/logger.js";
|
import * as log from "../utils/logger.js";
|
||||||
|
|
||||||
// Providers that require stored credentials (not noAuth)
|
// 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) {
|
export async function handleTts(request) {
|
||||||
let body;
|
let body;
|
||||||
|
|||||||
Reference in New Issue
Block a user