diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index 1e991183..a0888ed7 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -156,6 +156,7 @@ export const PROVIDER_MODELS = { { id: "gpt-5.3-codex", name: "GPT 5.3 Codex" }, ], kmc: [ // Kimi Coding + { id: "kimi-k2.6", name: "Kimi K2.6" }, { id: "kimi-k2.5", name: "Kimi K2.5" }, { id: "kimi-k2.5-thinking", name: "Kimi K2.5 Thinking" }, { id: "kimi-latest", name: "Kimi Latest" }, @@ -233,6 +234,10 @@ export const PROVIDER_MODELS = { { id: "tts-1", name: "TTS-1", type: "tts" }, { id: "tts-1-hd", name: "TTS-1 HD", type: "tts" }, { id: "gpt-4o-mini-tts", name: "GPT-4o Mini TTS", type: "tts" }, + // STT models + { id: "whisper-1", name: "Whisper 1", type: "stt", params: ["language", "response_format", "temperature", "prompt"] }, + { id: "gpt-4o-transcribe", name: "GPT-4o Transcribe", type: "stt", params: ["language", "response_format", "temperature", "prompt"] }, + { id: "gpt-4o-mini-transcribe", name: "GPT-4o Mini Transcribe", type: "stt", params: ["language", "response_format", "temperature", "prompt"] }, // Image models { id: "gpt-image-1", name: "GPT Image 1", type: "image", params: ["n", "size", "quality", "response_format"] }, { id: "dall-e-3", name: "DALL-E 3", type: "image", params: ["size", "quality", "style", "response_format"] }, @@ -267,6 +272,11 @@ export const PROVIDER_MODELS = { { id: "gemini-3.1-flash-image-preview", name: "Gemini 3.1 Flash Image (Nano Banana 2)", type: "image", params: [] }, { id: "gemini-3-pro-image-preview", name: "Gemini 3 Pro Image (Nano Banana Pro)", type: "image", params: [] }, { id: "gemini-2.5-flash-image", name: "Gemini 2.5 Flash Image (Nano Banana)", type: "image", params: [] }, + // STT models (multimodal generateContent) + { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro (Best)", type: "stt", params: ["language", "prompt"] }, + { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", type: "stt", params: ["language", "prompt"] }, + { id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite (Cheapest)", type: "stt", params: ["language", "prompt"] }, + { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash", type: "stt", params: ["language", "prompt"] }, ], openrouter: [ // Embedding models @@ -301,6 +311,7 @@ export const PROVIDER_MODELS = { { id: "glm-4.5-air", name: "GLM-4.5-Air" }, ], kimi: [ + { id: "kimi-k2.6", name: "Kimi K2.6" }, { id: "kimi-k2.5", name: "Kimi K2.5" }, { id: "kimi-k2.5-thinking", name: "Kimi K2.5 Thinking" }, { id: "kimi-latest", name: "Kimi Latest" }, @@ -402,6 +413,10 @@ export const PROVIDER_MODELS = { { id: "meta-llama/llama-4-maverick-17b-128e-instruct", name: "Llama 4 Maverick" }, { id: "qwen/qwen3-32b", name: "Qwen3 32B" }, { id: "openai/gpt-oss-120b", name: "GPT-OSS 120B" }, + // STT models + { id: "whisper-large-v3", name: "Whisper Large v3", type: "stt", params: ["language", "response_format", "temperature", "prompt"] }, + { id: "whisper-large-v3-turbo", name: "Whisper Large v3 Turbo", type: "stt", params: ["language", "response_format", "temperature", "prompt"] }, + { id: "distil-whisper-large-v3-en", name: "Distil Whisper Large v3 EN", type: "stt", params: ["language", "response_format", "temperature", "prompt"] }, ], xai: [ { id: "grok-4", name: "Grok 4" }, @@ -450,6 +465,8 @@ export const PROVIDER_MODELS = { { id: "minimaxai/minimax-m2.7", name: "Minimax M2.7" }, { id: "z-ai/glm4.7", name: "GLM 4.7" }, { id: "nvidia/nv-embedqa-e5-v5", name: "NV EmbedQA E5 v5", type: "embedding" }, + // STT models + { id: "nvidia/parakeet-ctc-1.1b-asr", name: "Parakeet CTC 1.1B", type: "stt", params: ["language"] }, ], nebius: [ { id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B Instruct" }, @@ -555,6 +572,18 @@ export const PROVIDER_MODELS = { huggingface: [ { id: "black-forest-labs/FLUX.1-schnell", name: "FLUX.1 Schnell", type: "image", params: [] }, { id: "stabilityai/stable-diffusion-xl-base-1.0", name: "SDXL Base 1.0", type: "image", params: [] }, + // STT models + { id: "openai/whisper-large-v3", name: "Whisper Large v3 (HF)", type: "stt", params: ["language"] }, + { id: "openai/whisper-small", name: "Whisper Small (HF)", type: "stt", params: ["language"] }, + ], + deepgram: [ + { id: "nova-3", name: "Nova 3", type: "stt", params: ["language"] }, + { id: "nova-2", name: "Nova 2", type: "stt", params: ["language"] }, + { id: "whisper-large", name: "Whisper Large", type: "stt", params: ["language"] }, + ], + assemblyai: [ + { id: "universal-3-pro", name: "Universal 3 Pro", type: "stt", params: ["language"] }, + { id: "universal-2", name: "Universal 2", type: "stt", params: ["language"] }, ], "fal-ai": [ { id: "fal-ai/flux/schnell", name: "FLUX Schnell", type: "image", params: ["n", "size"] }, diff --git a/open-sse/config/ttsModels.js b/open-sse/config/ttsModels.js index b9dd539b..78ae7fbe 100644 --- a/open-sse/config/ttsModels.js +++ b/open-sse/config/ttsModels.js @@ -24,6 +24,15 @@ const VOICES_STANDARD = v("alloy", "ash", "coral", "echo", "fable", "nova", "ony // 13 voices for gpt-4o-mini-tts const VOICES_FULL = v("alloy", "ash", "ballad", "cedar", "coral", "echo", "fable", "marin", "nova", "onyx", "sage", "shimmer", "verse"); +// Gemini prebuilt voices (30 voices, multi-language auto-detect) +const GEMINI_VOICES = [ + "Zephyr", "Puck", "Charon", "Kore", "Fenrir", "Leda", "Orus", "Aoede", + "Callirrhoe", "Autonoe", "Enceladus", "Iapetus", "Umbriel", "Algieba", + "Despina", "Erinome", "Algenib", "Rasalgethi", "Laomedeia", "Achernar", + "Alnilam", "Schedar", "Gacrux", "Pulcherrima", "Achird", "Zubenelgenubi", + "Vindemiatrix", "Sadachbia", "Sadaltager", "Sulafat", +].map((id) => ({ id, name: id, type: "tts" })); + // ── TTS Config (config-driven, single source of truth) ───────────────────── export const TTS_MODELS_CONFIG = { openai: { @@ -85,6 +94,17 @@ export const TTS_MODELS_CONFIG = { "google-tts": { defaults: GOOGLE_TTS_LANGUAGES, }, + gemini: { + models: [ + { id: "gemini-2.5-flash-preview-tts", name: "Gemini 2.5 Flash TTS", type: "tts" }, + { id: "gemini-2.5-pro-preview-tts", name: "Gemini 2.5 Pro TTS", type: "tts" }, + ], + voices: { + "gemini-2.5-flash-preview-tts": GEMINI_VOICES, + "gemini-2.5-pro-preview-tts": GEMINI_VOICES, + }, + allVoices: GEMINI_VOICES, + }, }; // ── Helper: get voices for a specific model ──────────────────────────────── diff --git a/open-sse/handlers/sttCore.js b/open-sse/handlers/sttCore.js new file mode 100644 index 00000000..cfce0580 --- /dev/null +++ b/open-sse/handlers/sttCore.js @@ -0,0 +1,194 @@ +import { Buffer } from "node:buffer"; +import { createErrorResult } from "../utils/error.js"; +import { HTTP_STATUS } from "../config/runtimeConfig.js"; +import { AI_PROVIDERS } from "../../src/shared/constants/providers.js"; + +// Build auth headers from sttConfig + token +function buildAuthHeaders(cfg, token) { + if (!token) return {}; + switch (cfg.authHeader) { + case "bearer": return { "Authorization": `Bearer ${token}` }; + case "token": return { "Authorization": `Token ${token}` }; + case "x-api-key": return { "x-api-key": token }; + case "key": return { "Authorization": `Key ${token}` }; + default: return { "Authorization": `Bearer ${token}` }; + } +} + +// Map browser file MIME / ext → audio MIME for binary formats (deepgram/HF) +function resolveAudioContentType(file) { + const t = (file.type || "").toLowerCase(); + if (t.startsWith("audio/")) return t; + const name = typeof file.name === "string" ? file.name.toLowerCase() : ""; + const ext = name.includes(".") ? name.split(".").pop() : ""; + const map = { mp3: "audio/mpeg", mp4: "audio/mp4", m4a: "audio/mp4", wav: "audio/wav", ogg: "audio/ogg", flac: "audio/flac", webm: "audio/webm", aac: "audio/aac", opus: "audio/opus" }; + return map[ext] || "application/octet-stream"; +} + +async function upstreamError(res) { + let txt = ""; + try { txt = await res.text(); } catch {} + let msg = txt || `Upstream error (${res.status})`; + try { const j = JSON.parse(txt); msg = j?.error?.message || j?.error || j?.message || msg; } catch {} + return createErrorResult(res.status, typeof msg === "string" ? msg : JSON.stringify(msg)); +} + +// Deepgram: raw binary POST + model query param +async function transcribeDeepgram(cfg, file, model, token, formData) { + const url = new URL(cfg.baseUrl); + url.searchParams.set("model", model); + url.searchParams.set("smart_format", "true"); + url.searchParams.set("punctuate", "true"); + const lang = formData.get("language"); + if (typeof lang === "string" && lang.trim()) url.searchParams.set("language", lang.trim()); + else url.searchParams.set("detect_language", "true"); + + const buf = await file.arrayBuffer(); + const res = await fetch(url, { + method: "POST", + headers: { ...buildAuthHeaders(cfg, token), "Content-Type": resolveAudioContentType(file) }, + body: buf, + }); + if (!res.ok) return upstreamError(res); + const data = await res.json(); + const text = data.results?.channels?.[0]?.alternatives?.[0]?.transcript ?? ""; + return jsonResponse({ text }); +} + +// AssemblyAI: upload → submit → poll (max 120s) +async function transcribeAssemblyAI(cfg, file, model, token) { + const auth = buildAuthHeaders(cfg, token); + const buf = await file.arrayBuffer(); + const up = await fetch("https://api.assemblyai.com/v2/upload", { + method: "POST", headers: { ...auth, "Content-Type": "application/octet-stream" }, body: buf, + }); + if (!up.ok) return upstreamError(up); + const { upload_url } = await up.json(); + + const sub = await fetch(cfg.baseUrl, { + method: "POST", + headers: { ...auth, "Content-Type": "application/json" }, + body: JSON.stringify({ audio_url: upload_url, speech_models: [model], language_detection: true }), + }); + if (!sub.ok) return upstreamError(sub); + const { id } = await sub.json(); + + const start = Date.now(); + while (Date.now() - start < 120_000) { + await new Promise((r) => setTimeout(r, 2000)); + const poll = await fetch(`${cfg.baseUrl}/${id}`, { headers: auth }); + if (!poll.ok) continue; + const r = await poll.json(); + if (r.status === "completed") return jsonResponse({ text: r.text || "" }); + if (r.status === "error") return createErrorResult(500, r.error || "AssemblyAI failed"); + } + return createErrorResult(504, "AssemblyAI timeout after 120s"); +} + +// Nvidia NIM: multipart, normalize response +async function transcribeNvidia(cfg, file, model, token) { + const fd = new FormData(); + fd.append("file", file, file.name || "audio.wav"); + fd.append("model", model); + const res = await fetch(cfg.baseUrl, { method: "POST", headers: buildAuthHeaders(cfg, token), body: fd }); + if (!res.ok) return upstreamError(res); + const data = await res.json(); + return jsonResponse({ text: data.text || data.transcript || "" }); +} + +// Gemini: generateContent with inline_data audio + transcription prompt +async function transcribeGemini(cfg, file, model, token, formData) { + const buf = await file.arrayBuffer(); + const b64 = Buffer.from(buf).toString("base64"); + const mime = resolveAudioContentType(file); + const lang = formData.get("language"); + const userPrompt = formData.get("prompt"); + let promptText = userPrompt && typeof userPrompt === "string" && userPrompt.trim() + ? userPrompt.trim() + : "Generate a transcript of the speech. Return only the transcribed text, no commentary."; + if (typeof lang === "string" && lang.trim()) promptText += ` Language: ${lang.trim()}.`; + + const url = `${cfg.baseUrl}/${model}:generateContent?key=${token}`; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contents: [{ parts: [{ text: promptText }, { inline_data: { mime_type: mime, data: b64 } }] }], + }), + }); + if (!res.ok) return upstreamError(res); + const data = await res.json(); + const text = data?.candidates?.[0]?.content?.parts?.map((p) => p.text).filter(Boolean).join("") || ""; + return jsonResponse({ text }); +} + +// HuggingFace: POST raw binary to {baseUrl}/{model_id} +async function transcribeHuggingFace(cfg, file, model, token) { + if (model.includes("..") || model.includes("//")) return createErrorResult(400, "Invalid model ID"); + const url = `${cfg.baseUrl.replace(/\/+$/, "")}/${model}`; + const buf = await file.arrayBuffer(); + const res = await fetch(url, { + method: "POST", + headers: { ...buildAuthHeaders(cfg, token), "Content-Type": resolveAudioContentType(file) }, + body: buf, + }); + if (!res.ok) return upstreamError(res); + const data = await res.json(); + return jsonResponse({ text: data.text || "" }); +} + +// Default: OpenAI/Groq/Whisper-compatible multipart +async function transcribeOpenAICompatible(cfg, file, model, token, formData) { + const fd = new FormData(); + fd.append("file", file, file.name || "audio.wav"); + fd.append("model", model); + for (const k of ["language", "prompt", "response_format", "temperature"]) { + const v = formData.get(k); + if (v !== null && v !== undefined && v !== "") fd.append(k, v); + } + const res = await fetch(cfg.baseUrl, { method: "POST", headers: buildAuthHeaders(cfg, token), body: fd }); + if (!res.ok) return upstreamError(res); + const ct = res.headers.get("content-type") || "application/json"; + const txt = await res.text(); + return { success: true, response: new Response(txt, { status: 200, headers: { "Content-Type": ct, "Access-Control-Allow-Origin": "*" } }) }; +} + +function jsonResponse(obj) { + return { + success: true, + response: new Response(JSON.stringify(obj), { + status: 200, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + }), + }; +} + +/** + * STT core handler — dispatch by sttConfig.format. + * @returns {Promise<{success, response, status?, error?}>} + */ +export async function handleSttCore({ provider, model, formData, credentials }) { + const file = formData.get("file"); + if (!file) return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: file"); + + const cfg = AI_PROVIDERS[provider]?.sttConfig; + if (!cfg) return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Provider '${provider}' does not support STT`); + + const token = cfg.authType === "none" ? null : (credentials?.apiKey || credentials?.accessToken); + if (cfg.authType !== "none" && !token) { + return createErrorResult(HTTP_STATUS.UNAUTHORIZED, `No credentials for STT provider: ${provider}`); + } + + try { + switch (cfg.format) { + case "deepgram": return await transcribeDeepgram(cfg, file, model, token, formData); + case "assemblyai": return await transcribeAssemblyAI(cfg, file, model, token); + case "nvidia-asr": return await transcribeNvidia(cfg, file, model, token); + case "huggingface-asr": return await transcribeHuggingFace(cfg, file, model, token); + case "gemini-stt": return await transcribeGemini(cfg, file, model, token, formData); + default: return await transcribeOpenAICompatible(cfg, file, model, token, formData); + } + } catch (err) { + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, err.message || "STT request failed"); + } +} diff --git a/open-sse/handlers/ttsCore.js b/open-sse/handlers/ttsCore.js index ff8c52a9..b4b69eeb 100644 --- a/open-sse/handlers/ttsCore.js +++ b/open-sse/handlers/ttsCore.js @@ -48,16 +48,16 @@ function createTtsResponse(base64Audio, format, responseFormat) { * * @returns {Promise<{success, response, status?, error?}>} */ -export async function handleTtsCore({ provider, model, input, credentials, responseFormat = "mp3" }) { +export async function handleTtsCore({ provider, model, input, credentials, responseFormat = "mp3", language }) { if (!input?.trim()) { return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: input"); } try { - // Special-case adapters (google-tts, edge-tts, local-device, elevenlabs, openai, openrouter) + // Special-case adapters (google-tts, edge-tts, local-device, elevenlabs, openai, openrouter, gemini) const adapter = getTtsAdapter(provider); if (adapter) { - const result = await adapter.synthesize(input.trim(), model, credentials, responseFormat); + const result = await adapter.synthesize(input.trim(), model, credentials, responseFormat, { language }); // Adapter may return a full {success, response} (legacy) or {base64, format} if (result.success !== undefined) return result; return createTtsResponse(result.base64, result.format, responseFormat); diff --git a/open-sse/handlers/ttsProviders/gemini.js b/open-sse/handlers/ttsProviders/gemini.js new file mode 100644 index 00000000..9dff6e11 --- /dev/null +++ b/open-sse/handlers/ttsProviders/gemini.js @@ -0,0 +1,117 @@ +// Gemini TTS — generateContent with AUDIO modality returns PCM L16, wrap as WAV +import { Buffer } from "node:buffer"; + +const DEFAULT_MODEL = "gemini-2.5-flash-preview-tts"; +const DEFAULT_VOICE = "Kore"; +const KNOWN_MODELS = ["gemini-2.5-flash-preview-tts", "gemini-2.5-pro-preview-tts"]; + +// Parse "model/voice" — if input doesn't match a known TTS model, treat it as voice with default model +function parseGeminiModelVoice(input) { + if (!input) return { modelId: DEFAULT_MODEL, voiceId: DEFAULT_VOICE }; + for (const id of KNOWN_MODELS) { + if (input === id) return { modelId: id, voiceId: DEFAULT_VOICE }; + if (input.startsWith(`${id}/`)) return { modelId: id, voiceId: input.slice(id.length + 1) }; + } + return { modelId: DEFAULT_MODEL, voiceId: input }; +} +// Gemini returns PCM 16-bit signed mono @ 24kHz +const SAMPLE_RATE = 24000; +const CHANNELS = 1; +const BITS_PER_SAMPLE = 16; + +// Build WAV header for raw PCM payload +function pcmToWav(pcmBuffer) { + const dataSize = pcmBuffer.length; + const byteRate = SAMPLE_RATE * CHANNELS * BITS_PER_SAMPLE / 8; + const blockAlign = CHANNELS * BITS_PER_SAMPLE / 8; + const header = Buffer.alloc(44); + header.write("RIFF", 0); + header.writeUInt32LE(36 + dataSize, 4); + header.write("WAVE", 8); + header.write("fmt ", 12); + header.writeUInt32LE(16, 16); + header.writeUInt16LE(1, 20); + header.writeUInt16LE(CHANNELS, 22); + header.writeUInt32LE(SAMPLE_RATE, 24); + header.writeUInt32LE(byteRate, 28); + header.writeUInt16LE(blockAlign, 32); + header.writeUInt16LE(BITS_PER_SAMPLE, 34); + header.write("data", 36); + header.writeUInt32LE(dataSize, 40); + return Buffer.concat([header, pcmBuffer]); +} + +// Build TTS prompt: add "Say [in {language}]:" prefix to force TTS mode +function buildPrompt(text, language) { + if (/:\s/.test(text)) return text; // user already provided style instruction + return language ? `Say in ${language}: ${text}` : `Say: ${text}`; +} + +export default { + async synthesize(text, model, credentials, _responseFormat, opts = {}) { + if (!credentials?.apiKey) throw new Error("No Gemini API key configured"); + const { modelId, voiceId } = parseGeminiModelVoice(model); + const url = `https://generativelanguage.googleapis.com/v1beta/models/${modelId}:generateContent?key=${credentials.apiKey}`; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contents: [{ parts: [{ text: buildPrompt(text, opts.language) }] }], + generationConfig: { + responseModalities: ["AUDIO"], + speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: voiceId } } }, + }, + }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err?.error?.message || `Gemini TTS failed: ${res.status}`); + } + const data = await res.json(); + const b64 = data?.candidates?.[0]?.content?.parts?.find((p) => p.inlineData?.data)?.inlineData?.data; + if (!b64) { + const reason = data?.candidates?.[0]?.finishReason || data?.promptFeedback?.blockReason || "unknown"; + throw new Error(`Gemini TTS returned no audio (finishReason: ${reason}, voice: ${voiceId}, model: ${modelId})`); + } + const wav = pcmToWav(Buffer.from(b64, "base64")); + return { base64: wav.toString("base64"), format: "wav" }; + }, +}; + +// Voice fetcher — return prebuilt voices (Gemini has no list API) +const PREBUILT_VOICES = [ + { id: "Zephyr", lang: "en", gender: "Female" }, + { id: "Puck", lang: "en", gender: "Male" }, + { id: "Charon", lang: "en", gender: "Male" }, + { id: "Kore", lang: "en", gender: "Female" }, + { id: "Fenrir", lang: "en", gender: "Male" }, + { id: "Leda", lang: "en", gender: "Female" }, + { id: "Orus", lang: "en", gender: "Male" }, + { id: "Aoede", lang: "en", gender: "Female" }, + { id: "Callirrhoe", lang: "en", gender: "Female" }, + { id: "Autonoe", lang: "en", gender: "Female" }, + { id: "Enceladus", lang: "en", gender: "Male" }, + { id: "Iapetus", lang: "en", gender: "Male" }, + { id: "Umbriel", lang: "en", gender: "Male" }, + { id: "Algieba", lang: "en", gender: "Male" }, + { id: "Despina", lang: "en", gender: "Female" }, + { id: "Erinome", lang: "en", gender: "Female" }, + { id: "Algenib", lang: "en", gender: "Male" }, + { id: "Rasalgethi", lang: "en", gender: "Male" }, + { id: "Laomedeia", lang: "en", gender: "Female" }, + { id: "Achernar", lang: "en", gender: "Female" }, + { id: "Alnilam", lang: "en", gender: "Male" }, + { id: "Schedar", lang: "en", gender: "Male" }, + { id: "Gacrux", lang: "en", gender: "Female" }, + { id: "Pulcherrima", lang: "en", gender: "Female" }, + { id: "Achird", lang: "en", gender: "Male" }, + { id: "Zubenelgenubi", lang: "en", gender: "Male" }, + { id: "Vindemiatrix", lang: "en", gender: "Female" }, + { id: "Sadachbia", lang: "en", gender: "Male" }, + { id: "Sadaltager", lang: "en", gender: "Male" }, + { id: "Sulafat", lang: "en", gender: "Female" }, +]; + +export async function fetchGeminiVoices() { + return PREBUILT_VOICES.map((v) => ({ voice_id: v.id, name: v.id, labels: { language: v.lang, gender: v.gender } })); +} diff --git a/open-sse/handlers/ttsProviders/index.js b/open-sse/handlers/ttsProviders/index.js index 02345a88..f80ddf8b 100644 --- a/open-sse/handlers/ttsProviders/index.js +++ b/open-sse/handlers/ttsProviders/index.js @@ -5,6 +5,7 @@ import localDevice, { fetchLocalDeviceVoices } from "./localDevice.js"; import elevenlabs, { fetchElevenLabsVoices } from "./elevenlabs.js"; import openai from "./openai.js"; import openrouter from "./openrouter.js"; +import gemini, { fetchGeminiVoices } from "./gemini.js"; import { FORMAT_HANDLERS } from "./genericFormats.js"; import { parseModelVoice } from "./_base.js"; @@ -16,6 +17,7 @@ const SPECIAL_ADAPTERS = { elevenlabs, openai, openrouter, + gemini, }; export function getTtsAdapter(provider) { @@ -41,7 +43,8 @@ export const VOICE_FETCHERS = { "edge-tts": fetchEdgeTtsVoices, "local-device": fetchLocalDeviceVoices, elevenlabs: fetchElevenLabsVoices, + gemini: fetchGeminiVoices, }; // Re-export for backward compat -export { fetchEdgeTtsVoices, fetchLocalDeviceVoices, fetchElevenLabsVoices }; +export { fetchEdgeTtsVoices, fetchLocalDeviceVoices, fetchElevenLabsVoices, fetchGeminiVoices }; diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 094aa86e..9bc03251 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -11,6 +11,24 @@ const GITHUB_CONFIG = { userAgent: "GitHubCopilotChat/0.26.7", }; +// GLM quota endpoints (region-aware) +const GLM_QUOTA_URLS = { + international: "https://api.z.ai/api/monitor/usage/quota/limit", + china: "https://open.bigmodel.cn/api/monitor/usage/quota/limit", +}; + +// MiniMax usage endpoints (try in order, fallback on transient errors) +const MINIMAX_USAGE_URLS = { + minimax: [ + "https://www.minimax.io/v1/token_plan/remains", + "https://api.minimax.io/v1/api/openplatform/coding_plan/remains", + ], + "minimax-cn": [ + "https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains", + "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains", + ], +}; + // Antigravity API config (from Quotio) const ANTIGRAVITY_CONFIG = { quotaApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", @@ -40,13 +58,13 @@ const CLAUDE_CONFIG = { * @returns {Object} Usage data with quotas */ export async function getUsageForProvider(connection, proxyOptions = null) { - const { provider, accessToken, providerSpecificData } = connection; + const { provider, accessToken, apiKey, providerSpecificData } = connection; switch (provider) { case "github": return await getGitHubUsage(accessToken, providerSpecificData, proxyOptions); case "gemini-cli": - return await getGeminiUsage(accessToken, proxyOptions); + return await getGeminiUsage(accessToken, providerSpecificData, proxyOptions); case "antigravity": return await getAntigravityUsage(accessToken, providerSpecificData, proxyOptions); case "claude": @@ -61,6 +79,12 @@ export async function getUsageForProvider(connection, proxyOptions = null) { return await getIflowUsage(accessToken); case "ollama": return await getOllamaUsage(accessToken); + case "glm": + case "glm-cn": + return await getGlmUsage(apiKey, provider, proxyOptions); + case "minimax": + case "minimax-cn": + return await getMiniMaxUsage(apiKey, provider, proxyOptions); default: return { message: `Usage API not implemented for ${provider}` }; } @@ -188,31 +212,115 @@ function formatGitHubQuotaSnapshot(quota) { } /** - * Gemini CLI Usage (Google Cloud) + * Gemini CLI Usage — fetch per-model quota via Cloud Code Assist API. + * Uses retrieveUserQuota (same endpoint as `gemini /stats`) returning + * per-model buckets with remainingFraction + resetTime. */ -async function getGeminiUsage(accessToken, proxyOptions = null) { +async function getGeminiUsage(accessToken, providerSpecificData, proxyOptions = null) { + if (!accessToken) { + return { plan: "Free", message: "Gemini CLI access token not available." }; + } + + try { + // Resolve project id: prefer connection-stored id, else loadCodeAssist lookup + let projectId = providerSpecificData?.projectId || null; + let plan = "Free"; + + if (!projectId) { + const subInfo = await getGeminiSubscriptionInfo(accessToken, proxyOptions); + projectId = subInfo?.cloudaicompanionProject || null; + plan = subInfo?.currentTier?.name || plan; + } + + if (!projectId) { + return { plan, message: "Gemini CLI project ID not available." }; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + let response; + try { + response = await proxyAwareFetch( + "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ project: projectId }), + signal: controller.signal, + }, + proxyOptions + ); + } finally { + clearTimeout(timeoutId); + } + + if (!response.ok) { + return { plan, message: `Gemini CLI quota error (${response.status}).` }; + } + + const data = await response.json(); + const quotas = {}; + + if (Array.isArray(data.buckets)) { + for (const bucket of data.buckets) { + if (!bucket.modelId || bucket.remainingFraction == null) continue; + + const remainingFraction = Number(bucket.remainingFraction) || 0; + const total = 1000; // Normalized base, matches antigravity convention + const remaining = Math.round(total * remainingFraction); + const used = Math.max(0, total - remaining); + + quotas[bucket.modelId] = { + used, + total, + resetAt: parseResetTime(bucket.resetTime), + remainingPercentage: remainingFraction * 100, + unlimited: false, + }; + } + } + + return { plan, quotas }; + } catch (error) { + return { message: `Gemini CLI error: ${error.message}` }; + } +} + +/** + * Get Gemini CLI subscription info via loadCodeAssist + */ +async function getGeminiSubscriptionInfo(accessToken, proxyOptions = null) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); try { - // Gemini CLI uses Google Cloud quotas - // Try to get quota info from Cloud Resource Manager const response = await proxyAwareFetch( - "https://cloudresourcemanager.googleapis.com/v1/projects?filter=lifecycleState:ACTIVE", + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", { + method: "POST", headers: { Authorization: `Bearer ${accessToken}`, - Accept: "application/json", + "Content-Type": "application/json", }, + body: JSON.stringify({ + metadata: { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, + }), + signal: controller.signal, }, proxyOptions ); - - if (!response.ok) { - // Quota API may not be accessible, return generic message - return { message: "Gemini CLI uses Google Cloud quotas. Check Google Cloud Console for details." }; - } - - return { message: "Gemini CLI connected. Usage tracked via Google Cloud Console." }; - } catch (error) { - return { message: "Unable to fetch Gemini usage. Check Google Cloud Console." }; + if (!response.ok) return null; + return await response.json(); + } catch { + return null; + } finally { + clearTimeout(timeoutId); } } @@ -798,3 +906,206 @@ async function getOllamaUsage(accessToken, providerSpecificData) { return { message: "Unable to fetch Ollama Cloud usage." }; } } + +/** + * GLM Coding Plan usage (international + China regions) + */ +async function getGlmUsage(apiKey, provider, proxyOptions = null) { + if (!apiKey) { + return { message: "GLM API key not available." }; + } + + const region = provider === "glm-cn" ? "china" : "international"; + const quotaUrl = GLM_QUOTA_URLS[region]; + + try { + const response = await proxyAwareFetch(quotaUrl, { + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + }, proxyOptions); + + if (!response.ok) { + if (response.status === 401) { + return { message: "GLM API key invalid or expired." }; + } + return { message: `GLM quota API error (${response.status}).` }; + } + + const json = await response.json(); + const data = json?.data && typeof json.data === "object" ? json.data : {}; + const limits = Array.isArray(data.limits) ? data.limits : []; + const quotas = {}; + + for (const limit of limits) { + if (!limit || limit.type !== "TOKENS_LIMIT") continue; + const usedPercent = Number(limit.percentage) || 0; + const resetMs = Number(limit.nextResetTime) || 0; + const remaining = Math.max(0, 100 - usedPercent); + + quotas["session"] = { + used: usedPercent, + total: 100, + remaining, + remainingPercentage: remaining, + resetAt: resetMs > 0 ? new Date(resetMs).toISOString() : null, + unlimited: false, + }; + } + + const levelRaw = typeof data.level === "string" ? data.level : ""; + const plan = levelRaw + ? levelRaw.charAt(0).toUpperCase() + levelRaw.slice(1).toLowerCase() + : "Unknown"; + + return { plan, quotas }; + } catch (error) { + return { message: `GLM error: ${error.message}` }; + } +} + +// ── MiniMax helpers ────────────────────────────────────────────────────── +function isMiniMaxTextQuotaModel(modelName) { + const normalized = (modelName || "").trim().toLowerCase(); + return normalized.startsWith("minimax-m") || normalized.startsWith("coding-plan"); +} + +function getMiniMaxField(model, snakeKey, camelKey) { + if (!model || typeof model !== "object") return null; + return model[snakeKey] ?? model[camelKey] ?? null; +} + +function getMiniMaxSessionTotal(model) { + return Math.max(0, Number(getMiniMaxField(model, "current_interval_total_count", "currentIntervalTotalCount")) || 0); +} + +function getMiniMaxWeeklyTotal(model) { + return Math.max(0, Number(getMiniMaxField(model, "current_weekly_total_count", "currentWeeklyTotalCount")) || 0); +} + +function pickMiniMaxRepresentativeModel(models, getTotal) { + const withQuota = models.filter((m) => getTotal(m) > 0); + const pool = withQuota.length > 0 ? withQuota : models; + if (pool.length === 0) return null; + return pool.reduce((best, current) => (getTotal(current) > getTotal(best) ? current : best)); +} + +function getMiniMaxResetAt(model, capturedAtMs, remainsSnake, remainsCamel, endSnake, endCamel) { + const remainsMs = Number(getMiniMaxField(model, remainsSnake, remainsCamel)) || 0; + if (remainsMs > 0) return new Date(capturedAtMs + remainsMs).toISOString(); + return parseResetTime(getMiniMaxField(model, endSnake, endCamel)); +} + +function buildMiniMaxQuota(total, count, resetAt, countMeansRemaining) { + const safeTotal = Math.max(0, total); + const used = countMeansRemaining ? Math.max(safeTotal - count, 0) : Math.min(Math.max(0, count), safeTotal); + const remaining = Math.max(safeTotal - used, 0); + return { + used, + total: safeTotal, + remaining, + remainingPercentage: safeTotal > 0 ? Math.max(0, Math.min(100, (remaining / safeTotal) * 100)) : 0, + resetAt, + unlimited: false, + }; +} + +/** + * MiniMax Token Plan / Coding Plan usage + */ +async function getMiniMaxUsage(apiKey, provider, proxyOptions = null) { + if (!apiKey) { + return { message: "MiniMax API key not available." }; + } + + const usageUrls = MINIMAX_USAGE_URLS[provider] || []; + let lastErrorMessage = ""; + + for (let index = 0; index < usageUrls.length; index += 1) { + const usageUrl = usageUrls[index]; + const canFallback = index < usageUrls.length - 1; + + try { + const response = await proxyAwareFetch(usageUrl, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + "Content-Type": "application/json", + }, + }, proxyOptions); + + const rawText = await response.text(); + let payload = {}; + if (rawText) { + try { payload = JSON.parse(rawText); } catch { payload = {}; } + } + + const baseResp = (payload?.base_resp ?? payload?.baseResp) || {}; + const apiStatusCode = Number(baseResp.status_code ?? baseResp.statusCode) || 0; + const apiStatusMessage = String(baseResp.status_msg ?? baseResp.statusMsg ?? "").trim(); + const combined = `${apiStatusMessage} ${rawText}`.trim(); + const authLike = /token plan|coding plan|invalid api key|invalid key|unauthorized|inactive/i; + + if (response.status === 401 || response.status === 403 || apiStatusCode === 1004 || authLike.test(combined)) { + return { message: "MiniMax API key invalid or inactive. Use an active Token/Coding Plan key." }; + } + + if (!response.ok) { + lastErrorMessage = `MiniMax usage endpoint error (${response.status})`; + if ((response.status === 404 || response.status === 405 || response.status >= 500) && canFallback) continue; + return { message: `MiniMax connected. ${lastErrorMessage}` }; + } + + if (apiStatusCode !== 0) { + return { message: `MiniMax connected. ${apiStatusMessage || "Upstream quota API error"}` }; + } + + const modelRemains = payload?.model_remains ?? payload?.modelRemains; + const allModels = Array.isArray(modelRemains) ? modelRemains : []; + const textModels = allModels.filter((m) => isMiniMaxTextQuotaModel(String(getMiniMaxField(m, "model_name", "modelName")))); + + if (textModels.length === 0) { + return { message: "MiniMax connected. No text quota data was returned." }; + } + + const capturedAtMs = Date.now(); + const countMeansRemaining = usageUrl.includes("/coding_plan/remains"); + const quotas = {}; + + const sessionModel = pickMiniMaxRepresentativeModel(textModels, getMiniMaxSessionTotal); + if (sessionModel) { + const total = getMiniMaxSessionTotal(sessionModel); + const count = Math.max(0, Number(getMiniMaxField(sessionModel, "current_interval_usage_count", "currentIntervalUsageCount")) || 0); + quotas["session (5h)"] = buildMiniMaxQuota( + total, count, + getMiniMaxResetAt(sessionModel, capturedAtMs, "remains_time", "remainsTime", "end_time", "endTime"), + countMeansRemaining + ); + } + + const weeklyModel = pickMiniMaxRepresentativeModel(textModels, getMiniMaxWeeklyTotal); + if (weeklyModel && getMiniMaxWeeklyTotal(weeklyModel) > 0) { + const total = getMiniMaxWeeklyTotal(weeklyModel); + const count = Math.max(0, Number(getMiniMaxField(weeklyModel, "current_weekly_usage_count", "currentWeeklyUsageCount")) || 0); + quotas["weekly (7d)"] = buildMiniMaxQuota( + total, count, + getMiniMaxResetAt(weeklyModel, capturedAtMs, "weekly_remains_time", "weeklyRemainsTime", "weekly_end_time", "weeklyEndTime"), + countMeansRemaining + ); + } + + if (Object.keys(quotas).length === 0) { + return { message: "MiniMax connected. Unable to extract quota usage." }; + } + + return { quotas }; + } catch (error) { + lastErrorMessage = error.message; + if (!canFallback) break; + } + } + + return { message: lastErrorMessage ? `MiniMax connected. Unable to fetch usage: ${lastErrorMessage}` : "MiniMax connected. Unable to fetch usage." }; +} diff --git a/open-sse/utils/reasoningContentInjector.js b/open-sse/utils/reasoningContentInjector.js index 80cd9315..0f28496a 100644 --- a/open-sse/utils/reasoningContentInjector.js +++ b/open-sse/utils/reasoningContentInjector.js @@ -16,8 +16,10 @@ const MODEL_RULES = [ ]; function shouldInject(message, scope) { - if (message?.role !== "assistant" || "reasoning_content" in message) return false; - if (scope === "toolCalls") return Array.isArray(message.tool_calls); + if (message?.role !== "assistant") return false; + const rc = message.reasoning_content; + if (typeof rc === "string" && rc.length > 0) return false; + if (scope === "toolCalls") return Array.isArray(message.tool_calls) && message.tool_calls.length > 0; return true; } diff --git a/package.json b/package.json index 948996be..6a9d691f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.4.16", + "version": "0.4.17", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/public/providers/alicode-intl.png b/public/providers/alicode-intl.png index 21c3ef3d..9bee7d5b 100644 Binary files a/public/providers/alicode-intl.png and b/public/providers/alicode-intl.png differ diff --git a/public/providers/alicode.png b/public/providers/alicode.png index 21c3ef3d..9bee7d5b 100644 Binary files a/public/providers/alicode.png and b/public/providers/alicode.png differ diff --git a/public/providers/aws-polly.png b/public/providers/aws-polly.png new file mode 100644 index 00000000..eef2d601 Binary files /dev/null and b/public/providers/aws-polly.png differ diff --git a/public/providers/black-forest-labs.png b/public/providers/black-forest-labs.png new file mode 100644 index 00000000..c42a3134 Binary files /dev/null and b/public/providers/black-forest-labs.png differ diff --git a/public/providers/byteplus.png b/public/providers/byteplus.png old mode 100755 new mode 100644 index a5dc899d..cafbf6cd Binary files a/public/providers/byteplus.png and b/public/providers/byteplus.png differ diff --git a/public/providers/cloudflare-ai.png b/public/providers/cloudflare-ai.png index d2e8519a..26cda8cb 100644 Binary files a/public/providers/cloudflare-ai.png and b/public/providers/cloudflare-ai.png differ diff --git a/public/providers/fal-ai.png b/public/providers/fal-ai.png new file mode 100644 index 00000000..871855e3 Binary files /dev/null and b/public/providers/fal-ai.png differ diff --git a/public/providers/jina-ai.png b/public/providers/jina-ai.png new file mode 100644 index 00000000..bb8ee31a Binary files /dev/null and b/public/providers/jina-ai.png differ diff --git a/public/providers/nebius.png b/public/providers/nebius.png index 5a9586ed..c4ebd3cb 100644 Binary files a/public/providers/nebius.png and b/public/providers/nebius.png differ diff --git a/public/providers/nvidia.png b/public/providers/nvidia.png index f80a72e7..d115e366 100644 Binary files a/public/providers/nvidia.png and b/public/providers/nvidia.png differ diff --git a/public/providers/ollama-local.png b/public/providers/ollama-local.png index 8cd2cf1e..302b1b18 100644 Binary files a/public/providers/ollama-local.png and b/public/providers/ollama-local.png differ diff --git a/public/providers/ollama.png b/public/providers/ollama.png index 8cd2cf1e..302b1b18 100644 Binary files a/public/providers/ollama.png and b/public/providers/ollama.png differ diff --git a/public/providers/recraft.png b/public/providers/recraft.png new file mode 100644 index 00000000..3ed12856 Binary files /dev/null and b/public/providers/recraft.png differ diff --git a/public/providers/runwayml.png b/public/providers/runwayml.png new file mode 100644 index 00000000..8f53a141 Binary files /dev/null and b/public/providers/runwayml.png differ diff --git a/public/providers/stability-ai.png b/public/providers/stability-ai.png new file mode 100644 index 00000000..31cf71c7 Binary files /dev/null and b/public/providers/stability-ai.png differ diff --git a/public/providers/topaz.png b/public/providers/topaz.png new file mode 100644 index 00000000..3f86008e Binary files /dev/null and b/public/providers/topaz.png differ diff --git a/public/providers/vertex-partner.png b/public/providers/vertex-partner.png index 00ee1a40..892af458 100644 Binary files a/public/providers/vertex-partner.png and b/public/providers/vertex-partner.png differ diff --git a/public/providers/vertex.png b/public/providers/vertex.png index 00ee1a40..892af458 100644 Binary files a/public/providers/vertex.png and b/public/providers/vertex.png differ diff --git a/public/providers/volcengine-ark.png b/public/providers/volcengine-ark.png index 60625a16..e452a09b 100644 Binary files a/public/providers/volcengine-ark.png and b/public/providers/volcengine-ark.png differ diff --git a/skills/9router-stt/SKILL.md b/skills/9router-stt/SKILL.md new file mode 100644 index 00000000..32ac3c38 --- /dev/null +++ b/skills/9router-stt/SKILL.md @@ -0,0 +1,77 @@ +--- +name: 9router-stt +description: Speech-to-text via 9Router /v1/audio/transcriptions using OpenAI Whisper / Groq / Gemini / Deepgram / AssemblyAI / NVIDIA / HuggingFace models. Use when the user wants to transcribe audio, convert speech to text, or get subtitles from audio files. +--- + +# 9Router — Speech-to-Text + +Requires `NINEROUTER_URL` (and `NINEROUTER_KEY` if auth enabled). See https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router/SKILL.md for setup. + +## Discover models + +```bash +curl $NINEROUTER_URL/v1/models/stt | jq '.data[].id' +``` + +`model` = STT model ID (e.g. `openai/whisper-1`, `groq/whisper-large-v3`, `deepgram/nova-3`, `gemini/gemini-2.5-flash`). + +## Endpoint + +`POST $NINEROUTER_URL/v1/audio/transcriptions` (OpenAI Whisper compatible, `multipart/form-data`) + +| Field | Required | Notes | +|---|---|---| +| `model` | yes | from `/v1/models/stt` | +| `file` | yes | audio file (mp3, wav, m4a, webm, ogg, flac) | +| `language` | no | ISO-639-1 (e.g. `en`, `vi`) | +| `prompt` | no | hint text to guide transcription | +| `response_format` | no | `json` (default) / `text` / `verbose_json` / `srt` / `vtt` | +| `temperature` | no | 0–1 | + +## Examples + +```bash +curl -X POST "$NINEROUTER_URL/v1/audio/transcriptions" \ + -H "Authorization: Bearer $NINEROUTER_KEY" \ + -F "model=openai/whisper-1" \ + -F "file=@audio.mp3" \ + -F "language=vi" +``` + +JS (Node): + +```js +import { createReadStream } from "node:fs"; +const form = new FormData(); +form.append("model", "groq/whisper-large-v3-turbo"); +form.append("file", new Blob([await (await import("node:fs/promises")).readFile("audio.mp3")]), "audio.mp3"); +const r = await fetch(`${process.env.NINEROUTER_URL}/v1/audio/transcriptions`, { + method: "POST", + headers: { "Authorization": `Bearer ${process.env.NINEROUTER_KEY}` }, + body: form, +}); +const { text } = await r.json(); +console.log(text); +``` + +## Response shape + +Default (`response_format=json`): +```json +{ "text": "Xin chào, đây là bản ghi âm." } +``` + +`verbose_json` adds `language`, `duration`, `segments[]` with timestamps. +`srt` / `vtt` return subtitle text. + +## Provider quirks + +| Provider | `model` format | Notes | +|---|---|---| +| `openai` | `whisper-1`, `gpt-4o-transcribe`, `gpt-4o-mini-transcribe` | Native OpenAI shape | +| `groq` | `whisper-large-v3`, `whisper-large-v3-turbo`, `distil-whisper-large-v3-en` | Fastest; OpenAI shape | +| `gemini` | `gemini-2.5-flash`, `gemini-2.5-pro`, `gemini-2.5-flash-lite` | Server converts to `generateContent` with audio inline | +| `deepgram` | `nova-3`, `nova-2`, `whisper-large` | Token auth; server adapts response | +| `assemblyai` | `universal-3-pro`, `universal-2` | Async upload+poll handled server-side | +| `nvidia` | `nvidia/parakeet-ctc-1.1b-asr` | NIM endpoint | +| `huggingface` | `openai/whisper-large-v3`, `openai/whisper-small` | HF Inference API | diff --git a/skills/9router/SKILL.md b/skills/9router/SKILL.md index 0bc6d24e..ea4c511d 100644 --- a/skills/9router/SKILL.md +++ b/skills/9router/SKILL.md @@ -49,6 +49,7 @@ When the user needs a specific capability, fetch that skill's `SKILL.md` from it | Chat / code-gen | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-chat/SKILL.md | | Image generation | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-image/SKILL.md | | Text-to-speech | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-tts/SKILL.md | +| Speech-to-text | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-stt/SKILL.md | | Embeddings | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-embeddings/SKILL.md | | Web search | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-web-search/SKILL.md | | Web fetch (URL → markdown) | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-web-fetch/SKILL.md | diff --git a/skills/README.md b/skills/README.md index 67d3afd2..f9f06b90 100644 --- a/skills/README.md +++ b/skills/README.md @@ -12,6 +12,7 @@ Drop-in skills for any AI agent (Claude, Cursor, ChatGPT, custom SDK). Just **co | Chat / code-gen | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-chat/SKILL.md | | Image generation | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-image/SKILL.md | | Text-to-speech | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-tts/SKILL.md | +| Speech-to-text | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-stt/SKILL.md | | Embeddings | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-embeddings/SKILL.md | | Web search | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-web-search/SKILL.md | | Web fetch (URL → markdown) | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-web-fetch/SKILL.md | diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js index d8ebc998..9e9f9ce6 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js +++ b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js @@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from "react"; import { Card, CardSkeleton } from "@/shared/components"; import { CLI_TOOLS } from "@/shared/constants/cliTools"; import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; -import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, HermesToolCard, DefaultToolCard, OpenCodeToolCard, MitmLinkCard } from "./components"; +import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, HermesToolCard, DefaultToolCard, OpenCodeToolCard, CoworkToolCard, MitmLinkCard } from "./components"; import { MITM_TOOLS } from "@/shared/constants/cliTools"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -17,6 +17,7 @@ const STATUS_ENDPOINTS = { droid: "/api/cli-tools/droid-settings", openclaw: "/api/cli-tools/openclaw-settings", hermes: "/api/cli-tools/hermes-settings", + cowork: "/api/cli-tools/cowork-settings", }; export default function CLIToolsPageClient({ machineId }) { @@ -27,6 +28,8 @@ export default function CLIToolsPageClient({ machineId }) { const [cloudEnabled, setCloudEnabled] = useState(false); const [tunnelEnabled, setTunnelEnabled] = useState(false); const [tunnelPublicUrl, setTunnelPublicUrl] = useState(""); + const [tailscaleEnabled, setTailscaleEnabled] = useState(false); + const [tailscaleUrl, setTailscaleUrl] = useState(""); const [apiKeys, setApiKeys] = useState([]); const [toolStatuses, setToolStatuses] = useState({}); @@ -68,8 +71,10 @@ export default function CLIToolsPageClient({ machineId }) { } if (tunnelRes.ok) { const data = await tunnelRes.json(); - setTunnelEnabled(data.enabled || false); - setTunnelPublicUrl(data.publicUrl || ""); + setTunnelEnabled(!!(data.tunnel?.enabled || data.tunnel?.settingsEnabled)); + setTunnelPublicUrl(data.tunnel?.publicUrl || ""); + setTailscaleEnabled(!!(data.tailscale?.enabled || data.tailscale?.settingsEnabled)); + setTailscaleUrl(data.tailscale?.tunnelUrl || ""); } } catch (error) { console.log("Error loading settings:", error); @@ -176,6 +181,22 @@ export default function CLIToolsPageClient({ machineId }) { return ; case "opencode": return ; + case "cowork": + return ( + + ); case "droid": return ; case "openclaw": diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js new file mode 100644 index 00000000..833cf817 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CoworkToolCard.js @@ -0,0 +1,401 @@ +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; +import Image from "next/image"; + +const ENDPOINT = "/api/cli-tools/cowork-settings"; + +const isLocalhostUrl = (url) => /localhost|127\.0\.0\.1|0\.0\.0\.0/i.test(url || ""); + +const stripV1 = (url) => (url || "").replace(/\/v1\/?$/, ""); +const ensureV1 = (url) => { + const trimmed = (url || "").replace(/\/+$/, ""); + if (!trimmed) return ""; + return /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`; +}; + +export default function CoworkToolCard({ + tool, + isExpanded, + onToggle, + baseUrl, + apiKeys, + activeProviders, + hasActiveProviders, + cloudEnabled, + cloudUrl, + tunnelEnabled, + tunnelPublicUrl, + tailscaleEnabled, + tailscaleUrl, + initialStatus, +}) { + const [status, setStatus] = useState(initialStatus || null); + const [checking, setChecking] = useState(false); + const [applying, setApplying] = useState(false); + const [restoring, setRestoring] = useState(false); + const [message, setMessage] = useState(null); + const [selectedApiKey, setSelectedApiKey] = useState(""); + const [selectedModels, setSelectedModels] = useState([]); + const [modalOpen, setModalOpen] = useState(false); + const [modelAliases, setModelAliases] = useState({}); + const [showManualConfigModal, setShowManualConfigModal] = useState(false); + const [endpointMode, setEndpointMode] = useState("custom"); + const [customBaseUrl, setCustomBaseUrl] = useState(""); + + const endpointOptions = useMemo(() => { + const opts = []; + if (tunnelEnabled && tunnelPublicUrl) { + opts.push({ value: "tunnel", label: `Tunnel - ${tunnelPublicUrl}`, url: ensureV1(tunnelPublicUrl) }); + } + if (tailscaleEnabled && tailscaleUrl) { + opts.push({ value: "tailscale", label: `Tailscale - ${tailscaleUrl}`, url: ensureV1(tailscaleUrl) }); + } + if (cloudEnabled && cloudUrl) { + opts.push({ value: "cloud", label: `Cloud - ${cloudUrl}`, url: ensureV1(cloudUrl) }); + } + opts.push({ value: "custom", label: "Custom URL (VPS / public host)", url: "" }); + return opts; + }, [tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl]); + + useEffect(() => { + if (apiKeys?.length > 0 && !selectedApiKey) { + setSelectedApiKey(apiKeys[0].key); + } + }, [apiKeys, selectedApiKey]); + + useEffect(() => { + if (initialStatus) setStatus(initialStatus); + }, [initialStatus]); + + useEffect(() => { + if (isExpanded && !status) { + checkStatus(); + fetchModelAliases(); + } + if (isExpanded) fetchModelAliases(); + }, [isExpanded]); + + useEffect(() => { + if (status?.cowork?.models?.length) { + setSelectedModels(status.cowork.models); + } + if (status?.cowork?.baseUrl && !customBaseUrl) { + setCustomBaseUrl(stripV1(status.cowork.baseUrl)); + setEndpointMode("custom"); + } + }, [status]); + + // Auto-pick first available preset when expand if user has not set anything + useEffect(() => { + if (!customBaseUrl && endpointOptions[0]?.url) { + setEndpointMode(endpointOptions[0].value); + setCustomBaseUrl(stripV1(endpointOptions[0].url)); + } + }, [endpointOptions]); + + const fetchModelAliases = async () => { + try { + const res = await fetch("/api/models/alias"); + const data = await res.json(); + if (res.ok) setModelAliases(data.aliases || {}); + } catch (error) { + console.log("Error fetching model aliases:", error); + } + }; + + const checkStatus = async () => { + setChecking(true); + try { + const res = await fetch(ENDPOINT); + const data = await res.json(); + setStatus(data); + } catch (error) { + setStatus({ installed: false, error: error.message }); + } finally { + setChecking(false); + } + }; + + const getEffectiveBaseUrl = () => ensureV1(customBaseUrl); + + const getConfigStatus = () => { + if (!status?.installed) return null; + const url = status?.cowork?.baseUrl; + if (!url) return "not_configured"; + if (isLocalhostUrl(url)) return "invalid"; + return status.has9Router ? "configured" : "other"; + }; + + const configStatus = getConfigStatus(); + const hasCustomSelectedApiKey = selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey); + + const handleEndpointModeChange = (value) => { + setEndpointMode(value); + const opt = endpointOptions.find((o) => o.value === value); + if (opt?.url) { + setCustomBaseUrl(stripV1(opt.url)); + } else { + setCustomBaseUrl(""); + } + }; + + const handleApply = async () => { + setMessage(null); + const effectiveUrl = getEffectiveBaseUrl(); + + if (isLocalhostUrl(effectiveUrl)) { + setMessage({ type: "error", text: "Localhost is not allowed. Enable Tunnel/Tailscale or use VPS." }); + return; + } + if (selectedModels.length === 0) { + setMessage({ type: "error", text: "Please select at least one model" }); + return; + } + + setApplying(true); + try { + const keyToUse = selectedApiKey?.trim() + || (apiKeys?.length > 0 ? apiKeys[0].key : null) + || (!cloudEnabled ? "sk_9router" : null); + + const res = await fetch(ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: effectiveUrl, + apiKey: keyToUse, + models: selectedModels, + }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings applied. Quit & reopen Claude Desktop to load." }); + checkStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to apply settings" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setApplying(false); + } + }; + + const handleReset = async () => { + setRestoring(true); + setMessage(null); + try { + const res = await fetch(ENDPOINT, { method: "DELETE" }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings reset successfully" }); + setSelectedModels([]); + checkStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to reset" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setRestoring(false); + } + }; + + const getManualConfigs = () => { + const keyToUse = (selectedApiKey && selectedApiKey.trim()) + ? selectedApiKey + : (!cloudEnabled ? "sk_9router" : ""); + + const modelsToShow = selectedModels.length > 0 ? selectedModels : ["provider/model-id"]; + const cfg = { + inferenceProvider: "gateway", + inferenceGatewayBaseUrl: getEffectiveBaseUrl() || "https://your-public-host/v1", + inferenceGatewayApiKey: keyToUse, + inferenceModels: modelsToShow.map((name) => ({ name })), + }; + + return [{ + filename: "~/Library/Application Support/Claude-3p/configLibrary/.json", + content: JSON.stringify(cfg, null, 2), + }]; + }; + + return ( + +
+
+
+ {tool.name} { e.target.style.display = "none"; }} /> +
+
+
+

