diff --git a/open-sse/config/audioRegistry.ts b/open-sse/config/audioRegistry.ts index dee3994671..a61fd64701 100644 --- a/open-sse/config/audioRegistry.ts +++ b/open-sse/config/audioRegistry.ts @@ -99,6 +99,7 @@ export const AUDIO_TRANSCRIPTION_PROVIDERS: Record = { baseUrl: "http://localhost:8000/v1/audio/transcriptions", authType: "none", authHeader: "none", + format: "openai", models: [ { id: "qwen3-asr", name: "Qwen3 ASR" }, ], @@ -203,6 +204,7 @@ export const AUDIO_SPEECH_PROVIDERS: Record = { baseUrl: "http://localhost:8000/v1/audio/speech", authType: "none", authHeader: "none", + format: "openai", models: [ { id: "qwen3-tts", name: "Qwen3 TTS" }, ], diff --git a/open-sse/handlers/audioSpeech.ts b/open-sse/handlers/audioSpeech.ts index 884adef5c7..77095edf14 100644 --- a/open-sse/handlers/audioSpeech.ts +++ b/open-sse/handlers/audioSpeech.ts @@ -20,6 +20,42 @@ import { getSpeechProvider, parseSpeechModel } from "../config/audioRegistry.ts" import { buildAuthHeaders } from "../config/registryUtils.ts"; import { errorResponse } from "../utils/error.ts"; +/** + * Return a CORS error response from an upstream fetch failure + */ +function upstreamErrorResponse(res, errText) { + return new Response(errText, { + status: res.status, + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": getCorsOrigin(), + }, + }); +} + +/** + * Return a CORS audio stream response + */ +function audioStreamResponse(res, defaultContentType = "audio/mpeg") { + const contentType = res.headers.get("content-type") || defaultContentType; + return new Response(res.body, { + status: 200, + headers: { + "Content-Type": contentType, + "Access-Control-Allow-Origin": getCorsOrigin(), + "Transfer-Encoding": "chunked", + }, + }); +} + +/** + * Validate a path segment to prevent path traversal / SSRF. + * Returns true if safe, false if it contains traversal sequences. + */ +function isValidPathSegment(segment: string): boolean { + return !segment.includes("..") && !segment.includes("//"); +} + /** * Handle Hyperbolic TTS (returns base64 audio in JSON) */ @@ -34,14 +70,7 @@ async function handleHyperbolicSpeech(providerConfig, body, token) { }); if (!res.ok) { - const errText = await res.text(); - return new Response(errText, { - status: res.status, - headers: { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": getCorsOrigin(), - }, - }); + return upstreamErrorResponse(res, await res.text()); } const data = await res.json(); @@ -74,25 +103,10 @@ async function handleDeepgramSpeech(providerConfig, body, modelId, token) { }); if (!res.ok) { - const errText = await res.text(); - return new Response(errText, { - status: res.status, - headers: { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": getCorsOrigin(), - }, - }); + return upstreamErrorResponse(res, await res.text()); } - const contentType = res.headers.get("content-type") || "audio/mpeg"; - return new Response(res.body, { - status: 200, - headers: { - "Content-Type": contentType, - "Access-Control-Allow-Origin": getCorsOrigin(), - "Transfer-Encoding": "chunked", - }, - }); + return audioStreamResponse(res); } /** @@ -103,6 +117,9 @@ async function handleDeepgramSpeech(providerConfig, body, modelId, token) { async function handleElevenLabsSpeech(providerConfig, body, modelId, token) { // ElevenLabs uses voice_id in URL path; default to "21m00Tcm4TlvDq8ikWAM" (Rachel) const voiceId = body.voice || "21m00Tcm4TlvDq8ikWAM"; + if (!isValidPathSegment(voiceId)) { + return errorResponse(400, "Invalid voice ID"); + } const url = `${providerConfig.baseUrl}/${voiceId}`; const res = await fetch(url, { @@ -118,25 +135,10 @@ async function handleElevenLabsSpeech(providerConfig, body, modelId, token) { }); if (!res.ok) { - const errText = await res.text(); - return new Response(errText, { - status: res.status, - headers: { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": getCorsOrigin(), - }, - }); + return upstreamErrorResponse(res, await res.text()); } - const contentType = res.headers.get("content-type") || "audio/mpeg"; - return new Response(res.body, { - status: 200, - headers: { - "Content-Type": contentType, - "Access-Control-Allow-Origin": getCorsOrigin(), - "Transfer-Encoding": "chunked", - }, - }); + return audioStreamResponse(res); } /** @@ -158,25 +160,10 @@ async function handleNvidiaTtsSpeech(providerConfig, body, modelId, token) { }); if (!res.ok) { - const errText = await res.text(); - return new Response(errText, { - status: res.status, - headers: { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": getCorsOrigin(), - }, - }); + return upstreamErrorResponse(res, await res.text()); } - const contentType = res.headers.get("content-type") || "audio/wav"; - return new Response(res.body, { - status: 200, - headers: { - "Content-Type": contentType, - "Access-Control-Allow-Origin": getCorsOrigin(), - "Transfer-Encoding": "chunked", - }, - }); + return audioStreamResponse(res, "audio/wav"); } /** @@ -184,6 +171,9 @@ async function handleNvidiaTtsSpeech(providerConfig, body, modelId, token) { * POST {baseUrl}/{model_id} with { inputs: text } → audio binary */ async function handleHuggingFaceTtsSpeech(providerConfig, body, modelId, token) { + if (!isValidPathSegment(modelId)) { + return errorResponse(400, "Invalid model ID"); + } const url = `${providerConfig.baseUrl}/${modelId}`; const res = await fetch(url, { @@ -196,32 +186,17 @@ async function handleHuggingFaceTtsSpeech(providerConfig, body, modelId, token) }); if (!res.ok) { - const errText = await res.text(); - return new Response(errText, { - status: res.status, - headers: { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": getCorsOrigin(), - }, - }); + return upstreamErrorResponse(res, await res.text()); } - const contentType = res.headers.get("content-type") || "audio/wav"; - return new Response(res.body, { - status: 200, - headers: { - "Content-Type": contentType, - "Access-Control-Allow-Origin": getCorsOrigin(), - "Transfer-Encoding": "chunked", - }, - }); + return audioStreamResponse(res, "audio/wav"); } /** * Handle Coqui TTS (local, no auth) * POST {baseUrl} with { text, speaker_id } → WAV audio */ -async function handleCoquiSpeech(providerConfig, body, modelId) { +async function handleCoquiSpeech(providerConfig, body) { const res = await fetch(providerConfig.baseUrl, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -232,14 +207,7 @@ async function handleCoquiSpeech(providerConfig, body, modelId) { }); if (!res.ok) { - const errText = await res.text(); - return new Response(errText, { - status: res.status, - headers: { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": getCorsOrigin(), - }, - }); + return upstreamErrorResponse(res, await res.text()); } const contentType = res.headers.get("content-type") || "audio/wav"; @@ -256,7 +224,7 @@ async function handleCoquiSpeech(providerConfig, body, modelId) { * Handle Tortoise TTS (local, no auth) * POST {baseUrl} with { text, voice } → audio binary */ -async function handleTortoiseSpeech(providerConfig, body, modelId) { +async function handleTortoiseSpeech(providerConfig, body) { const res = await fetch(providerConfig.baseUrl, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -267,14 +235,7 @@ async function handleTortoiseSpeech(providerConfig, body, modelId) { }); if (!res.ok) { - const errText = await res.text(); - return new Response(errText, { - status: res.status, - headers: { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": getCorsOrigin(), - }, - }); + return upstreamErrorResponse(res, await res.text()); } const contentType = res.headers.get("content-type") || "audio/wav"; @@ -343,11 +304,11 @@ export async function handleAudioSpeech({ body, credentials }) { } if (providerConfig.format === "coqui") { - return handleCoquiSpeech(providerConfig, body, modelId); + return handleCoquiSpeech(providerConfig, body); } if (providerConfig.format === "tortoise") { - return handleTortoiseSpeech(providerConfig, body, modelId); + return handleTortoiseSpeech(providerConfig, body); } // Default: OpenAI-compatible JSON → audio stream proxy (also used by Qwen3) @@ -367,26 +328,10 @@ export async function handleAudioSpeech({ body, credentials }) { }); if (!res.ok) { - const errText = await res.text(); - return new Response(errText, { - status: res.status, - headers: { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": getCorsOrigin(), - }, - }); + return upstreamErrorResponse(res, await res.text()); } - // Stream audio response back to client - const contentType = res.headers.get("content-type") || "audio/mpeg"; - return new Response(res.body, { - status: 200, - headers: { - "Content-Type": contentType, - "Access-Control-Allow-Origin": getCorsOrigin(), - "Transfer-Encoding": "chunked", - }, - }); + return audioStreamResponse(res); } catch (err) { return errorResponse(500, `Speech request failed: ${err.message}`); } diff --git a/open-sse/handlers/audioTranscription.ts b/open-sse/handlers/audioTranscription.ts index 182008b4e8..63229878e1 100644 --- a/open-sse/handlers/audioTranscription.ts +++ b/open-sse/handlers/audioTranscription.ts @@ -17,6 +17,26 @@ import { getTranscriptionProvider, parseTranscriptionModel } from "../config/aud import { buildAuthHeaders } from "../config/registryUtils.ts"; import { errorResponse } from "../utils/error.ts"; +/** + * Return a CORS error response from an upstream fetch failure + */ +function upstreamErrorResponse(res, errText) { + return new Response(errText, { + status: res.status, + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": getCorsOrigin(), + }, + }); +} + +/** + * Validate a path segment to prevent path traversal / SSRF. + */ +function isValidPathSegment(segment: string): boolean { + return !segment.includes("..") && !segment.includes("//"); +} + /** * Handle Deepgram transcription (raw binary audio, model via query param) */ @@ -37,14 +57,7 @@ async function handleDeepgramTranscription(providerConfig, file, modelId, token) }); if (!res.ok) { - const errText = await res.text(); - return new Response(errText, { - status: res.status, - headers: { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": getCorsOrigin(), - }, - }); + return upstreamErrorResponse(res, await res.text()); } const data = await res.json(); @@ -72,14 +85,7 @@ async function handleAssemblyAITranscription(providerConfig, file, modelId, toke }); if (!uploadRes.ok) { - const errText = await uploadRes.text(); - return new Response(errText, { - status: uploadRes.status, - headers: { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": getCorsOrigin(), - }, - }); + return upstreamErrorResponse(uploadRes, await uploadRes.text()); } const { upload_url } = await uploadRes.json(); @@ -99,14 +105,7 @@ async function handleAssemblyAITranscription(providerConfig, file, modelId, toke }); if (!submitRes.ok) { - const errText = await submitRes.text(); - return new Response(errText, { - status: submitRes.status, - headers: { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": getCorsOrigin(), - }, - }); + return upstreamErrorResponse(submitRes, await submitRes.text()); } const { id: transcriptId } = await submitRes.json(); @@ -155,14 +154,7 @@ async function handleNvidiaTranscription(providerConfig, file, modelId, token) { }); if (!res.ok) { - const errText = await res.text(); - return new Response(errText, { - status: res.status, - headers: { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": getCorsOrigin(), - }, - }); + return upstreamErrorResponse(res, await res.text()); } const data = await res.json(); @@ -177,6 +169,9 @@ async function handleNvidiaTranscription(providerConfig, file, modelId, token) { * POST raw binary audio to {baseUrl}/{model_id}, returns { text } */ async function handleHuggingFaceTranscription(providerConfig, file, modelId, token) { + if (!isValidPathSegment(modelId)) { + return errorResponse(400, "Invalid model ID"); + } const url = `${providerConfig.baseUrl}/${modelId}`; const arrayBuffer = await file.arrayBuffer(); @@ -190,14 +185,7 @@ async function handleHuggingFaceTranscription(providerConfig, file, modelId, tok }); if (!res.ok) { - const errText = await res.text(); - return new Response(errText, { - status: res.status, - headers: { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": getCorsOrigin(), - }, - }); + return upstreamErrorResponse(res, await res.text()); } const data = await res.json(); @@ -291,14 +279,7 @@ export async function handleAudioTranscription({ formData, credentials }) { }); if (!res.ok) { - const errText = await res.text(); - return new Response(errText, { - status: res.status, - headers: { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": getCorsOrigin(), - }, - }); + return upstreamErrorResponse(res, await res.text()); } const data = await res.text(); diff --git a/open-sse/handlers/musicGeneration.ts b/open-sse/handlers/musicGeneration.ts index 3efab8fa9a..1e2539980b 100644 --- a/open-sse/handlers/musicGeneration.ts +++ b/open-sse/handlers/musicGeneration.ts @@ -21,6 +21,7 @@ import { fetchComfyOutput, extractComfyOutputFiles, } from "../utils/comfyuiClient.ts"; +import { saveCallLog } from "@/lib/usageDb"; /** * Handle music generation request @@ -57,6 +58,7 @@ export async function handleMusicGeneration({ body, credentials, log }) { * Submits an audio generation workflow (Stable Audio / MusicGen), polls, fetches output */ async function handleComfyUIMusicGeneration({ model, provider, providerConfig, body, log }) { + const startTime = Date.now(); const duration = body.duration || 10; // seconds // Audio generation workflow template for ComfyUI @@ -127,12 +129,31 @@ async function handleComfyUIMusicGeneration({ model, provider, providerConfig, b audioFiles.push({ b64_json: base64, format: "wav" }); } + saveCallLog({ + method: "POST", + path: "/v1/music/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + responseBody: { audio_count: audioFiles.length }, + }).catch(() => {}); + return { success: true, data: { created: Math.floor(Date.now() / 1000), data: audioFiles }, }; } catch (err) { if (log) log.error("MUSIC", `${provider} comfyui error: ${err.message}`); + saveCallLog({ + method: "POST", + path: "/v1/music/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: err.message, + }).catch(() => {}); return { success: false, status: 502, error: `Music provider error: ${err.message}` }; } } diff --git a/open-sse/handlers/videoGeneration.ts b/open-sse/handlers/videoGeneration.ts index 65d52d3b5b..ec4ee3d4bd 100644 --- a/open-sse/handlers/videoGeneration.ts +++ b/open-sse/handlers/videoGeneration.ts @@ -22,6 +22,7 @@ import { fetchComfyOutput, extractComfyOutputFiles, } from "../utils/comfyuiClient.ts"; +import { saveCallLog } from "@/lib/usageDb"; /** * Handle video generation request @@ -62,6 +63,7 @@ export async function handleVideoGeneration({ body, credentials, log }) { * Submits an AnimateDiff or SVD workflow, polls for completion, fetches output video */ async function handleComfyUIVideoGeneration({ model, provider, providerConfig, body, log }) { + const startTime = Date.now(); const [width, height] = (body.size || "512x512").split("x").map(Number); const frames = body.frames || 16; @@ -137,12 +139,31 @@ async function handleComfyUIVideoGeneration({ model, provider, providerConfig, b videos.push({ b64_json: base64, format: "webp" }); } + saveCallLog({ + method: "POST", + path: "/v1/videos/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + responseBody: { videos_count: videos.length }, + }).catch(() => {}); + return { success: true, data: { created: Math.floor(Date.now() / 1000), data: videos }, }; } catch (err) { if (log) log.error("VIDEO", `${provider} comfyui error: ${err.message}`); + saveCallLog({ + method: "POST", + path: "/v1/videos/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: err.message, + }).catch(() => {}); return { success: false, status: 502, error: `Video provider error: ${err.message}` }; } } @@ -152,6 +173,7 @@ async function handleComfyUIVideoGeneration({ model, provider, providerConfig, b * POST to the AnimateDiff API endpoint */ async function handleSDWebUIVideoGeneration({ model, provider, providerConfig, body, log }) { + const startTime = Date.now(); const [width, height] = (body.size || "512x512").split("x").map(Number); const url = `${providerConfig.baseUrl}/animatediff/v1/generate`; @@ -181,6 +203,15 @@ async function handleSDWebUIVideoGeneration({ model, provider, providerConfig, b if (!response.ok) { const errorText = await response.text(); if (log) log.error("VIDEO", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); + saveCallLog({ + method: "POST", + path: "/v1/videos/generations", + status: response.status, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: errorText.slice(0, 500), + }).catch(() => {}); return { success: false, status: response.status, error: errorText }; } @@ -195,12 +226,31 @@ async function handleSDWebUIVideoGeneration({ model, provider, providerConfig, b } } + saveCallLog({ + method: "POST", + path: "/v1/videos/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + responseBody: { videos_count: videos.length }, + }).catch(() => {}); + return { success: true, data: { created: Math.floor(Date.now() / 1000), data: videos }, }; } catch (err) { if (log) log.error("VIDEO", `${provider} sdwebui error: ${err.message}`); + saveCallLog({ + method: "POST", + path: "/v1/videos/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: err.message, + }).catch(() => {}); return { success: false, status: 502, error: `Video provider error: ${err.message}` }; } } diff --git a/src/app/api/v1/models/route.ts b/src/app/api/v1/models/route.ts index 78ef1064c2..c8ee5cfa18 100644 --- a/src/app/api/v1/models/route.ts +++ b/src/app/api/v1/models/route.ts @@ -14,8 +14,8 @@ import { getAllImageModels } from "@omniroute/open-sse/config/imageRegistry.ts"; import { getAllRerankModels } from "@omniroute/open-sse/config/rerankRegistry.ts"; import { getAllAudioModels } from "@omniroute/open-sse/config/audioRegistry.ts"; import { getAllModerationModels } from "@omniroute/open-sse/config/moderationRegistry.ts"; -import { getAllVideoModels } from "@omniroute/open-sse/config/videoRegistry.ts"; -import { getAllMusicModels } from "@omniroute/open-sse/config/musicRegistry.ts"; +import { getAllVideoModels, getVideoProvider } from "@omniroute/open-sse/config/videoRegistry.ts"; +import { getAllMusicModels, getMusicProvider } from "@omniroute/open-sse/config/musicRegistry.ts"; const FALLBACK_ALIAS_TO_PROVIDER = { ag: "antigravity", @@ -314,6 +314,8 @@ export async function GET(request: Request) { // Add video models (local providers always listed, cloud filtered by active) for (const videoModel of getAllVideoModels()) { + const vConfig = getVideoProvider(videoModel.provider); + if (vConfig?.authType !== "none" && !isProviderActive(videoModel.provider)) continue; models.push({ id: videoModel.id, object: "model", @@ -325,6 +327,8 @@ export async function GET(request: Request) { // Add music models (local providers always listed, cloud filtered by active) for (const musicModel of getAllMusicModels()) { + const mConfig = getMusicProvider(musicModel.provider); + if (mConfig?.authType !== "none" && !isProviderActive(musicModel.provider)) continue; models.push({ id: musicModel.id, object: "model",