{tool.name}

+ {configStatus === "configured" && Connected} + {configStatus === "not_configured" && Not configured} + {configStatus === "invalid" && Localhost (invalid)} + {configStatus === "other" && Other} +
+

{tool.description}

+
+
+ expand_more +
+ + {isExpanded && ( +
+
+ info + Claude Cowork runs in a sandboxed VM and cannot reach localhost. Use Tunnel, Tailscale, or VPS public URL. +
+ + {checking && ( +
+ progress_activity + Checking Claude Cowork... +
+ )} + + {!checking && status && !status.installed && ( +
+
+ warning +
+

Claude Desktop (Cowork mode) not detected

+

Open Claude Desktop → Help → Troubleshooting → Enable Developer mode → Configure third-party inference, then return here.

+
+
+
+ +
+
+ )} + + {!checking && status?.installed && ( + <> +
+ {status?.cowork?.baseUrl && ( +
+ Current + arrow_forward + + {status.cowork.baseUrl} + +
+ )} + +
+ Endpoint Mode + arrow_forward + +
+ +
+ Base URL + arrow_forward + setCustomBaseUrl(stripV1(e.target.value))} + placeholder="https://your-host.com/v1" + className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" + /> +
+ +
+ API Key + arrow_forward + {apiKeys.length > 0 || selectedApiKey ? ( + + ) : ( + + {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"} + + )} +
+ +
+ Models + arrow_forward +
+
+ {selectedModels.length === 0 ? ( + No models selected + ) : ( + selectedModels.map((m) => ( + + {m} + + + )) + )} +
+ +
+
+
+ + {message && ( +
+ {message.type === "success" ? "check_circle" : "error"} + {message.text} +
+ )} + +
+ + + +
+ + )} +
+ )} + + setModalOpen(false)} + onSelect={(model) => { + if (!selectedModels.includes(model.value)) { + setSelectedModels([...selectedModels, model.value]); + } + setModalOpen(false); + }} + selectedModel={null} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Add Model for Claude Cowork" + /> + + setShowManualConfigModal(false)} + title="Claude Cowork - Manual Configuration" + configs={getManualConfigs()} + /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/index.js b/src/app/(dashboard)/dashboard/cli-tools/components/index.js index 4500800d..06b45ccc 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/index.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/index.js @@ -6,6 +6,7 @@ export { default as HermesToolCard } from "./HermesToolCard"; export { default as DefaultToolCard } from "./DefaultToolCard"; export { default as AntigravityToolCard } from "./AntigravityToolCard"; export { default as OpenCodeToolCard } from "./OpenCodeToolCard"; +export { default as CoworkToolCard } from "./CoworkToolCard"; export { default as CopilotToolCard } from "./CopilotToolCard"; export { default as MitmServerCard } from "./MitmServerCard"; export { default as MitmToolCard } from "./MitmToolCard"; diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js index cded5093..36605632 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js @@ -12,6 +12,7 @@ import ConnectionsCard from "@/app/(dashboard)/dashboard/providers/components/Co import ModelsCard from "@/app/(dashboard)/dashboard/providers/components/ModelsCard"; import { TTS_PROVIDER_CONFIG } from "@/shared/constants/ttsProviders"; import { getTtsVoicesForModel } from "open-sse/config/ttsModels.js"; +import { GOOGLE_TTS_LANGUAGES } from "open-sse/config/googleTtsLanguages.js"; // Shared row layout — defined outside components to avoid re-mount on re-render function Row({ label, children }) { @@ -92,13 +93,6 @@ const KIND_EXAMPLE_CONFIG = { 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", @@ -394,6 +388,8 @@ function TtsExampleCard({ providerId }) { const [modalSearch, setModalSearch] = useState(""); const [modalError, setModalError] = useState(""); const [byLang, setByLang] = useState({}); + // Language hint (e.g. Gemini): controls the spoken language without affecting voice selection + const [languageHint, setLanguageHint] = useState(""); useEffect(() => { setLocalEndpoint(window.location.origin); @@ -514,10 +510,15 @@ function TtsExampleCard({ providerId }) { return ""; })(); + const ttsBody = (() => { + const b = { model: modelFull, input }; + if (config.hasLanguageHint && languageHint) b.language = languageHint; + return b; + })(); const curlSnippet = `curl -X POST ${endpoint}/v1/audio/speech${responseFormat === "json" ? "?response_format=json" : ""} \\ -H "Content-Type: application/json" \\ -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\ - -d '{"model": "${modelFull}", "input": "${input}"}' \\ + -d '${JSON.stringify(ttsBody)}' \\ ${responseFormat === "json" ? "" : "--output speech.mp3"}`; const handleRun = async () => { @@ -534,7 +535,7 @@ function TtsExampleCard({ providerId }) { const res = await fetch(url, { method: "POST", headers, - body: JSON.stringify({ model: modelFull, input: input.trim() }), + body: JSON.stringify({ ...ttsBody, input: input.trim() }), }); setLatency(Date.now() - start); if (!res.ok) { @@ -608,6 +609,22 @@ function TtsExampleCard({ providerId }) { )} + {/* Language hint dropdown (Gemini) — sends body.language to guide pronunciation */} + {config.hasLanguageHint && ( + + + + )} + {/* Language row + Browse button (edge-tts, local-device, elevenlabs) */} {config.hasBrowseButton && ( @@ -886,7 +903,7 @@ function GenericExampleCard({ providerId, kind }) { // Get models for this kind (e.g., type="image") const kindModels = getModelsByProviderId(providerId).filter((m) => m.type === kind); // Kinds that need a model identifier in the request (image/video/music) - const KIND_NEEDS_MODEL = new Set(["image", "video", "music", "stt", "imageToText"]); + const KIND_NEEDS_MODEL = new Set(["image", "video", "music", "imageToText"]); const needsModel = KIND_NEEDS_MODEL.has(kind); const allowManualModel = needsModel && kindModels.length === 0; const [selectedModel, setSelectedModel] = useState(kindModels[0]?.id ?? ""); @@ -1344,6 +1361,288 @@ function GenericExampleCard({ providerId, kind }) { ); } +// ─── STT Example Card ──────────────────────────────────────────────────────── +function SttExampleCard({ providerId }) { + const providerAlias = getProviderAlias(providerId); + const builtinSttModels = getModelsByProviderId(providerId).filter((m) => m.type === "stt"); + const [customSttModels, setCustomSttModels] = useState([]); + const sttModels = [...builtinSttModels, ...customSttModels]; + + const [selectedModel, setSelectedModel] = useState(builtinSttModels[0]?.id ?? ""); + const selectedModelObj = sttModels.find((m) => m.id === selectedModel); + const allowedParams = Array.isArray(selectedModelObj?.params) ? selectedModelObj.params : []; + + const [audioFile, setAudioFile] = useState(null); + const [language, setLanguage] = useState(""); + const [prompt, setPrompt] = useState(""); + const [responseFormat, setResponseFormat] = useState("json"); + const [temperature, setTemperature] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [useTunnel, setUseTunnel] = useState(false); + const [localEndpoint, setLocalEndpoint] = useState(""); + const [tunnelEndpoint, setTunnelEndpoint] = useState(""); + const [result, setResult] = useState(null); + const [latency, setLatency] = 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 loadCustom = () => { + fetch("/api/models/custom", { cache: "no-store" }) + .then((r) => r.json()) + .then((d) => { + const list = (d.models || []).filter((m) => m.type === "stt" && m.providerAlias === providerAlias); + setCustomSttModels(list); + }) + .catch(() => {}); + }; + loadCustom(); + window.addEventListener("focus", loadCustom); + window.addEventListener("customModelChanged", loadCustom); + return () => { + window.removeEventListener("focus", loadCustom); + window.removeEventListener("customModelChanged", loadCustom); + }; + }, [providerAlias]); + + const endpoint = useTunnel ? tunnelEndpoint : localEndpoint; + const modelFull = selectedModel ? `${providerAlias}/${selectedModel}` : ""; + + const curlSnippet = `curl -X POST ${endpoint}/v1/audio/transcriptions \\ + -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\ + -F "file=@${audioFile?.name || "audio.mp3"}" \\ + -F "model=${modelFull}"${allowedParams.includes("language") && language ? ` \\\n -F "language=${language}"` : ""}${allowedParams.includes("response_format") ? ` \\\n -F "response_format=${responseFormat}"` : ""}${allowedParams.includes("temperature") && temperature ? ` \\\n -F "temperature=${temperature}"` : ""}${allowedParams.includes("prompt") && prompt ? ` \\\n -F "prompt=${prompt}"` : ""}`; + + const handleRun = async () => { + if (!audioFile || !modelFull) return; + setRunning(true); + setError(""); + setResult(null); + const start = Date.now(); + try { + const fd = new FormData(); + fd.append("file", audioFile); + fd.append("model", modelFull); + if (allowedParams.includes("language") && language) fd.append("language", language); + if (allowedParams.includes("response_format")) fd.append("response_format", responseFormat); + if (allowedParams.includes("temperature") && temperature) fd.append("temperature", temperature); + if (allowedParams.includes("prompt") && prompt) fd.append("prompt", prompt); + + const headers = {}; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + const res = await fetch("/api/v1/audio/transcriptions", { method: "POST", headers, body: fd }); + setLatency(Date.now() - start); + const ct = res.headers.get("content-type") || ""; + const data = ct.includes("application/json") ? await res.json() : await res.text(); + if (!res.ok) { + setError(data?.error?.message || data?.error || data || `HTTP ${res.status}`); + return; + } + setResult(data); + } catch (e) { + setError(e.message || "Network error"); + } finally { + setRunning(false); + } + }; + + const resultStr = typeof result === "string" ? result : (result ? JSON.stringify(result, null, 2) : `{\n "text": "Hello world..."\n}`); + + return ( + +

Example

+
+ {/* Model */} + {sttModels.length > 0 ? ( + + + + ) : ( + + setSelectedModel(e.target.value)} + placeholder="Enter model id" + className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono" + /> + + )} + + {/* Endpoint */} + +
+ + {endpoint}/v1/audio/transcriptions + + {tunnelEndpoint && ( + + )} +
+
+ + {/* API Key */} + + + {apiKey ? `${apiKey.slice(0, 8)}${"\u2022".repeat(Math.min(20, apiKey.length - 8))}` : No key configured} + + + + {/* Audio file */} + +
+ setAudioFile(e.target.files?.[0] || null)} + className="w-full text-xs text-text-muted file:mr-2 file:py-1 file:px-2.5 file:rounded-lg file:border file:border-border file:bg-background file:text-text-main hover:file:bg-sidebar file:cursor-pointer" + /> + {audioFile && ( + + {audioFile.name} · {(audioFile.size / 1024).toFixed(1)} KB + + )} +
+
+ + {/* Language (if model supports) */} + {allowedParams.includes("language") && ( + + setLanguage(e.target.value)} + placeholder="e.g. en, vi, ja (auto-detect if empty)" + className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono" + /> + + )} + + {/* Prompt (if model supports) */} + {allowedParams.includes("prompt") && ( + + setPrompt(e.target.value)} + placeholder="optional context to improve accuracy" + className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> + + )} + + {/* Temperature (if model supports) */} + {allowedParams.includes("temperature") && ( + + setTemperature(e.target.value)} + placeholder="0 - 1 (default 0)" + className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> + + )} + + {/* Response format (if model supports) */} + {allowedParams.includes("response_format") && ( + + + + )} + + {/* Curl + Run */} +
+
+ Request +
+ + +
+
+
{curlSnippet}
+
+ + {error &&

{error}

} + + {/* Response */} +
+
+ + Response {result && latency && ⚡ {latency}ms} + + {result && ( + + )} +
+
+            {resultStr}
+          
+
+
+
+ ); +} + // MediaProviderDetailPage export default function MediaProviderDetailPage() { const { kind, id } = useParams(); @@ -1502,11 +1801,12 @@ export default function MediaProviderDetailPage() { )} {/* Provider Info — config-driven, supports searchConfig, fetchConfig, ttsConfig, embeddingConfig, searchViaChat */} - {!isCustom && (provider.searchConfig || provider.fetchConfig || provider.ttsConfig || provider.embeddingConfig || provider.searchViaChat) && ( + {!isCustom && (provider.searchConfig || provider.fetchConfig || provider.ttsConfig || provider.sttConfig || provider.embeddingConfig || provider.searchViaChat) && ( )} {kind === "tts" && } + {kind === "stt" && !isCustom && } {!isCustom && KIND_EXAMPLE_CONFIG[kind] && } {isCustom && ( diff --git a/src/app/(dashboard)/dashboard/providers/[id]/AddCustomModelModal.js b/src/app/(dashboard)/dashboard/providers/[id]/AddCustomModelModal.js index 6e8ca2f4..d0bf1659 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/AddCustomModelModal.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/AddCustomModelModal.js @@ -15,15 +15,22 @@ export default function AddCustomModelModal({ isOpen, providerAlias, providerDis if (isOpen) { setModelId(""); setTestStatus(null); setTestError(""); } }, [isOpen]); + // Strip provider's own alias prefix (e.g. "cc/model" -> "model" for cc provider) + const stripAlias = (id) => { + const prefix = `${providerAlias}/`; + return id.startsWith(prefix) ? id.slice(prefix.length) : id; + }; + const handleTest = async () => { - if (!modelId.trim()) return; + const cleanId = stripAlias(modelId.trim()); + if (!cleanId) return; setTestStatus("testing"); setTestError(""); try { const res = await fetch("/api/models/test", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: `${providerAlias}/${modelId.trim()}` }), + body: JSON.stringify({ model: `${providerAlias}/${cleanId}` }), }); const data = await res.json(); setTestStatus(data.ok ? "ok" : "error"); @@ -35,10 +42,11 @@ export default function AddCustomModelModal({ isOpen, providerAlias, providerDis }; const handleSave = async () => { - if (!modelId.trim() || saving) return; + const cleanId = stripAlias(modelId.trim()); + if (!cleanId || saving) return; setSaving(true); try { - await onSave(modelId.trim()); + await onSave(cleanId); } finally { setSaving(false); } @@ -74,7 +82,7 @@ export default function AddCustomModelModal({ isOpen, providerAlias, providerDis

- Sent to provider as: {modelId.trim() || "model-id"} + Sent to provider as: {stripAlias(modelId.trim()) || "model-id"}

diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js b/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js index 25d62c5f..207fe012 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js @@ -1,6 +1,6 @@ import PropTypes from "prop-types"; -export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onTest, isTesting }) { +export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onTest, isTesting, onDisable }) { const borderColor = testStatus === "ok" ? "border-green-500/40" : testStatus === "error" @@ -55,7 +55,7 @@ export default function ModelRow({ model, fullModel, alias, copied, onCopy, test {copied === `model-${model.id}` ? "Copied!" : "Copy"} - {isCustom && ( + {isCustom ? ( - )} + ) : onDisable ? ( + + ) : null} ); @@ -83,4 +91,5 @@ ModelRow.propTypes = { onDeleteAlias: PropTypes.func, onTest: PropTypes.func, isTesting: PropTypes.bool, + onDisable: PropTypes.func, }; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index f74cf1e0..ad6f884a 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -46,6 +46,7 @@ export default function ProviderDetailPage() { const [thinkingMode, setThinkingMode] = useState("auto"); const [suggestedModels, setSuggestedModels] = useState([]); const [kiloFreeModels, setKiloFreeModels] = useState([]); + const [disabledModelIds, setDisabledModelIds] = useState([]); const { copied, copy } = useCopyToClipboard(); const providerInfo = providerNode @@ -74,6 +75,62 @@ export default function ProviderDetailPage() { ? (providerNode?.prefix || providerId) : providerAlias; + const fetchDisabledModels = useCallback(async () => { + try { + const res = await fetch(`/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}`, { cache: "no-store" }); + const data = await res.json(); + if (res.ok) setDisabledModelIds(data.ids || []); + } catch (error) { + console.log("Error fetching disabled models:", error); + } + }, [providerStorageAlias]); + + const handleDisableModel = async (modelId) => { + try { + const res = await fetch("/api/models/disabled", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ providerAlias: providerStorageAlias, ids: [modelId] }), + }); + if (res.ok) await fetchDisabledModels(); + } catch (error) { + console.log("Error disabling model:", error); + } + }; + + const handleEnableModel = async (modelId) => { + try { + const res = await fetch(`/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}&id=${encodeURIComponent(modelId)}`, { method: "DELETE" }); + if (res.ok) await fetchDisabledModels(); + } catch (error) { + console.log("Error enabling model:", error); + } + }; + + const handleDisableAll = async (ids) => { + if (!ids.length) return; + if (!confirm(`Disable all ${ids.length} model(s)?`)) return; + try { + const res = await fetch("/api/models/disabled", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ providerAlias: providerStorageAlias, ids }), + }); + if (res.ok) await fetchDisabledModels(); + } catch (error) { + console.log("Error disabling all models:", error); + } + }; + + const handleEnableAll = async () => { + try { + const res = await fetch(`/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}`, { method: "DELETE" }); + if (res.ok) await fetchDisabledModels(); + } catch (error) { + console.log("Error enabling all models:", error); + } + }; + // Define callbacks BEFORE the useEffect that uses them const fetchAliases = useCallback(async () => { try { @@ -237,7 +294,8 @@ export default function ProviderDetailPage() { useEffect(() => { fetchConnections(); fetchAliases(); - }, [fetchConnections, fetchAliases]); + fetchDisabledModels(); + }, [fetchConnections, fetchAliases, fetchDisabledModels]); // Fetch suggested models from provider's public API (if configured) useEffect(() => { @@ -587,10 +645,13 @@ export default function ProviderDetailPage() { } // Combine hardcoded models with Kilo free models (deduplicated) // Exclude non-llm models (embedding, tts, etc.) — they have dedicated pages under media-providers - const displayModels = [ + const allModels = [ ...models, ...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)), ].filter((m) => !m.type || m.type === "llm"); + const disabledSet = new Set(disabledModelIds); + const displayModels = allModels.filter((m) => !disabledSet.has(m.id)); + const disabledDisplayModels = allModels.filter((m) => disabledSet.has(m.id)); // Custom models added by user (stored as aliases: modelId → providerAlias/modelId) const customModels = Object.entries(modelAliases) .filter(([alias, fullModel]) => { @@ -610,6 +671,25 @@ export default function ProviderDetailPage() { return (
+ {/* Custom models first */} + {customModels.map((model) => ( + {}} + onDeleteAlias={() => handleDeleteAlias(model.alias)} + testStatus={modelTestResults[model.id]} + onTest={connections.length > 0 || isFreeNoAuth ? () => handleTestModel(model.id) : undefined} + isTesting={testingModelId === model.id} + isCustom + isFree={false} + /> + ))} + {displayModels.map((model) => { const fullModel = `${providerStorageAlias}/${model.id}`; const oldFormatModel = `${providerId}/${model.id}`; @@ -630,33 +710,15 @@ export default function ProviderDetailPage() { onTest={connections.length > 0 || isFreeNoAuth ? () => handleTestModel(model.id) : undefined} isTesting={testingModelId === model.id} isFree={model.isFree} + onDisable={() => handleDisableModel(model.id)} /> ); })} - {/* Custom models inline */} - {customModels.map((model) => ( - {}} - onDeleteAlias={() => handleDeleteAlias(model.alias)} - testStatus={modelTestResults[model.id]} - onTest={connections.length > 0 || isFreeNoAuth ? () => handleTestModel(model.id) : undefined} - isTesting={testingModelId === model.id} - isCustom - isFree={false} - /> - ))} - {/* Add model button — inline, same style as model chips */}
); })()} + + {/* Disabled models — restorable */} + {disabledDisplayModels.length > 0 && ( +
+

Disabled models ({disabledDisplayModels.length}):

+
+ {disabledDisplayModels.map((m) => ( + + ))} +
+
+ )} ); }; @@ -969,6 +1051,27 @@ export default function ProviderDetailPage() {

{"Available Models"}

+ {!isCompatible && (() => { + const allIds = [ + ...models, + ...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)), + ].filter((m) => !m.type || m.type === "llm").map((m) => m.id); + const activeIds = allIds.filter((id) => !disabledModelIds.includes(id)); + return ( +
+ {disabledModelIds.length > 0 && ( + + )} + {activeIds.length > 0 && ( + + )} +
+ ); + })()} {!!modelsTestError && (

{modelsTestError}

diff --git a/src/app/(dashboard)/dashboard/providers/components/ModelsCard.js b/src/app/(dashboard)/dashboard/providers/components/ModelsCard.js index 14f4f28f..f55d001f 100644 --- a/src/app/(dashboard)/dashboard/providers/components/ModelsCard.js +++ b/src/app/(dashboard)/dashboard/providers/components/ModelsCard.js @@ -165,7 +165,10 @@ export default function ModelsCard({ providerId, kindFilter, providerAliasOverri headers: { "Content-Type": "application/json" }, body: JSON.stringify({ providerAlias, id: modelId, type: effectiveType }), }); - if (res.ok) await fetchData(); + if (res.ok) { + await fetchData(); + window.dispatchEvent(new CustomEvent("customModelChanged")); + } } catch (e) { console.log("add custom model error:", e); } }; @@ -173,7 +176,10 @@ export default function ModelsCard({ providerId, kindFilter, providerAliasOverri try { const params = new URLSearchParams({ providerAlias, id: modelId, type: effectiveType }); const res = await fetch(`/api/models/custom?${params}`, { method: "DELETE" }); - if (res.ok) await fetchData(); + if (res.ok) { + await fetchData(); + window.dispatchEvent(new CustomEvent("customModelChanged")); + } } catch (e) { console.log("delete custom model error:", e); } }; diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js index 70ef4f98..0940cb91 100644 --- a/src/app/(dashboard)/dashboard/providers/page.js +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -24,6 +24,7 @@ import { import Link from "next/link"; import { getErrorCode, getRelativeTime } from "@/shared/utils"; import { useNotificationStore } from "@/store/notificationStore"; +import { useHeaderSearchStore } from "@/store/headerSearchStore"; import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge"; function getStatusDisplay(connected, error, errorCode) { @@ -103,6 +104,18 @@ export default function ProvidersPage() { const [testingMode, setTestingMode] = useState(null); const [testResults, setTestResults] = useState(null); const notify = useNotificationStore(); + const searchQuery = useHeaderSearchStore((s) => s.query); + const registerSearch = useHeaderSearchStore((s) => s.register); + const unregisterSearch = useHeaderSearchStore((s) => s.unregister); + + useEffect(() => { + registerSearch("Search providers..."); + return () => unregisterSearch(); + }, [registerSearch, unregisterSearch]); + + const matchSearch = (name) => + !searchQuery.trim() || + name.toLowerCase().includes(searchQuery.trim().toLowerCase()); useEffect(() => { const fetchData = async () => { @@ -224,7 +237,8 @@ export default function ProvidersPage() { color: "#10A37F", textIcon: "OC", apiType: node.apiType, - })); + })) + .filter((p) => matchSearch(p.name)); const anthropicCompatibleProviders = providerNodes .filter((node) => node.type === "anthropic-compatible") @@ -233,7 +247,22 @@ export default function ProvidersPage() { name: node.name || "Anthropic Compatible", color: "#D97757", textIcon: "AC", - })); + })) + .filter((p) => matchSearch(p.name)); + + const oauthEntries = Object.entries(OAUTH_PROVIDERS).filter(([, info]) => + matchSearch(info.name), + ); + const freeEntries = Object.entries(FREE_PROVIDERS).filter(([, info]) => + matchSearch(info.name), + ); + const freeTierEntries = Object.entries(FREE_TIER_PROVIDERS).filter( + ([, info]) => matchSearch(info.name), + ); + const apikeyEntries = Object.entries(APIKEY_PROVIDERS).filter( + ([, info]) => + (info.serviceKinds ?? ["llm"]).includes("llm") && matchSearch(info.name), + ); if (loading) { return ( @@ -244,9 +273,27 @@ export default function ProvidersPage() { ); } + const hasAnyResult = + oauthEntries.length > 0 || + freeEntries.length > 0 || + freeTierEntries.length > 0 || + apikeyEntries.length > 0 || + compatibleProviders.length > 0 || + anthropicCompatibleProviders.length > 0; + return (
+ {!hasAnyResult && ( +
+ + search_off + +

No providers match your search

+
+ )} + {/* OAuth Providers */} + {oauthEntries.length > 0 && (

@@ -275,7 +322,7 @@ export default function ProvidersPage() {

- {Object.entries(OAUTH_PROVIDERS).map(([key, info]) => ( + {oauthEntries.map(([key, info]) => (
+ )} {/* Free Tier Providers */} + {(freeEntries.length > 0 || freeTierEntries.length > 0) && (

@@ -314,7 +363,7 @@ export default function ProvidersPage() {

- {Object.entries(FREE_PROVIDERS).map(([key, info]) => ( + {freeEntries.map(([key, info]) => ( handleToggleProvider(key, "oauth", active)} /> ))} - {Object.entries(FREE_TIER_PROVIDERS).map(([key, info]) => ( + {freeTierEntries.map(([key, info]) => (
+ )} {/* API Key Providers — fixed list */} + {apikeyEntries.length > 0 && (

@@ -363,20 +414,19 @@ export default function ProvidersPage() {

- {Object.entries(APIKEY_PROVIDERS) - .filter(([, info]) => (info.serviceKinds ?? ["llm"]).includes("llm")) - .map(([key, info]) => ( - handleToggleProvider(key, "apikey", active)} - /> - ))} + {apikeyEntries.map(([key, info]) => ( + handleToggleProvider(key, "apikey", active)} + /> + ))}
+ )} {/* Web Cookie Providers — use browser subscription cookie instead of API key */} {/*
diff --git a/src/app/(dashboard)/dashboard/proxy-pools/page.js b/src/app/(dashboard)/dashboard/proxy-pools/page.js index 383cf562..6eab6b6c 100644 --- a/src/app/(dashboard)/dashboard/proxy-pools/page.js +++ b/src/app/(dashboard)/dashboard/proxy-pools/page.js @@ -41,6 +41,10 @@ export default function ProxyPoolsPage() { const [importing, setImporting] = useState(false); const [deploying, setDeploying] = useState(false); const [testingId, setTestingId] = useState(null); + const [selectedIds, setSelectedIds] = useState([]); + const [healthChecking, setHealthChecking] = useState(false); + const [healthProgress, setHealthProgress] = useState({ current: 0, total: 0 }); + const [bulkBusy, setBulkBusy] = useState(false); const notify = useNotificationStore(); const fetchProxyPools = useCallback(async () => { @@ -162,6 +166,136 @@ export default function ProxyPoolsPage() { } }; + const handleToggleActive = async (pool) => { + const next = !pool.isActive; + setProxyPools((prev) => prev.map((p) => p.id === pool.id ? { ...p, isActive: next } : p)); + try { + const res = await fetch(`/api/proxy-pools/${pool.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isActive: next }), + }); + if (!res.ok) { + setProxyPools((prev) => prev.map((p) => p.id === pool.id ? { ...p, isActive: pool.isActive } : p)); + notify.error("Failed to update active state"); + } + } catch (error) { + console.log("Error toggling active:", error); + setProxyPools((prev) => prev.map((p) => p.id === pool.id ? { ...p, isActive: pool.isActive } : p)); + } + }; + + const allSelected = proxyPools.length > 0 && selectedIds.length === proxyPools.length; + const toggleSelect = (id) => setSelectedIds((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]); + const toggleSelectAll = () => setSelectedIds(allSelected ? [] : proxyPools.map((p) => p.id)); + const clearSelection = () => setSelectedIds([]); + + const bulkSetActive = async (isActive) => { + const targets = selectedIds.length > 0 ? selectedIds : proxyPools.map((p) => p.id); + if (targets.length === 0) return; + setBulkBusy(true); + try { + let ok = 0; let failed = 0; + for (const id of targets) { + try { + const res = await fetch(`/api/proxy-pools/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isActive }), + }); + if (res.ok) ok += 1; else failed += 1; + } catch { failed += 1; } + } + await fetchProxyPools(); + notify.success(`${isActive ? "Activated" : "Deactivated"} ${ok}${failed ? `, failed ${failed}` : ""}`); + } finally { + setBulkBusy(false); + } + }; + + const bulkDelete = async () => { + if (selectedIds.length === 0) return; + if (!confirm(`Delete ${selectedIds.length} proxy pool(s)?`)) return; + setBulkBusy(true); + try { + let ok = 0; let blocked = 0; let failed = 0; + for (const id of selectedIds) { + try { + const res = await fetch(`/api/proxy-pools/${id}`, { method: "DELETE" }); + if (res.ok) ok += 1; + else if (res.status === 409) blocked += 1; + else failed += 1; + } catch { failed += 1; } + } + await fetchProxyPools(); + clearSelection(); + notify.success(`Deleted ${ok}${blocked ? `, ${blocked} bound` : ""}${failed ? `, ${failed} failed` : ""}`); + } finally { + setBulkBusy(false); + } + }; + + const handleHealthCheck = async () => { + const targets = selectedIds.length > 0 + ? proxyPools.filter((p) => selectedIds.includes(p.id)) + : proxyPools; + if (targets.length === 0) return; + setHealthChecking(true); + setHealthProgress({ current: 0, total: targets.length }); + let alive = 0; const deadIds = []; + let done = 0; + const CONCURRENCY = 10; + const queue = [...targets]; + + const worker = async () => { + while (queue.length > 0) { + const pool = queue.shift(); + if (!pool) break; + try { + const res = await fetch(`/api/proxy-pools/${pool.id}/test`, { method: "POST" }); + const data = await res.json(); + if (res.ok && data.ok) alive += 1; else deadIds.push(pool.id); + } catch { + deadIds.push(pool.id); + } finally { + done += 1; + setHealthProgress({ current: done, total: targets.length }); + } + } + }; + + await Promise.all(Array.from({ length: Math.min(CONCURRENCY, targets.length) }, worker)); + await fetchProxyPools(); + setHealthChecking(false); + setHealthProgress({ current: 0, total: 0 }); + + if (deadIds.length > 0 && confirm(`Alive: ${alive}, Dead: ${deadIds.length}.\n\nDisable ${deadIds.length} dead proxies?`)) { + setBulkBusy(true); + try { + for (const id of deadIds) { + try { + await fetch(`/api/proxy-pools/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isActive: false }), + }); + } catch {} + } + await fetchProxyPools(); + notify.success(`Disabled ${deadIds.length} dead proxies`); + } finally { + setBulkBusy(false); + } + } else { + notify.success(`Health check done. Alive: ${alive}, Dead: ${deadIds.length}`); + } + }; + + // Cleanup selectedIds when pools change + useEffect(() => { + setSelectedIds((prev) => prev.filter((id) => proxyPools.some((p) => p.id === id))); + }, [proxyPools]); + const openBatchImportModal = () => { setBatchImportText(""); setShowBatchImportModal(true); @@ -354,13 +488,57 @@ export default function ProxyPoolsPage() {
-
-
- Total: {proxyPools.length} - Active: {activeCount} -
+
+ {proxyPools.length > 0 && ( + + )} + Total: {proxyPools.length} + Active: {activeCount}
+ {(selectedIds.length > 0 || healthChecking) && ( +
+ checklist + + {selectedIds.length > 0 ? `${selectedIds.length} selected` : "All pools"} + +
+ + {selectedIds.length > 0 && ( + <> + + + + + + )} +
+
+ )} + {proxyPools.length === 0 ? (

No proxy pool entries yet

@@ -372,8 +550,15 @@ export default function ProxyPoolsPage() { ) : (
{proxyPools.map((pool) => ( -
-
+
+
+ toggleSelect(pool.id)} + className="mt-1 size-4 shrink-0 rounded border-black/20 dark:border-white/20" + /> +

{pool.name}

@@ -397,9 +582,16 @@ export default function ProxyPoolsPage() { Last tested: {formatDateTime(pool.lastTestedAt)} {pool.lastError ? ` · ${pool.lastError}` : ""}

+
-
+
+ handleToggleActive(pool)} + title={pool.isActive ? "Disable" : "Enable"} + /> + )} +
+ ); +} + Header.propTypes = { onMenuClick: PropTypes.func, showMenuButton: PropTypes.bool, diff --git a/src/shared/components/ModelSelectModal.js b/src/shared/components/ModelSelectModal.js index 065cff92..051b30d8 100644 --- a/src/shared/components/ModelSelectModal.js +++ b/src/shared/components/ModelSelectModal.js @@ -40,6 +40,7 @@ export default function ModelSelectModal({ const [combos, setCombos] = useState([]); const [providerNodes, setProviderNodes] = useState([]); const [customModels, setCustomModels] = useState([]); + const [disabledModels, setDisabledModels] = useState({}); const fetchCombos = async () => { try { @@ -89,6 +90,22 @@ export default function ModelSelectModal({ if (isOpen) fetchCustomModels(); }, [isOpen]); + const fetchDisabledModels = async () => { + try { + const res = await fetch("/api/models/disabled"); + if (!res.ok) throw new Error(`Failed to fetch disabled models: ${res.status}`); + const data = await res.json(); + setDisabledModels(data.disabled || {}); + } catch (error) { + console.error("Error fetching disabled models:", error); + setDisabledModels({}); + } + }; + + useEffect(() => { + if (isOpen) fetchDisabledModels(); + }, [isOpen]); + const allProviders = useMemo(() => ({ ...OAUTH_PROVIDERS, ...FREE_PROVIDERS, ...FREE_TIER_PROVIDERS, ...APIKEY_PROVIDERS }), []); // Group models by provider with priority order @@ -104,7 +121,9 @@ export default function ModelSelectModal({ // Filter a models[] array by kindFilter (keep only matching m.type) const filterByKind = (models) => { - if (!kindFilter || !TYPED_KINDS.has(kindFilter)) return models; + // No kindFilter → LLM context: keep only LLM models (no type or type === "llm") + if (!kindFilter) return models.filter((m) => m.isPlaceholder || !m.type || m.type === "llm"); + if (!TYPED_KINDS.has(kindFilter)) return models; return models.filter((m) => m.isPlaceholder || m.type === kindFilter); }; @@ -239,11 +258,18 @@ export default function ModelSelectModal({ .filter((m) => m.providerAlias === alias && !hardcodedIds.has(m.id) && !customAliasIds.has(m.id)) .map((m) => ({ id: m.id, name: m.name || m.id, value: `${alias}/${m.id}`, isCustom: true })); - let allModels = filterByKind([ + const merged = [ ...hardcodedModels.map((m) => ({ id: m.id, name: m.name, value: `${alias}/${m.id}`, type: m.type })), ...customAliasModels, ...customRegisteredModels, - ]); + ]; + // Dedupe by value (alias may equal hardcoded id, causing React key collision) + const seen = new Set(); + let allModels = filterByKind(merged.filter((m) => { + if (seen.has(m.value)) return false; + seen.add(m.value); + return true; + })); // Provider-as-model fallback: providers that support the kind but have no hardcoded models // can still be picked (value = providerAlias). Skips embedding (always needs model). @@ -265,8 +291,20 @@ export default function ModelSelectModal({ } }); + // Filter out disabled models per provider (disabled keyed by storage alias OR providerId) + Object.entries(groups).forEach(([providerId, group]) => { + const aliasKey = getProviderAlias(providerId); + const disabledIds = new Set([ + ...(disabledModels[aliasKey] || []), + ...(disabledModels[providerId] || []), + ]); + if (disabledIds.size === 0) return; + group.models = group.models.filter((m) => !disabledIds.has(m.id)); + if (group.models.length === 0) delete groups[providerId]; + }); + return groups; - }, [filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, kindFilter]); + }, [filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, disabledModels, kindFilter, activeProviders]); // Filter combos by search query (and hide combos when kindFilter is set — combos are LLM-only by design) const filteredCombos = useMemo(() => { diff --git a/src/shared/components/OAuthModal.js b/src/shared/components/OAuthModal.js index caf69841..d6bc3e85 100644 --- a/src/shared/components/OAuthModal.js +++ b/src/shared/components/OAuthModal.js @@ -173,24 +173,13 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, // Authorization code flow - build redirect URI (some providers require fixed ports) const appPort = window.location.port || (window.location.protocol === "https:" ? "443" : "80"); let redirectUri; - let codexProxyActive = false; - if (provider === "codex") { - // Try to start proxy on fixed port 1455 → redirect callback to app port - try { - const proxyRes = await fetch(`/api/oauth/codex/start-proxy?app_port=${appPort}`); - const proxyData = await proxyRes.json(); - codexProxyActive = proxyData.success; - } catch { - codexProxyActive = false; - } - // Always use fixed port 1455 as redirect_uri (Codex requirement) redirectUri = "http://localhost:1455/auth/callback"; } else { redirectUri = `http://localhost:${appPort}/callback`; } - // Build authorize URL, optionally passing provider-specific metadata (e.g. gitlab clientId) + // Build authorize URL first to get codeVerifier/state for codex server-side mode const authorizeUrl = new URL(`/api/oauth/${provider}/authorize`, window.location.origin); authorizeUrl.searchParams.set("redirect_uri", redirectUri); if (oauthMeta) { @@ -200,10 +189,29 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, const data = await res.json(); if (!res.ok) throw new Error(data.error); - setAuthData({ ...data, redirectUri }); + // Codex: start proxy with server-side session (auto-exchange) + fallback to channels + let codexProxyActive = false; + let codexServerSide = false; + if (provider === "codex") { + try { + const proxyUrl = new URL(`/api/oauth/codex/start-proxy`, window.location.origin); + proxyUrl.searchParams.set("app_port", appPort); + proxyUrl.searchParams.set("state", data.state); + proxyUrl.searchParams.set("code_verifier", data.codeVerifier); + proxyUrl.searchParams.set("redirect_uri", redirectUri); + const proxyRes = await fetch(proxyUrl.toString()); + const proxyData = await proxyRes.json(); + codexProxyActive = proxyData.success; + codexServerSide = !!proxyData.serverSide; + } catch { + codexProxyActive = false; + } + } + + setAuthData({ ...data, redirectUri, codexServerSide }); if (provider === "codex" && codexProxyActive) { - // Proxy active: callback will redirect to app port automatically + // Proxy active: callback will be handled server-side (auto-exchange) or via channels (fallback) setStep("waiting"); popupRef.current = window.open(data.authUrl, "oauth_popup", "width=600,height=700"); if (!popupRef.current) { @@ -247,6 +255,49 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, } }, [isOpen, provider, startOAuthFlow]); + // Codex server-side mode: poll status (proxy auto-exchanges + saves DB) + useEffect(() => { + if (!authData?.codexServerSide || !authData?.state) return; + if (callbackProcessedRef.current) return; + let cancelled = false; + const POLL_INTERVAL_MS = 1500; + const MAX_ATTEMPTS = 200; // ~5 minutes + let attempts = 0; + + const tick = async () => { + if (cancelled || callbackProcessedRef.current) return; + attempts += 1; + try { + const res = await fetch(`/api/oauth/codex/poll-status?state=${encodeURIComponent(authData.state)}`); + const data = await res.json(); + if (cancelled || callbackProcessedRef.current) return; + if (data.status === "done") { + callbackProcessedRef.current = true; + setStep("success"); + onSuccess?.(); + return; + } + if (data.status === "error") { + callbackProcessedRef.current = true; + setError(data.error || "Authentication failed"); + setStep("error"); + return; + } + } catch { + // Network error, keep polling + } + if (attempts >= MAX_ATTEMPTS) { + callbackProcessedRef.current = true; + setError("Authentication timeout"); + setStep("error"); + return; + } + setTimeout(tick, POLL_INTERVAL_MS); + }; + setTimeout(tick, POLL_INTERVAL_MS); + return () => { cancelled = true; }; + }, [authData, onSuccess]); + // Listen for OAuth callback via multiple methods useEffect(() => { if (!authData) return; diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js index 55481b03..918d3582 100644 --- a/src/shared/components/Sidebar.js +++ b/src/shared/components/Sidebar.js @@ -12,7 +12,7 @@ import Button from "./Button"; import { ConfirmModal } from "./Modal"; // const VISIBLE_MEDIA_KINDS = ["embedding", "image", "imageToText", "tts", "stt", "webSearch", "webFetch", "video", "music"]; -const VISIBLE_MEDIA_KINDS = ["embedding", "image", "tts"]; +const VISIBLE_MEDIA_KINDS = ["embedding", "image", "tts", "stt"]; // Combined entry: webSearch + webFetch share one page at /dashboard/media-providers/web const COMBINED_WEB_ITEM = { id: "web", label: "Web Fetch & Search", icon: "travel_explore", href: "/dashboard/media-providers/web" }; diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index 8ff1f114..71d6e395 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -114,6 +114,22 @@ export const CLI_TOOLS = { description: "OpenCode AI Terminal Assistant", configType: "custom", }, + cowork: { + id: "cowork", + name: "Claude Cowork", + image: "/providers/claude.png", + color: "#D97757", + description: "Claude Desktop Cowork (third-party inference)", + configType: "custom", + }, + hermes: { + id: "hermes", + name: "Hermes Agent", + image: "/providers/hermes.png", + color: "#8B5CF6", + description: "Nous Research self-improving AI agent", + configType: "custom", + }, droid: { id: "droid", name: "Factory Droid", @@ -212,14 +228,6 @@ export const CLI_TOOLS = { }`, }, }, - hermes: { - id: "hermes", - name: "Hermes Agent", - image: "/providers/hermes.png", - color: "#8B5CF6", - description: "Nous Research self-improving AI agent", - configType: "custom", - }, // HIDDEN: gemini-cli // "gemini-cli": { // id: "gemini-cli", diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js index 14c02f04..4423ad94 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -3,7 +3,7 @@ // Free Providers (kiro first, iflow last) export const FREE_PROVIDERS = { kiro: { id: "kiro", alias: "kr", name: "Kiro AI", icon: "psychology_alt", color: "#FF6B35", website: "https://kiro.dev", notice: { signupUrl: "https://kiro.dev" } }, - qwen: { id: "qwen", alias: "qw", name: "Qwen Code", icon: "psychology", color: "#10B981", deprecated: true, deprecationNotice: "Qwen OAuth free tier was discontinued by Alibaba on 2026-04-15. New connections will not work.", website: "https://chat.qwen.ai", notice: { signupUrl: "https://chat.qwen.ai" }, serviceKinds: ["llm", "tts", "stt"], ttsConfig: { baseUrl: "http://localhost:8000/v1/audio/speech", authType: "none", authHeader: "none", format: "openai", models: [{ id: "qwen3-tts", name: "Qwen3 TTS" }] } }, + qwen: { id: "qwen", alias: "qw", name: "Qwen Code", icon: "psychology", color: "#10B981", mediaPriority: 999, deprecated: true, deprecationNotice: "Qwen OAuth free tier was discontinued by Alibaba on 2026-04-15. New connections will not work.", website: "https://chat.qwen.ai", notice: { signupUrl: "https://chat.qwen.ai" }, serviceKinds: ["llm", "tts"], ttsConfig: { baseUrl: "http://localhost:8000/v1/audio/speech", authType: "none", authHeader: "none", format: "openai", models: [{ id: "qwen3-tts", name: "Qwen3 TTS" }] } }, "gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI", icon: "terminal", color: "#4285F4", deprecated: true, deprecationNotice: "Gemini CLI is designed exclusively for Gemini CLI. Using it with other tools (OpenClaw, Claude, Codex...) may result in account restrictions or bans.", website: "https://github.com/google-gemini/gemini-cli", notice: { signupUrl: "https://github.com/google-gemini/gemini-cli" } }, // gitlab: { id: "gitlab", alias: "gl", name: "GitLab Duo", icon: "code", color: "#FC6D26" }, // codebuddy: { id: "codebuddy", alias: "cb", name: "CodeBuddy", icon: "smart_toy", color: "#006EFF" }, @@ -15,10 +15,10 @@ export const FREE_PROVIDERS = { // Free Tier Providers (has free access but may require account/API key) export const FREE_TIER_PROVIDERS = { openrouter: { id: "openrouter", alias: "openrouter", name: "OpenRouter", icon: "router", color: "#F97316", textIcon: "OR", website: "https://openrouter.ai", notice: { text: "Free tier: 27+ free models, no credit card needed, 200 req/day. After $10 credit: 1,000 req/day.", apiKeyUrl: "https://openrouter.ai/settings/keys" }, modelsFetcher: { url: "https://openrouter.ai/api/v1/models", type: "openrouter-free" }, passthroughModels: true, serviceKinds: ["llm", "embedding", "tts", "imageToText"], embeddingConfig: { baseUrl: "https://openrouter.ai/api/v1/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "openai/text-embedding-3-small", name: "Text Embedding 3 Small (OpenRouter)", dimensions: 1536 }, { id: "openai/text-embedding-3-large", name: "Text Embedding 3 Large (OpenRouter)", dimensions: 3072 }, { id: "openai/text-embedding-ada-002", name: "Text Embedding Ada 002 (OpenRouter)", dimensions: 1536 }] } }, - 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" }, serviceKinds: ["llm", "tts", "embedding", "stt"], ttsConfig: { baseUrl: "https://integrate.api.nvidia.com/v1/audio/speech", authType: "apikey", authHeader: "bearer", format: "nvidia-tts", models: [{ id: "fastpitch", name: "FastPitch" }, { id: "tacotron2", name: "Tacotron2" }] }, embeddingConfig: { baseUrl: "https://integrate.api.nvidia.com/v1/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "nvidia/nv-embedqa-e5-v5", name: "NV EmbedQA E5 v5", dimensions: 1024 }] } }, + 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" }, serviceKinds: ["llm", "tts", "embedding"], ttsConfig: { baseUrl: "https://integrate.api.nvidia.com/v1/audio/speech", authType: "apikey", authHeader: "bearer", format: "nvidia-tts", models: [{ id: "fastpitch", name: "FastPitch" }, { id: "tacotron2", name: "Tacotron2" }] }, embeddingConfig: { baseUrl: "https://integrate.api.nvidia.com/v1/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "nvidia/nv-embedqa-e5-v5", name: "NV EmbedQA E5 v5", dimensions: 1024 }] } }, ollama: { id: "ollama", alias: "ollama", name: "Ollama Cloud", icon: "cloud", color: "#ffffffff", textIcon: "OL", website: "https://ollama.com", notice: { text: "Free tier: light usage, 1 cloud model at a time (limits reset every 5h & 7d). Pro $20/mo · Max $100/mo.", apiKeyUrl: "https://ollama.com/settings/keys" } }, vertex: { id: "vertex", alias: "vx", name: "Vertex AI", icon: "cloud", color: "#4285F4", textIcon: "VX", website: "https://cloud.google.com/vertex-ai", notice: { text: "New Google Cloud accounts get $300 free credits. Requires GCP project + Service Account with Vertex AI API enabled.", apiKeyUrl: "https://console.cloud.google.com/iam-admin/serviceaccounts" } }, - gemini: { id: "gemini", alias: "gemini", name: "Gemini", icon: "diamond", color: "#4285F4", textIcon: "GE", website: "https://ai.google.dev", notice: { apiKeyUrl: "https://aistudio.google.com/app/apikey" }, serviceKinds: ["llm", "embedding", "image", "imageToText", "webSearch"], searchViaChat: { defaultModel: "gemini-2.5-flash", pricingUrl: "https://ai.google.dev/pricing", freeTier: "Free tier: 15 RPM, 1M tokens/day on gemini-2.5-flash via AI Studio." }, embeddingConfig: { baseUrl: "https://generativelanguage.googleapis.com/v1beta/models", authType: "apikey", authHeader: "key", models: [{ id: "text-embedding-004", name: "Text Embedding 004", dimensions: 768 }, { id: "embedding-001", name: "Embedding 001", dimensions: 768 }] } }, + gemini: { id: "gemini", alias: "gemini", name: "Gemini", icon: "diamond", color: "#4285F4", textIcon: "GE", mediaPriority: 1, website: "https://ai.google.dev", notice: { apiKeyUrl: "https://aistudio.google.com/app/apikey" }, serviceKinds: ["llm", "embedding", "image", "imageToText", "webSearch", "tts", "stt"], sttConfig: { baseUrl: "https://generativelanguage.googleapis.com/v1beta/models", authType: "apikey", authHeader: "key", format: "gemini-stt", models: [{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro (Best)" }, { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" }, { id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite (Cheapest)" }, { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" }] }, searchViaChat: { defaultModel: "gemini-2.5-flash", pricingUrl: "https://ai.google.dev/pricing", freeTier: "Free tier: 15 RPM, 1M tokens/day on gemini-2.5-flash via AI Studio." }, embeddingConfig: { baseUrl: "https://generativelanguage.googleapis.com/v1beta/models", authType: "apikey", authHeader: "key", models: [{ id: "text-embedding-004", name: "Text Embedding 004", dimensions: 768 }, { id: "embedding-001", name: "Embedding 001", dimensions: 768 }] }, ttsConfig: { baseUrl: "https://generativelanguage.googleapis.com/v1beta/models", authType: "apikey", authHeader: "key", format: "gemini-tts", models: [{ id: "gemini-2.5-flash-preview-tts", name: "Gemini 2.5 Flash TTS" }, { id: "gemini-2.5-pro-preview-tts", name: "Gemini 2.5 Pro TTS" }] } }, "cloudflare-ai": { id: "cloudflare-ai", alias: "cf", name: "Cloudflare", icon: "cloud", color: "#F38020", textIcon: "CF", website: "https://developers.cloudflare.com/workers-ai/", notice: { text: "Workers AI free tier. Requires a Cloudflare API token and Account ID.", apiKeyUrl: "https://dash.cloudflare.com/profile/api-tokens" }, serviceKinds: ["llm"], hasProviderSpecificData: true }, byteplus: { id: "byteplus", alias: "bpm", name: "BytePlus ModelArk", icon: "cloud", color: "#2563EB", textIcon: "BP", website: "https://console.byteplus.com/ark", notice: { text: "Free credits for new accounts. Access to Seed 2.0, Kimi K2 Thinking, GLM 4.7, GPT-OSS-120B models.", apiKeyUrl: "https://console.byteplus.com/ark/region:ark+ap-southeast-1/apiKey" }, serviceKinds: ["llm"] }, }; @@ -63,13 +63,13 @@ export const APIKEY_PROVIDERS = { "alicode-intl": { id: "alicode-intl", alias: "alicode-intl", name: "Alibaba Intl", icon: "cloud", color: "#FF6A00", textIcon: "ALi", website: "https://modelstudio.console.alibabacloud.com", notice: { apiKeyUrl: "https://modelstudio.console.alibabacloud.com/?apiKey=1" } }, "xiaomi-mimo": { id: "xiaomi-mimo", alias: "mimo", name: "Xiaomi MiMo", icon: "smart_toy", color: "#FF6900", textIcon: "XM", website: "https://xiaomimimo.com", notice: { apiKeyUrl: "https://xiaomimimo.com" } }, "volcengine-ark": { id: "volcengine-ark", alias: "ark", name: "Volcengine Ark", icon: "cloud", color: "#1677FF", textIcon: "ARK", website: "https://ark.cn-beijing.volces.com", notice: { apiKeyUrl: "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey" } }, - openai: { id: "openai", alias: "openai", name: "OpenAI", icon: "auto_awesome", color: "#10A37F", textIcon: "OA", website: "https://platform.openai.com", notice: { apiKeyUrl: "https://platform.openai.com/api-keys" }, serviceKinds: ["llm", "embedding", "tts", "image", "imageToText", "webSearch"], thinkingConfig: THINKING_CONFIG.effort, searchViaChat: { defaultModel: "gpt-4o-mini", pricingUrl: "https://openai.com/api/pricing" }, ttsConfig: { baseUrl: "https://api.openai.com/v1/audio/speech", authType: "apikey", authHeader: "bearer", format: "openai", models: [{ id: "tts-1", name: "TTS-1" }, { id: "tts-1-hd", name: "TTS-1 HD" }, { id: "gpt-4o-mini-tts", name: "GPT-4o Mini TTS" }] }, embeddingConfig: { baseUrl: "https://api.openai.com/v1/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "text-embedding-3-small", name: "Text Embedding 3 Small", dimensions: 1536 }, { id: "text-embedding-3-large", name: "Text Embedding 3 Large", dimensions: 3072 }, { id: "text-embedding-ada-002", name: "Text Embedding Ada 002", dimensions: 1536 }] } }, + openai: { id: "openai", alias: "openai", name: "OpenAI", icon: "auto_awesome", color: "#10A37F", textIcon: "OA", website: "https://platform.openai.com", notice: { apiKeyUrl: "https://platform.openai.com/api-keys" }, serviceKinds: ["llm", "embedding", "tts", "stt", "image", "imageToText", "webSearch"], thinkingConfig: THINKING_CONFIG.effort, searchViaChat: { defaultModel: "gpt-4o-mini", pricingUrl: "https://openai.com/api/pricing" }, ttsConfig: { baseUrl: "https://api.openai.com/v1/audio/speech", authType: "apikey", authHeader: "bearer", format: "openai", models: [{ id: "tts-1", name: "TTS-1" }, { id: "tts-1-hd", name: "TTS-1 HD" }, { id: "gpt-4o-mini-tts", name: "GPT-4o Mini TTS" }] }, sttConfig: { baseUrl: "https://api.openai.com/v1/audio/transcriptions", authType: "apikey", authHeader: "bearer", format: "openai", models: [{ id: "whisper-1", name: "Whisper 1" }, { id: "gpt-4o-transcribe", name: "GPT-4o Transcribe" }, { id: "gpt-4o-mini-transcribe", name: "GPT-4o Mini Transcribe" }] }, embeddingConfig: { baseUrl: "https://api.openai.com/v1/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "text-embedding-3-small", name: "Text Embedding 3 Small", dimensions: 1536 }, { id: "text-embedding-3-large", name: "Text Embedding 3 Large", dimensions: 3072 }, { id: "text-embedding-ada-002", name: "Text Embedding Ada 002", dimensions: 1536 }] } }, anthropic: { id: "anthropic", alias: "anthropic", name: "Anthropic", icon: "smart_toy", color: "#D97757", textIcon: "AN", website: "https://console.anthropic.com", notice: { apiKeyUrl: "https://console.anthropic.com/settings/keys" }, serviceKinds: ["llm", "imageToText"] }, "opencode-go": { id: "opencode-go", alias: "ocg", name: "OpenCode Go", icon: "terminal", color: "#E87040", textIcon: "OC", website: "https://opencode.ai/auth", notice: { text: "OpenCode Go subscription: $5/mo (then $10/mo). Access to Kimi, GLM, Qwen, MiMo, MiniMax models.", apiKeyUrl: "https://opencode.ai/auth" } }, azure: { id: "azure", alias: "azure", name: "Azure OpenAI", icon: "cloud", color: "#0078D4", textIcon: "AZ", website: "https://azure.microsoft.com/en-us/products/ai-services/openai-service", notice: { apiKeyUrl: "https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/OpenAI" }, hasProviderSpecificData: true }, deepseek: { id: "deepseek", alias: "ds", name: "DeepSeek", icon: "bolt", color: "#4D6BFE", textIcon: "DS", website: "https://deepseek.com", notice: { apiKeyUrl: "https://platform.deepseek.com/api_keys" } }, - groq: { id: "groq", alias: "groq", name: "Groq", icon: "speed", color: "#F55036", textIcon: "GQ", website: "https://groq.com", notice: { apiKeyUrl: "https://console.groq.com/keys" }, serviceKinds: ["llm", "imageToText"] }, + groq: { id: "groq", alias: "groq", name: "Groq", icon: "speed", color: "#F55036", textIcon: "GQ", website: "https://groq.com", notice: { apiKeyUrl: "https://console.groq.com/keys" }, serviceKinds: ["llm", "imageToText", "stt"], sttConfig: { baseUrl: "https://api.groq.com/openai/v1/audio/transcriptions", authType: "apikey", authHeader: "bearer", format: "openai", models: [{ id: "whisper-large-v3", name: "Whisper Large v3" }, { id: "whisper-large-v3-turbo", name: "Whisper Large v3 Turbo" }, { id: "distil-whisper-large-v3-en", name: "Distil Whisper Large v3 EN" }] } }, xai: { id: "xai", alias: "xai", name: "xAI (Grok)", icon: "auto_awesome", color: "#1DA1F2", textIcon: "XA", website: "https://x.ai", notice: { apiKeyUrl: "https://console.x.ai" }, serviceKinds: ["llm", "imageToText", "webSearch"], searchViaChat: { defaultModel: "grok-4.20-reasoning", pricingUrl: "https://x.ai/api#pricing" } }, mistral: { id: "mistral", alias: "mistral", name: "Mistral", icon: "air", color: "#FF7000", textIcon: "MI", website: "https://mistral.ai", notice: { apiKeyUrl: "https://console.mistral.ai/api-keys" }, serviceKinds: ["llm", "imageToText", "embedding"], embeddingConfig: { baseUrl: "https://api.mistral.ai/v1/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "mistral-embed", name: "Mistral Embed", dimensions: 1024 }] } }, perplexity: { id: "perplexity", alias: "pplx", name: "Perplexity", icon: "search", color: "#20808D", textIcon: "PP", website: "https://www.perplexity.ai", notice: { apiKeyUrl: "https://www.perplexity.ai/settings/api" }, serviceKinds: ["llm", "webSearch"], searchConfig: { baseUrl: "https://api.perplexity.ai/search", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.005, freeMonthlyQuota: 0, searchTypes: ["web"], defaultMaxResults: 5, maxMaxResults: 20, timeoutMs: 10000, cacheTTLMs: 300000 } }, @@ -80,22 +80,22 @@ export const APIKEY_PROVIDERS = { nebius: { id: "nebius", alias: "nebius", name: "Nebius AI", icon: "cloud", color: "#6C5CE7", textIcon: "NB", website: "https://nebius.com", notice: { apiKeyUrl: "https://studio.nebius.com/settings/api-keys" }, serviceKinds: ["llm", "embedding"], embeddingConfig: { baseUrl: "https://api.tokenfactory.nebius.com/v1/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "Qwen/Qwen3-Embedding-8B", name: "Qwen3 Embedding 8B", dimensions: 4096 }] } }, siliconflow: { id: "siliconflow", alias: "siliconflow", name: "SiliconFlow", icon: "cloud_queue", color: "#5B6EF5", textIcon: "SF", website: "https://cloud.siliconflow.com", notice: { apiKeyUrl: "https://cloud.siliconflow.com/account/ak" } }, hyperbolic: { id: "hyperbolic", alias: "hyp", name: "Hyperbolic", icon: "bolt", color: "#00D4FF", textIcon: "HY", website: "https://hyperbolic.xyz", notice: { apiKeyUrl: "https://app.hyperbolic.xyz/settings" }, serviceKinds: ["llm", "tts"], ttsConfig: { baseUrl: "https://api.hyperbolic.xyz/v1/audio/generation", authType: "apikey", authHeader: "bearer", format: "hyperbolic", models: [{ id: "melo-tts", name: "Melo TTS" }] } }, - deepgram: { id: "deepgram", alias: "dg", name: "Deepgram", icon: "mic", color: "#13EF93", textIcon: "DG", website: "https://deepgram.com", notice: { text: "$200 free credit on signup (no card required). Aura-1: $0.015/1k chars, Aura-2: $0.030/1k chars (Pay-As-You-Go).", apiKeyUrl: "https://console.deepgram.com/api-keys" }, serviceKinds: ["stt", "imageToText", "tts"], ttsConfig: { baseUrl: "https://api.deepgram.com/v1/speak", authType: "apikey", authHeader: "token", format: "deepgram", models: [] } }, - assemblyai: { id: "assemblyai", alias: "aai", name: "AssemblyAI", icon: "record_voice_over", color: "#0062FF", textIcon: "AA", website: "https://assemblyai.com", notice: { apiKeyUrl: "https://www.assemblyai.com/app/api-keys" }, serviceKinds: ["stt"] }, + deepgram: { id: "deepgram", alias: "dg", name: "Deepgram", icon: "mic", color: "#13EF93", textIcon: "DG", website: "https://deepgram.com", notice: { text: "$200 free credit on signup (no card required). Aura-1: $0.015/1k chars, Aura-2: $0.030/1k chars (Pay-As-You-Go).", apiKeyUrl: "https://console.deepgram.com/api-keys" }, serviceKinds: ["stt", "imageToText", "tts"], ttsConfig: { baseUrl: "https://api.deepgram.com/v1/speak", authType: "apikey", authHeader: "token", format: "deepgram", models: [] }, sttConfig: { baseUrl: "https://api.deepgram.com/v1/listen", authType: "apikey", authHeader: "token", format: "deepgram", models: [{ id: "nova-3", name: "Nova 3" }, { id: "nova-2", name: "Nova 2" }, { id: "whisper-large", name: "Whisper Large" }] } }, + assemblyai: { id: "assemblyai", alias: "aai", name: "AssemblyAI", icon: "record_voice_over", color: "#0062FF", textIcon: "AA", website: "https://assemblyai.com", notice: { apiKeyUrl: "https://www.assemblyai.com/app/api-keys" }, serviceKinds: ["stt"], sttConfig: { baseUrl: "https://api.assemblyai.com/v2/transcript", authType: "apikey", authHeader: "bearer", format: "assemblyai", async: true, models: [{ id: "universal-3-pro", name: "Universal 3 Pro" }, { id: "universal-2", name: "Universal 2" }] } }, nanobanana: { id: "nanobanana", alias: "nb", name: "NanoBanana API", icon: "extension", color: "#FFD700", textIcon: "🍌", website: "https://nanobananaapi.ai", notice: { text: "3rd-party proxy for Google Nano Banana (Gemini 2.5/3 Flash Image). For official, use Gemini provider.", apiKeyUrl: "https://nanobananaapi.ai/dashboard" }, serviceKinds: ["image"] }, elevenlabs: { id: "elevenlabs", alias: "el", name: "ElevenLabs", icon: "record_voice_over", color: "#6C47FF", textIcon: "EL", website: "https://elevenlabs.io", notice: { apiKeyUrl: "https://elevenlabs.io/app/settings/api-keys" }, serviceKinds: ["tts"], ttsConfig: { baseUrl: "https://api.elevenlabs.io/v1/text-to-speech", authType: "apikey", authHeader: "xi-api-key", format: "elevenlabs", models: [{ id: "eleven_multilingual_v2", name: "Eleven Multilingual v2" }, { id: "eleven_turbo_v2_5", name: "Eleven Turbo v2.5" }] } }, cartesia: { id: "cartesia", alias: "cartesia", name: "Cartesia", icon: "spatial_audio", color: "#FF4F8B", textIcon: "CA", website: "https://cartesia.ai", notice: { apiKeyUrl: "https://play.cartesia.ai/keys" }, serviceKinds: ["tts"], hidden: true, ttsConfig: { baseUrl: "https://api.cartesia.ai/tts/bytes", authType: "apikey", authHeader: "x-api-key", format: "cartesia", models: [{ id: "sonic-2", name: "Sonic 2" }, { id: "sonic-3", name: "Sonic 3" }] } }, playht: { id: "playht", alias: "playht", name: "PlayHT", icon: "play_circle", color: "#00B4D8", textIcon: "PH", website: "https://play.ht", notice: { apiKeyUrl: "https://play.ht/studio/api-access" }, serviceKinds: ["tts"], hidden: true, ttsConfig: { baseUrl: "https://api.play.ht/api/v2/tts/stream", authType: "apikey", authHeader: "playht", format: "playht", models: [{ id: "PlayDialog", name: "PlayDialog" }, { id: "Play3.0-mini", name: "Play 3.0 Mini" }] } }, - "local-device": { id: "local-device", alias: "local-device", name: "Local Device", icon: "speaker", color: "#64748B", textIcon: "LD", serviceKinds: ["tts"], noAuth: true, ttsConfig: { baseUrl: "local-device", authType: "none", authHeader: "none", format: "local-device", models: [] } }, - "google-tts": { id: "google-tts", alias: "google-tts", name: "Google TTS", icon: "record_voice_over", color: "#4285F4", textIcon: "GT", serviceKinds: ["tts"], noAuth: true, ttsConfig: { baseUrl: "google-tts", authType: "none", authHeader: "none", format: "google-tts", models: [] } }, - "edge-tts": { id: "edge-tts", alias: "edge-tts", name: "Edge TTS", icon: "record_voice_over", color: "#0078D4", textIcon: "ET", serviceKinds: ["tts"], noAuth: true, ttsConfig: { baseUrl: "edge-tts", authType: "none", authHeader: "none", format: "edge-tts", models: [] } }, + "local-device": { id: "local-device", alias: "local-device", name: "Local Device", icon: "speaker", color: "#64748B", textIcon: "LD", mediaPriority: 5, serviceKinds: ["tts"], noAuth: true, ttsConfig: { baseUrl: "local-device", authType: "none", authHeader: "none", format: "local-device", models: [] } }, + "google-tts": { id: "google-tts", alias: "google-tts", name: "Google TTS", icon: "record_voice_over", color: "#4285F4", textIcon: "GT", mediaPriority: 5, serviceKinds: ["tts"], noAuth: true, ttsConfig: { baseUrl: "google-tts", authType: "none", authHeader: "none", format: "google-tts", models: [] } }, + "edge-tts": { id: "edge-tts", alias: "edge-tts", name: "Edge TTS", icon: "record_voice_over", color: "#0078D4", textIcon: "ET", mediaPriority: 5, serviceKinds: ["tts"], noAuth: true, ttsConfig: { baseUrl: "edge-tts", authType: "none", authHeader: "none", format: "edge-tts", models: [] } }, coqui: { id: "coqui", alias: "coqui", name: "Coqui TTS", icon: "record_voice_over", color: "#10B981", textIcon: "CQ", website: "https://github.com/coqui-ai/TTS", serviceKinds: ["tts"], hidden: true, noAuth: true, ttsConfig: { baseUrl: "http://localhost:5002/api/tts", authType: "none", authHeader: "none", format: "coqui", models: [{ id: "tts_models/en/ljspeech/tacotron2-DDC", name: "Tacotron2 DDC (LJSpeech)" }] } }, tortoise: { id: "tortoise", alias: "tortoise", name: "Tortoise TTS", icon: "record_voice_over", color: "#7C3AED", textIcon: "TT", website: "https://github.com/neonbjb/tortoise-tts", serviceKinds: ["tts"], hidden: true, noAuth: true, ttsConfig: { baseUrl: "http://localhost:5000/api/tts", authType: "none", authHeader: "none", format: "tortoise", models: [{ id: "tortoise-v2", name: "Tortoise v2" }] } }, inworld: { id: "inworld", alias: "inworld", name: "Inworld TTS", icon: "record_voice_over", color: "#FF6B6B", textIcon: "IW", website: "https://inworld.ai", notice: { text: "Free tier: 40 minutes/month TTS. Paid: TTS-1.5 Mini $0.01/min ($15/1M chars), TTS-1.5 Max $0.025/min ($30/1M chars). 270+ voices, 15 languages.", apiKeyUrl: "https://platform.inworld.ai/api-keys" }, serviceKinds: ["tts"], ttsConfig: { baseUrl: "https://api.inworld.ai/tts/v1/voice", authType: "apikey", authHeader: "basic", format: "inworld", models: [{ id: "inworld-tts-1.5-mini", name: "Inworld TTS 1.5 Mini ($0.01/min)" }, { id: "inworld-tts-1.5-max", name: "Inworld TTS 1.5 Max ($0.025/min)" }] } }, "voyage-ai": { id: "voyage-ai", alias: "voyage", name: "Voyage AI", icon: "data_array", color: "#0EA5E9", textIcon: "VG", website: "https://www.voyageai.com", notice: { apiKeyUrl: "https://dash.voyageai.com/api-keys" }, serviceKinds: ["embedding"], embeddingConfig: { baseUrl: "https://api.voyageai.com/v1/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "voyage-3-large", name: "Voyage 3 Large", dimensions: 1024 }, { id: "voyage-3.5", name: "Voyage 3.5", dimensions: 1024 }, { id: "voyage-3.5-lite", name: "Voyage 3.5 Lite", dimensions: 1024 }, { id: "voyage-code-3", name: "Voyage Code 3", dimensions: 1024 }, { id: "voyage-finance-2", name: "Voyage Finance 2", dimensions: 1024 }, { id: "voyage-law-2", name: "Voyage Law 2", dimensions: 1024 }, { id: "voyage-multilingual-2", name: "Voyage Multilingual 2", dimensions: 1024 }] } }, sdwebui: { id: "sdwebui", alias: "sdwebui", name: "SD WebUI", icon: "brush", color: "#FF7043", textIcon: "SD", website: "https://github.com/AUTOMATIC1111/stable-diffusion-webui", serviceKinds: ["image"] }, comfyui: { id: "comfyui", alias: "comfyui", name: "ComfyUI", icon: "account_tree", color: "#4CAF50", textIcon: "CF", website: "https://github.com/comfyanonymous/ComfyUI", serviceKinds: ["image"] }, - huggingface: { id: "huggingface", alias: "hf", name: "HuggingFace", icon: "face", color: "#FFD21E", textIcon: "HF", website: "https://huggingface.co", notice: { apiKeyUrl: "https://huggingface.co/settings/tokens" }, serviceKinds: ["image", "imageToText", "tts"], hiddenKinds: ["tts"], ttsConfig: { baseUrl: "https://api-inference.huggingface.co/models", authType: "apikey", authHeader: "bearer", format: "huggingface-tts", models: [{ id: "facebook/mms-tts-eng", name: "MMS TTS English" }, { id: "microsoft/speecht5_tts", name: "SpeechT5 TTS" }] } }, + huggingface: { id: "huggingface", alias: "hf", name: "HuggingFace", icon: "face", color: "#FFD21E", textIcon: "HF", website: "https://huggingface.co", notice: { apiKeyUrl: "https://huggingface.co/settings/tokens" }, serviceKinds: ["image", "imageToText", "tts", "stt"], hiddenKinds: ["tts"], ttsConfig: { baseUrl: "https://api-inference.huggingface.co/models", authType: "apikey", authHeader: "bearer", format: "huggingface-tts", models: [{ id: "facebook/mms-tts-eng", name: "MMS TTS English" }, { id: "microsoft/speecht5_tts", name: "SpeechT5 TTS" }] }, sttConfig: { baseUrl: "https://api-inference.huggingface.co/models", authType: "apikey", authHeader: "bearer", format: "huggingface-asr", models: [{ id: "openai/whisper-large-v3", name: "Whisper Large v3 (HF)" }, { id: "openai/whisper-small", name: "Whisper Small (HF)" }] } }, blackbox: { id: "blackbox", alias: "bb", name: "Blackbox AI", icon: "smart_toy", color: "#5B5FEF", textIcon: "BB", website: "https://blackbox.ai", notice: { apiKeyUrl: "https://www.blackbox.ai/api-management" }, serviceKinds: ["llm"] }, chutes: { id: "chutes", alias: "ch", name: "Chutes AI", icon: "water_drop", color: "#ffffffff", textIcon: "CH", website: "https://chutes.ai", notice: { apiKeyUrl: "https://chutes.ai/app/api" } }, "ollama-local": { id: "ollama-local", alias: "ollama-local", name: "Ollama Local", icon: "cloud", color: "#ffffffff", textIcon: "OL", website: "https://ollama.com" }, @@ -133,7 +133,7 @@ export const MEDIA_PROVIDER_KINDS = [ { id: "image", label: "Text to Image", icon: "brush", endpoint: { method: "POST", path: "/v1/images/generations" } }, { id: "imageToText", label: "Image to Text", icon: "image_search", endpoint: { method: "POST", path: "/v1/images/understanding" } }, { id: "tts", label: "Text To Speech", icon: "record_voice_over", endpoint: { method: "POST", path: "/v1/audio/speech" } }, - { id: "stt", label: "STT", icon: "mic", endpoint: { method: "POST", path: "/v1/audio/transcriptions" } }, + { id: "stt", label: "Speech To Text", icon: "mic", endpoint: { method: "POST", path: "/v1/audio/transcriptions" } }, { id: "webSearch", label: "Web Search", icon: "travel_explore", endpoint: { method: "POST", path: "/v1/search" } }, { id: "webFetch", label: "Web Fetch", icon: "language", endpoint: { method: "POST", path: "/v1/web/fetch" } }, { id: "video", label: "Video", icon: "movie", endpoint: { method: "POST", path: "/v1/video/generations" } }, @@ -203,13 +203,15 @@ export const ID_TO_ALIAS = Object.values(AI_PROVIDERS).reduce((acc, p) => { // Helper: Get providers by service kind (e.g. "tts", "embedding", "image") // Providers without serviceKinds default to ["llm"] export function getProvidersByKind(kind) { - return Object.values(AI_PROVIDERS).filter((p) => { - const kinds = p.serviceKinds ?? ["llm"]; - if (!kinds.includes(kind)) return false; - if (p.hidden) return false; // globally hidden - if (p.hiddenKinds?.includes(kind)) return false; // hidden for specific kind - return true; - }); + return Object.values(AI_PROVIDERS) + .filter((p) => { + const kinds = p.serviceKinds ?? ["llm"]; + if (!kinds.includes(kind)) return false; + if (p.hidden) return false; + if (p.hiddenKinds?.includes(kind)) return false; + return true; + }) + .sort((a, b) => (a.mediaPriority ?? 100) - (b.mediaPriority ?? 100)); } // Providers that support usage/quota API @@ -221,4 +223,17 @@ export const USAGE_SUPPORTED_PROVIDERS = [ "codex", "kimi-coding", "ollama", + "gemini-cli", + "glm", + "glm-cn", + "minimax", + "minimax-cn", +]; + +// Subset that uses apikey auth (still surfaced on quota page) +export const USAGE_APIKEY_PROVIDERS = [ + "glm", + "glm-cn", + "minimax", + "minimax-cn", ]; diff --git a/src/shared/constants/skills.js b/src/shared/constants/skills.js index 28f41392..1f758e32 100644 --- a/src/shared/constants/skills.js +++ b/src/shared/constants/skills.js @@ -39,6 +39,13 @@ export const SKILLS = [ endpoint: "/v1/audio/speech", icon: "record_voice_over", }, + { + id: "9router-stt", + name: "Speech-to-Text", + description: "Transcribe audio via OpenAI Whisper, Groq, Gemini, Deepgram, AssemblyAI…", + endpoint: "/v1/audio/transcriptions", + icon: "mic", + }, { id: "9router-embeddings", name: "Embeddings", diff --git a/src/shared/constants/ttsProviders.js b/src/shared/constants/ttsProviders.js index bc2e0324..e59d7f08 100644 --- a/src/shared/constants/ttsProviders.js +++ b/src/shared/constants/ttsProviders.js @@ -109,4 +109,14 @@ export const TTS_PROVIDER_CONFIG = { hasVoiceIdInput: true, voiceSource: "config", }, + "gemini": { + hasLanguageDropdown: false, + hasLanguageHint: true, // sends body.language to guide TTS pronunciation + hasModelSelector: true, + hasBrowseButton: false, + voiceSource: "hardcoded", + modelKey: "gemini-tts-models", + voiceKey: "gemini-tts-voices", + voicesPerModel: true, + }, }; diff --git a/src/sse/handlers/stt.js b/src/sse/handlers/stt.js new file mode 100644 index 00000000..501fcfb1 --- /dev/null +++ b/src/sse/handlers/stt.js @@ -0,0 +1,88 @@ +import { + extractApiKey, isValidApiKey, + getProviderCredentials, markAccountUnavailable, +} from "../services/auth.js"; +import { getSettings } from "@/lib/localDb"; +import { getModelInfo } from "../services/model.js"; +import { handleSttCore } from "open-sse/handlers/sttCore.js"; +import { errorResponse, unavailableResponse } from "open-sse/utils/error.js"; +import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js"; +import { AI_PROVIDERS } from "@/shared/constants/providers"; +import * as log from "../utils/logger.js"; + +// Providers requiring credentials for STT +const CREDENTIALED_PROVIDERS = new Set( + Object.entries(AI_PROVIDERS) + .filter(([, p]) => p.serviceKinds?.includes("stt") && !p.noAuth && p.sttConfig?.authType !== "none") + .map(([id]) => id) +); + +export async function handleStt(request) { + let formData; + try { + formData = await request.formData(); + } catch { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid multipart form data"); + } + + const modelStr = formData.get("model"); + log.request("POST", `/v1/audio/transcriptions | ${modelStr}`); + + const settings = await getSettings(); + if (settings.requireApiKey) { + const apiKey = extractApiKey(request); + if (!apiKey) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key"); + const valid = await isValidApiKey(apiKey); + if (!valid) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); + } + + if (!modelStr) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model"); + if (!formData.get("file")) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: file"); + + const modelInfo = await getModelInfo(modelStr); + if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format"); + + const { provider, model } = modelInfo; + log.info("ROUTING", `Provider: ${provider}, Model: ${model}`); + + // noAuth providers + if (!CREDENTIALED_PROVIDERS.has(provider)) { + const result = await handleSttCore({ provider, model, formData }); + if (result.success) return result.response; + return errorResponse(result.status || HTTP_STATUS.BAD_GATEWAY, result.error || "STT failed"); + } + + // Credentialed — fallback loop + const excludeConnectionIds = new Set(); + let lastError = null; + let lastStatus = null; + + while (true) { + const credentials = await getProviderCredentials(provider, excludeConnectionIds, model); + + if (!credentials || credentials.allRateLimited) { + if (credentials?.allRateLimited) { + const msg = lastError || credentials.lastError || "Unavailable"; + const status = lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE; + return unavailableResponse(status, `[${provider}/${model}] ${msg}`, credentials.retryAfter, credentials.retryAfterHuman); + } + if (excludeConnectionIds.size === 0) return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`); + return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable"); + } + + log.info("AUTH", `\x1b[32mUsing ${provider} account: ${credentials.connectionName}\x1b[0m`); + + const result = await handleSttCore({ provider, model, formData, credentials }); + + if (result.success) return result.response; + + const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model); + if (shouldFallback) { + excludeConnectionIds.add(credentials.connectionId); + lastError = result.error; + lastStatus = result.status; + continue; + } + return result.response || errorResponse(result.status, result.error); + } +} diff --git a/src/sse/handlers/tts.js b/src/sse/handlers/tts.js index 443a74f3..828d45b6 100644 --- a/src/sse/handlers/tts.js +++ b/src/sse/handlers/tts.js @@ -29,7 +29,8 @@ export async function handleTts(request) { const url = new URL(request.url); const modelStr = body.model; const responseFormat = url.searchParams.get("response_format") || "mp3"; // mp3 (default) | json - log.request("POST", `${url.pathname} | ${modelStr} | format=${responseFormat}`); + const language = body.language || ""; // Optional language hint (currently used by Gemini) + log.request("POST", `${url.pathname} | ${modelStr} | format=${responseFormat}${language ? ` | lang=${language}` : ""}`); const settings = await getSettings(); if (settings.requireApiKey) { @@ -52,7 +53,7 @@ export async function handleTts(request) { return handleComboChat({ body, models: comboModels, - handleSingleModel: (b, m) => handleSingleModelTts(b, m, responseFormat), + handleSingleModel: (b, m) => handleSingleModelTts(b, m, responseFormat, language), log, comboName: modelStr, comboStrategy, @@ -60,10 +61,10 @@ export async function handleTts(request) { }); } - return handleSingleModelTts(body, modelStr, responseFormat); + return handleSingleModelTts(body, modelStr, responseFormat, language); } -async function handleSingleModelTts(body, modelStr, responseFormat) { +async function handleSingleModelTts(body, modelStr, responseFormat, language) { const modelInfo = await getModelInfo(modelStr); if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format"); @@ -72,7 +73,7 @@ async function handleSingleModelTts(body, modelStr, responseFormat) { // noAuth providers — no credential needed if (!CREDENTIALED_PROVIDERS.has(provider)) { - const result = await handleTtsCore({ provider, model, input: body.input, responseFormat }); + const result = await handleTtsCore({ provider, model, input: body.input, responseFormat, language }); if (result.success) return result.response; return errorResponse(result.status || HTTP_STATUS.BAD_GATEWAY, result.error || "TTS failed"); } @@ -97,7 +98,7 @@ async function handleSingleModelTts(body, modelStr, responseFormat) { log.info("AUTH", `\x1b[32mUsing ${provider} account: ${credentials.connectionName}\x1b[0m`); - const result = await handleTtsCore({ provider, model, input: body.input, credentials, responseFormat }); + const result = await handleTtsCore({ provider, model, input: body.input, credentials, responseFormat, language }); if (result.success) return result.response; diff --git a/src/store/headerSearchStore.js b/src/store/headerSearchStore.js new file mode 100644 index 00000000..77927873 --- /dev/null +++ b/src/store/headerSearchStore.js @@ -0,0 +1,19 @@ +/** + * Header Search Store — Zustand-based reusable search input in Header. + * Pages register placeholder on mount, read query, unregister on unmount. + */ + +import { create } from "zustand"; + +export const useHeaderSearchStore = create((set) => ({ + query: "", + placeholder: "", + visible: false, + + setQuery: (query) => set({ query }), + + register: (placeholder = "Search...") => + set({ visible: true, placeholder, query: "" }), + + unregister: () => set({ visible: false, placeholder: "", query: "" }), +}));