From c6000ecc8e04a1c2111367cafc9268a06d48dc03 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 13 Feb 2026 22:57:32 -0300 Subject: [PATCH] feat(providers): add new endpoints (rerank, audio, moderations) and providers (Hyperbolic, Deepgram, AssemblyAI, NanoBanana) - Add /v1/rerank endpoint with Cohere, Together, NVIDIA, Fireworks - Add /v1/audio/transcriptions with OpenAI, Groq, Deepgram, AssemblyAI - Add /v1/audio/speech with OpenAI, Hyperbolic, Deepgram - Add /v1/moderations with OpenAI - Add Hyperbolic as chat provider (8 models, OpenAI-compatible) - Add Hyperbolic image generation (SDXL, SD2, FLUX) - Add NanoBanana image generation (Flash + Pro via nanobananaapi.ai) - Add Deepgram STT (Nova 3, Nova 2) with Token auth and binary format - Add AssemblyAI STT (Universal 3 Pro) with async upload-poll workflow - Add Deepgram TTS (Aura voices) and Hyperbolic TTS (Melo) - Update EndpointPageClient to show 7 endpoint sections - Update /v1/models to return type/subtype for all model categories - Fix build: remove output:standalone from next.config.mjs --- next.config.mjs | 1 - open-sse/config/audioRegistry.js | 168 +++++++++++++ open-sse/config/imageRegistry.js | 28 +++ open-sse/config/moderationRegistry.js | 64 +++++ open-sse/config/providerRegistry.js | 20 ++ open-sse/config/rerankRegistry.js | 96 ++++++++ open-sse/handlers/audioSpeech.js | 173 ++++++++++++++ open-sse/handlers/audioTranscription.js | 226 ++++++++++++++++++ open-sse/handlers/imageGeneration.js | 221 +++++++++++++++++ open-sse/handlers/moderations.js | 68 ++++++ open-sse/handlers/rerank.js | 136 +++++++++++ open-sse/index.js | 31 +++ .../dashboard/endpoint/EndpointPageClient.js | 96 +++++++- src/app/api/v1/audio/speech/route.js | 57 +++++ src/app/api/v1/audio/transcriptions/route.js | 59 +++++ src/app/api/v1/models/route.js | 37 +++ src/app/api/v1/moderations/route.js | 53 ++++ src/app/api/v1/rerank/route.js | 67 ++++++ src/shared/constants/providers.js | 36 +++ 19 files changed, 1634 insertions(+), 3 deletions(-) create mode 100644 open-sse/config/audioRegistry.js create mode 100644 open-sse/config/moderationRegistry.js create mode 100644 open-sse/config/rerankRegistry.js create mode 100644 open-sse/handlers/audioSpeech.js create mode 100644 open-sse/handlers/audioTranscription.js create mode 100644 open-sse/handlers/moderations.js create mode 100644 open-sse/handlers/rerank.js create mode 100644 src/app/api/v1/audio/speech/route.js create mode 100644 src/app/api/v1/audio/transcriptions/route.js create mode 100644 src/app/api/v1/moderations/route.js create mode 100644 src/app/api/v1/rerank/route.js diff --git a/next.config.mjs b/next.config.mjs index 28a7ea5788..df06768035 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -2,7 +2,6 @@ const nextConfig = { transpilePackages: ["@omniroute/open-sse"], allowedDevOrigins: ["192.168.*"], - output: "standalone", images: { unoptimized: true, }, diff --git a/open-sse/config/audioRegistry.js b/open-sse/config/audioRegistry.js new file mode 100644 index 0000000000..5f7456b6a9 --- /dev/null +++ b/open-sse/config/audioRegistry.js @@ -0,0 +1,168 @@ +/** + * Audio Provider Registry + * + * Defines providers that support audio endpoints: + * - /v1/audio/transcriptions (Whisper API) + * - /v1/audio/speech (TTS API) + */ + +export const AUDIO_TRANSCRIPTION_PROVIDERS = { + openai: { + id: "openai", + baseUrl: "https://api.openai.com/v1/audio/transcriptions", + authType: "apikey", + authHeader: "bearer", + models: [ + { id: "whisper-1", name: "Whisper 1" }, + { id: "gpt-4o-transcription", name: "GPT-4o Transcription" }, + ], + }, + + groq: { + id: "groq", + baseUrl: "https://api.groq.com/openai/v1/audio/transcriptions", + authType: "apikey", + authHeader: "bearer", + 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" }, + ], + }, + + deepgram: { + id: "deepgram", + 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", + baseUrl: "https://api.assemblyai.com/v2/transcript", + authType: "apikey", + authHeader: "bearer", + async: true, + format: "assemblyai", + models: [ + { id: "universal-3-pro", name: "Universal 3 Pro" }, + { id: "universal-2", name: "Universal 2" }, + ], + }, +}; + +export const AUDIO_SPEECH_PROVIDERS = { + openai: { + id: "openai", + baseUrl: "https://api.openai.com/v1/audio/speech", + authType: "apikey", + authHeader: "bearer", + 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" }, + ], + }, + + hyperbolic: { + id: "hyperbolic", + baseUrl: "https://api.hyperbolic.xyz/v1/audio/generation", + authType: "apikey", + authHeader: "bearer", + format: "hyperbolic", + models: [{ id: "melo-tts", name: "Melo TTS" }], + }, + + deepgram: { + id: "deepgram", + baseUrl: "https://api.deepgram.com/v1/speak", + authType: "apikey", + authHeader: "token", + format: "deepgram", + models: [ + { id: "aura-asteria-en", name: "Aura Asteria (EN)" }, + { id: "aura-luna-en", name: "Aura Luna (EN)" }, + { id: "aura-stella-en", name: "Aura Stella (EN)" }, + ], + }, +}; + +/** + * Get transcription provider config by ID + */ +export function getTranscriptionProvider(providerId) { + return AUDIO_TRANSCRIPTION_PROVIDERS[providerId] || null; +} + +/** + * Get speech provider config by ID + */ +export function getSpeechProvider(providerId) { + return AUDIO_SPEECH_PROVIDERS[providerId] || null; +} + +/** + * Parse audio model string (format: "provider/model" or just "model") + */ +function parseAudioModel(modelStr, registry) { + if (!modelStr) return { provider: null, model: null }; + + for (const [providerId, config] of Object.entries(registry)) { + if (modelStr.startsWith(providerId + "/")) { + return { provider: providerId, model: modelStr.slice(providerId.length + 1) }; + } + } + + for (const [providerId, config] of Object.entries(registry)) { + if (config.models.some((m) => m.id === modelStr)) { + return { provider: providerId, model: modelStr }; + } + } + + return { provider: null, model: modelStr }; +} + +export function parseTranscriptionModel(modelStr) { + return parseAudioModel(modelStr, AUDIO_TRANSCRIPTION_PROVIDERS); +} + +export function parseSpeechModel(modelStr) { + return parseAudioModel(modelStr, AUDIO_SPEECH_PROVIDERS); +} + +/** + * Get all audio models as a flat list + */ +export function getAllAudioModels() { + const models = []; + + for (const [providerId, config] of Object.entries(AUDIO_TRANSCRIPTION_PROVIDERS)) { + for (const model of config.models) { + models.push({ + id: `${providerId}/${model.id}`, + name: model.name, + provider: providerId, + subtype: "transcription", + }); + } + } + + for (const [providerId, config] of Object.entries(AUDIO_SPEECH_PROVIDERS)) { + for (const model of config.models) { + models.push({ + id: `${providerId}/${model.id}`, + name: model.name, + provider: providerId, + subtype: "speech", + }); + } + } + + return models; +} diff --git a/open-sse/config/imageRegistry.js b/open-sse/config/imageRegistry.js index acb8bf2972..48240fa422 100644 --- a/open-sse/config/imageRegistry.js +++ b/open-sse/config/imageRegistry.js @@ -80,6 +80,34 @@ export const IMAGE_PROVIDERS = { ], supportedSizes: ["1024x1024", "512x512"], }, + + hyperbolic: { + id: "hyperbolic", + baseUrl: "https://api.hyperbolic.xyz/v1/image/generation", + authType: "apikey", + authHeader: "bearer", + format: "hyperbolic", // custom: uses model_name, returns base64 images + models: [ + { id: "SDXL1.0-base", name: "SDXL 1.0 Base" }, + { id: "SD2", name: "Stable Diffusion 2" }, + { id: "FLUX.1-dev", name: "FLUX.1 Dev" }, + ], + supportedSizes: ["1024x1024", "512x512"], + }, + + nanobanana: { + id: "nanobanana", + baseUrl: "https://api.nanobananaapi.ai/api/v1/nanobanana/generate", + proUrl: "https://api.nanobananaapi.ai/api/v1/nanobanana/generate-pro", + authType: "apikey", + authHeader: "bearer", + format: "nanobanana", // custom format + models: [ + { id: "nanobanana-flash", name: "NanoBanana Flash (Gemini 2.5 Flash)" }, + { id: "nanobanana-pro", name: "NanoBanana Pro (Gemini 3 Pro)" }, + ], + supportedSizes: ["1024x1024"], + }, }; /** diff --git a/open-sse/config/moderationRegistry.js b/open-sse/config/moderationRegistry.js new file mode 100644 index 0000000000..4a3f621998 --- /dev/null +++ b/open-sse/config/moderationRegistry.js @@ -0,0 +1,64 @@ +/** + * Moderation Provider Registry + * + * Defines providers that support the /v1/moderations endpoint. + * Follows OpenAI's moderation API format. + */ + +export const MODERATION_PROVIDERS = { + openai: { + id: "openai", + baseUrl: "https://api.openai.com/v1/moderations", + authType: "apikey", + authHeader: "bearer", + models: [ + { id: "omni-moderation-latest", name: "Omni Moderation Latest" }, + { id: "text-moderation-latest", name: "Text Moderation Latest" }, + ], + }, +}; + +/** + * Get moderation provider config by ID + */ +export function getModerationProvider(providerId) { + return MODERATION_PROVIDERS[providerId] || null; +} + +/** + * Parse moderation model string + */ +export function parseModerationModel(modelStr) { + if (!modelStr) return { provider: null, model: null }; + + for (const [providerId, config] of Object.entries(MODERATION_PROVIDERS)) { + if (modelStr.startsWith(providerId + "/")) { + return { provider: providerId, model: modelStr.slice(providerId.length + 1) }; + } + } + + for (const [providerId, config] of Object.entries(MODERATION_PROVIDERS)) { + if (config.models.some((m) => m.id === modelStr)) { + return { provider: providerId, model: modelStr }; + } + } + + return { provider: null, model: modelStr }; +} + +/** + * Get all moderation models as a flat list + */ +export function getAllModerationModels() { + const models = []; + for (const [providerId, config] of Object.entries(MODERATION_PROVIDERS)) { + for (const model of config.models) { + models.push({ + id: `${providerId}/${model.id}`, + name: model.name, + provider: providerId, + }); + } + } + return models; +} diff --git a/open-sse/config/providerRegistry.js b/open-sse/config/providerRegistry.js index b27fef8c63..3bd7aa1cba 100644 --- a/open-sse/config/providerRegistry.js +++ b/open-sse/config/providerRegistry.js @@ -735,6 +735,26 @@ export const REGISTRY = { { id: "baidu/ERNIE-4.5-300B-A47B", name: "ERNIE 4.5 300B" }, ], }, + + hyperbolic: { + id: "hyperbolic", + alias: "hyp", + format: "openai", + executor: "default", + baseUrl: "https://api.hyperbolic.xyz/v1/chat/completions", + authType: "apikey", + authHeader: "bearer", + models: [ + { id: "Qwen/QwQ-32B", name: "QwQ 32B" }, + { id: "deepseek-ai/DeepSeek-R1", name: "DeepSeek R1" }, + { id: "deepseek-ai/DeepSeek-V3", name: "DeepSeek V3" }, + { id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B" }, + { id: "meta-llama/Llama-3.2-3B-Instruct", name: "Llama 3.2 3B" }, + { id: "Qwen/Qwen2.5-72B-Instruct", name: "Qwen 2.5 72B" }, + { id: "Qwen/Qwen2.5-Coder-32B-Instruct", name: "Qwen 2.5 Coder 32B" }, + { id: "NousResearch/Hermes-3-Llama-3.1-70B", name: "Hermes 3 70B" }, + ], + }, }; // ── Generator Functions ─────────────────────────────────────────────────── diff --git a/open-sse/config/rerankRegistry.js b/open-sse/config/rerankRegistry.js new file mode 100644 index 0000000000..90222f0382 --- /dev/null +++ b/open-sse/config/rerankRegistry.js @@ -0,0 +1,96 @@ +/** + * Rerank Provider Registry + * + * Defines providers that support the /v1/rerank endpoint. + * Follows the Cohere rerank API request/response format (industry standard). + * + * API keys are stored in the same provider credentials system, + * keyed by provider ID (e.g. "cohere", "together"). + */ + +export const RERANK_PROVIDERS = { + cohere: { + id: "cohere", + baseUrl: "https://api.cohere.com/v2/rerank", + authType: "apikey", + authHeader: "bearer", + models: [ + { id: "rerank-v3.5", name: "Rerank v3.5" }, + { id: "rerank-english-v3.0", name: "Rerank English v3.0" }, + { id: "rerank-multilingual-v3.0", name: "Rerank Multilingual v3.0" }, + ], + }, + + together: { + id: "together", + baseUrl: "https://api.together.xyz/v1/rerank", + authType: "apikey", + authHeader: "bearer", + models: [{ id: "Salesforce/Llama-Rank-V2", name: "Llama Rank V2" }], + }, + + nvidia: { + id: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1/ranking", + authType: "apikey", + authHeader: "bearer", + format: "nvidia", // NVIDIA uses slightly different field names + models: [{ id: "nvidia/nv-rerankqa-mistral-4b-v3", name: "NV RerankQA Mistral 4B v3" }], + }, + + fireworks: { + id: "fireworks", + baseUrl: "https://api.fireworks.ai/inference/v1/rerank", + authType: "apikey", + authHeader: "bearer", + models: [{ id: "accounts/fireworks/models/nomic-rerank-v1", name: "Nomic Rerank v1" }], + }, +}; + +/** + * Get rerank provider config by ID + */ +export function getRerankProvider(providerId) { + return RERANK_PROVIDERS[providerId] || null; +} + +/** + * Parse rerank model string (format: "provider/model" or just "model") + * Returns { provider, model } + */ +export function parseRerankModel(modelStr) { + if (!modelStr) return { provider: null, model: null }; + + // Try each provider prefix + for (const [providerId, config] of Object.entries(RERANK_PROVIDERS)) { + if (modelStr.startsWith(providerId + "/")) { + return { provider: providerId, model: modelStr.slice(providerId.length + 1) }; + } + } + + // No provider prefix — search all providers for the model + for (const [providerId, config] of Object.entries(RERANK_PROVIDERS)) { + if (config.models.some((m) => m.id === modelStr)) { + return { provider: providerId, model: modelStr }; + } + } + + return { provider: null, model: modelStr }; +} + +/** + * Get all rerank models as a flat list + */ +export function getAllRerankModels() { + const models = []; + for (const [providerId, config] of Object.entries(RERANK_PROVIDERS)) { + for (const model of config.models) { + models.push({ + id: `${providerId}/${model.id}`, + name: model.name, + provider: providerId, + }); + } + } + return models; +} diff --git a/open-sse/handlers/audioSpeech.js b/open-sse/handlers/audioSpeech.js new file mode 100644 index 0000000000..bb1196e299 --- /dev/null +++ b/open-sse/handlers/audioSpeech.js @@ -0,0 +1,173 @@ +/** + * Audio Speech Handler (TTS) + * + * Handles POST /v1/audio/speech (OpenAI TTS API format). + * Returns audio binary stream. + * + * Supported provider formats: + * - OpenAI: standard JSON → audio stream proxy + * - Hyperbolic: POST { text } → { audio: base64 } + * - Deepgram: POST { text } with model via query param, Token auth + */ + +import { getSpeechProvider, parseSpeechModel } from "../config/audioRegistry.js"; +import { errorResponse } from "../utils/error.js"; + +/** + * Build auth header for a speech provider + */ +function buildAuthHeader(providerConfig, token) { + if (providerConfig.authHeader === "token") { + return { Authorization: `Token ${token}` }; + } + return { Authorization: `Bearer ${token}` }; +} + +/** + * Handle Hyperbolic TTS (returns base64 audio in JSON) + */ +async function handleHyperbolicSpeech(providerConfig, body, token) { + const res = await fetch(providerConfig.baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...buildAuthHeader(providerConfig, token), + }, + body: JSON.stringify({ text: body.input }), + }); + + if (!res.ok) { + const errText = await res.text(); + return new Response(errText, { + status: res.status, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + }); + } + + const data = await res.json(); + // Hyperbolic returns { audio: "" }, decode to binary + const audioBuffer = Uint8Array.from(atob(data.audio), (c) => c.charCodeAt(0)); + + return new Response(audioBuffer, { + status: 200, + headers: { + "Content-Type": "audio/mpeg", + "Access-Control-Allow-Origin": "*", + }, + }); +} + +/** + * Handle Deepgram TTS (model via query param, Token auth, returns binary audio) + */ +async function handleDeepgramSpeech(providerConfig, body, modelId, token) { + const url = new URL(providerConfig.baseUrl); + url.searchParams.set("model", modelId); + + const res = await fetch(url.toString(), { + method: "POST", + headers: { + "Content-Type": "application/json", + ...buildAuthHeader(providerConfig, token), + }, + body: JSON.stringify({ text: body.input }), + }); + + if (!res.ok) { + const errText = await res.text(); + return new Response(errText, { + status: res.status, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + }); + } + + const contentType = res.headers.get("content-type") || "audio/mpeg"; + return new Response(res.body, { + status: 200, + headers: { + "Content-Type": contentType, + "Access-Control-Allow-Origin": "*", + "Transfer-Encoding": "chunked", + }, + }); +} + +/** + * Handle audio speech (TTS) request + * + * @param {Object} options + * @param {Object} options.body - JSON request body { model, input, voice, ... } + * @param {Object} options.credentials - Provider credentials { apiKey } + * @returns {Response} + */ +export async function handleAudioSpeech({ body, credentials }) { + if (!body.model) { + return errorResponse("model is required", 400); + } + if (!body.input) { + return errorResponse("input is required", 400); + } + + const { provider: providerId, model: modelId } = parseSpeechModel(body.model); + const providerConfig = providerId ? getSpeechProvider(providerId) : null; + + if (!providerConfig) { + return errorResponse( + `No speech provider found for model "${body.model}". Available: openai, hyperbolic, deepgram`, + 400 + ); + } + + const token = credentials?.apiKey || credentials?.accessToken; + if (!token) { + return errorResponse(`No credentials for speech provider: ${providerId}`, 401); + } + + try { + // Route to provider-specific handler + if (providerConfig.format === "hyperbolic") { + return handleHyperbolicSpeech(providerConfig, body, token); + } + + if (providerConfig.format === "deepgram") { + return handleDeepgramSpeech(providerConfig, body, modelId, token); + } + + // Default: OpenAI-compatible JSON → audio stream proxy + const res = await fetch(providerConfig.baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...buildAuthHeader(providerConfig, token), + }, + body: JSON.stringify({ + model: modelId, + input: body.input, + voice: body.voice || "alloy", + response_format: body.response_format || "mp3", + speed: body.speed || 1.0, + }), + }); + + if (!res.ok) { + const errText = await res.text(); + return new Response(errText, { + status: res.status, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + }); + } + + // 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": "*", + "Transfer-Encoding": "chunked", + }, + }); + } catch (err) { + return errorResponse(`Speech request failed: ${err.message}`, 500); + } +} diff --git a/open-sse/handlers/audioTranscription.js b/open-sse/handlers/audioTranscription.js new file mode 100644 index 0000000000..c25eac19f3 --- /dev/null +++ b/open-sse/handlers/audioTranscription.js @@ -0,0 +1,226 @@ +/** + * Audio Transcription Handler + * + * Handles POST /v1/audio/transcriptions (Whisper API format). + * Proxies multipart/form-data to upstream providers. + * + * Supported provider formats: + * - OpenAI/Groq: standard multipart form-data proxy + * - Deepgram: raw binary audio POST with model via query param + * - AssemblyAI: async workflow (upload → submit → poll) + */ + +import { getTranscriptionProvider, parseTranscriptionModel } from "../config/audioRegistry.js"; +import { errorResponse } from "../utils/error.js"; + +/** + * Build auth header for a transcription provider + */ +function buildAuthHeader(providerConfig, token) { + if (providerConfig.authHeader === "token") { + return { Authorization: `Token ${token}` }; + } + return { Authorization: `Bearer ${token}` }; +} + +/** + * Handle Deepgram transcription (raw binary audio, model via query param) + */ +async function handleDeepgramTranscription(providerConfig, file, modelId, token) { + const url = new URL(providerConfig.baseUrl); + url.searchParams.set("model", modelId); + url.searchParams.set("smart_format", "true"); + + const arrayBuffer = await file.arrayBuffer(); + + const res = await fetch(url.toString(), { + method: "POST", + headers: { + ...buildAuthHeader(providerConfig, token), + "Content-Type": file.type || "audio/wav", + }, + body: arrayBuffer, + }); + + if (!res.ok) { + const errText = await res.text(); + return new Response(errText, { + status: res.status, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + }); + } + + const data = await res.json(); + // Transform Deepgram response to OpenAI Whisper format + const text = data.results?.channels?.[0]?.alternatives?.[0]?.transcript || ""; + + return Response.json({ text }, { headers: { "Access-Control-Allow-Origin": "*" } }); +} + +/** + * Handle AssemblyAI transcription (async: upload file → submit → poll) + */ +async function handleAssemblyAITranscription(providerConfig, file, modelId, token) { + const authHeaders = buildAuthHeader(providerConfig, token); + + // Step 1: Upload the audio file + const arrayBuffer = await file.arrayBuffer(); + const uploadRes = await fetch("https://api.assemblyai.com/v2/upload", { + method: "POST", + headers: { + ...authHeaders, + "Content-Type": "application/octet-stream", + }, + body: arrayBuffer, + }); + + if (!uploadRes.ok) { + const errText = await uploadRes.text(); + return new Response(errText, { + status: uploadRes.status, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + }); + } + + const { upload_url } = await uploadRes.json(); + + // Step 2: Submit transcription request + const submitRes = await fetch(providerConfig.baseUrl, { + method: "POST", + headers: { + ...authHeaders, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + audio_url: upload_url, + speech_models: [modelId], + language_detection: true, + }), + }); + + if (!submitRes.ok) { + const errText = await submitRes.text(); + return new Response(errText, { + status: submitRes.status, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + }); + } + + const { id: transcriptId } = await submitRes.json(); + + // Step 3: Poll for completion (max 120s) + const pollUrl = `${providerConfig.baseUrl}/${transcriptId}`; + const maxWait = 120_000; + const start = Date.now(); + + while (Date.now() - start < maxWait) { + await new Promise((r) => setTimeout(r, 2000)); + + const pollRes = await fetch(pollUrl, { headers: authHeaders }); + if (!pollRes.ok) continue; + + const result = await pollRes.json(); + + if (result.status === "completed") { + return Response.json( + { text: result.text || "" }, + { headers: { "Access-Control-Allow-Origin": "*" } } + ); + } + + if (result.status === "error") { + return errorResponse(result.error || "AssemblyAI transcription failed", 500); + } + } + + return errorResponse("AssemblyAI transcription timed out after 120s", 504); +} + +/** + * Handle audio transcription request + * + * @param {Object} options + * @param {FormData} options.formData - Multipart form data with file + model + * @param {Object} options.credentials - Provider credentials { apiKey } + * @returns {Response} + */ +export async function handleAudioTranscription({ formData, credentials }) { + const model = formData.get("model"); + if (!model) { + return errorResponse("model is required", 400); + } + + const file = formData.get("file"); + if (!file) { + return errorResponse("file is required", 400); + } + + const { provider: providerId, model: modelId } = parseTranscriptionModel(model); + const providerConfig = providerId ? getTranscriptionProvider(providerId) : null; + + if (!providerConfig) { + return errorResponse( + `No transcription provider found for model "${model}". Available: openai, groq, deepgram, assemblyai`, + 400 + ); + } + + const token = credentials?.apiKey || credentials?.accessToken; + if (!token) { + return errorResponse(`No credentials for transcription provider: ${providerId}`, 401); + } + + // Route to provider-specific handler + if (providerConfig.format === "deepgram") { + return handleDeepgramTranscription(providerConfig, file, modelId, token); + } + + if (providerConfig.format === "assemblyai") { + return handleAssemblyAITranscription(providerConfig, file, modelId, token); + } + + // Default: OpenAI/Groq-compatible multipart proxy + const upstreamForm = new FormData(); + upstreamForm.append("file", file, file.name || "audio.wav"); + upstreamForm.append("model", modelId); + + // Forward optional parameters + for (const key of [ + "language", + "prompt", + "response_format", + "temperature", + "timestamp_granularities[]", + ]) { + const val = formData.get(key); + if (val !== null && val !== undefined) { + upstreamForm.append(key, val); + } + } + + try { + const res = await fetch(providerConfig.baseUrl, { + method: "POST", + headers: buildAuthHeader(providerConfig, token), + body: upstreamForm, + }); + + if (!res.ok) { + const errText = await res.text(); + return new Response(errText, { + status: res.status, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + }); + } + + const data = await res.text(); + const contentType = res.headers.get("content-type") || "application/json"; + + return new Response(data, { + status: 200, + headers: { "Content-Type": contentType, "Access-Control-Allow-Origin": "*" }, + }); + } catch (err) { + return errorResponse(`Transcription request failed: ${err.message}`, 500); + } +} diff --git a/open-sse/handlers/imageGeneration.js b/open-sse/handlers/imageGeneration.js index 1174bea951..5b449a9bf6 100644 --- a/open-sse/handlers/imageGeneration.js +++ b/open-sse/handlers/imageGeneration.js @@ -50,6 +50,28 @@ export async function handleImageGeneration({ body, credentials, log }) { return handleGeminiImageGeneration({ model, providerConfig, body, credentials, log }); } + if (providerConfig.format === "hyperbolic") { + return handleHyperbolicImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + }); + } + + if (providerConfig.format === "nanobanana") { + return handleNanoBananaImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + }); + } + return handleOpenAIImageGeneration({ model, provider, providerConfig, body, credentials, log }); } @@ -339,3 +361,202 @@ async function fetchImageEndpoint(url, headers, body, provider, log) { }; } } + +/** + * Handle Hyperbolic image generation + * Uses { model_name, prompt, height, width } and returns { images: [{ image: base64 }] } + */ +async function handleHyperbolicImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}) { + const startTime = Date.now(); + const token = credentials.apiKey || credentials.accessToken; + + const [width, height] = (body.size || "1024x1024").split("x").map(Number); + + const upstreamBody = { + model_name: model, + prompt: body.prompt, + height: height || 1024, + width: width || 1024, + backend: "auto", + }; + + if (log) { + const promptPreview = String(body.prompt ?? "").slice(0, 60); + log.info("IMAGE", `${provider}/${model} (hyperbolic) | prompt: "${promptPreview}..."`); + } + + try { + const response = await fetch(providerConfig.baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(upstreamBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + if (log) + log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); + + saveCallLog({ + method: "POST", + path: "/v1/images/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 }; + } + + const data = await response.json(); + // Transform { images: [{ image: base64 }] } → OpenAI format + const images = (data.images || []).map((img) => ({ + b64_json: img.image, + revised_prompt: body.prompt, + })); + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + responseBody: { images_count: images.length }, + }).catch(() => {}); + + return { + success: true, + data: { created: Math.floor(Date.now() / 1000), data: images }, + }; + } catch (err) { + if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: err.message, + }).catch(() => {}); + return { success: false, status: 502, error: `Image provider error: ${err.message}` }; + } +} + +/** + * Handle NanoBanana image generation + * Uses flash vs pro routing based on model ID + */ +async function handleNanoBananaImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}) { + const startTime = Date.now(); + const token = credentials.apiKey || credentials.accessToken; + + // Route to pro URL for "nanobanana-pro" model + const isPro = model === "nanobanana-pro"; + const url = isPro && providerConfig.proUrl ? providerConfig.proUrl : providerConfig.baseUrl; + + const upstreamBody = { + prompt: body.prompt, + }; + + if (log) { + const promptPreview = String(body.prompt ?? "").slice(0, 60); + log.info( + "IMAGE", + `${provider}/${model} (nanobanana ${isPro ? "pro" : "flash"}) | prompt: "${promptPreview}..."` + ); + } + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(upstreamBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + if (log) + log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); + + saveCallLog({ + method: "POST", + path: "/v1/images/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 }; + } + + const data = await response.json(); + // Normalize NanoBanana response to OpenAI format + const images = []; + if (data.image) { + images.push({ b64_json: data.image, revised_prompt: body.prompt }); + } else if (data.images) { + for (const img of data.images) { + images.push({ + b64_json: typeof img === "string" ? img : img.image || img.data, + revised_prompt: body.prompt, + }); + } + } else if (data.data) { + // Already OpenAI-like + return { success: true, data }; + } + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + responseBody: { images_count: images.length }, + }).catch(() => {}); + + return { + success: true, + data: { created: Math.floor(Date.now() / 1000), data: images }, + }; + } catch (err) { + if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: err.message, + }).catch(() => {}); + return { success: false, status: 502, error: `Image provider error: ${err.message}` }; + } +} diff --git a/open-sse/handlers/moderations.js b/open-sse/handlers/moderations.js new file mode 100644 index 0000000000..32509dada3 --- /dev/null +++ b/open-sse/handlers/moderations.js @@ -0,0 +1,68 @@ +/** + * Moderation Handler + * + * Handles POST /v1/moderations (OpenAI Moderations API format). + */ + +import { getModerationProvider, parseModerationModel } from "../config/moderationRegistry.js"; +import { errorResponse } from "../utils/error.js"; + +/** + * Handle moderation request + * + * @param {Object} options + * @param {Object} options.body - JSON body { model, input } + * @param {Object} options.credentials - Provider credentials { apiKey } + * @returns {Response} + */ +export async function handleModeration({ body, credentials }) { + if (!body.input) { + return errorResponse("input is required", 400); + } + + // Default to latest moderation model + const model = body.model || "omni-moderation-latest"; + const { provider: providerId, model: modelId } = parseModerationModel(model); + const providerConfig = providerId ? getModerationProvider(providerId) : null; + + if (!providerConfig) { + return errorResponse( + `No moderation provider found for model "${model}". Available: openai`, + 400 + ); + } + + const token = credentials?.apiKey || credentials?.accessToken; + if (!token) { + return errorResponse(`No credentials for moderation provider: ${providerId}`, 401); + } + + try { + const res = await fetch(providerConfig.baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + model: modelId, + input: body.input, + }), + }); + + if (!res.ok) { + const errText = await res.text(); + return new Response(errText, { + status: res.status, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + }); + } + + const data = await res.json(); + return Response.json(data, { + headers: { "Access-Control-Allow-Origin": "*" }, + }); + } catch (err) { + return errorResponse(`Moderation request failed: ${err.message}`, 500); + } +} diff --git a/open-sse/handlers/rerank.js b/open-sse/handlers/rerank.js new file mode 100644 index 0000000000..7ec8abf6d0 --- /dev/null +++ b/open-sse/handlers/rerank.js @@ -0,0 +1,136 @@ +/** + * Rerank Handler + * + * Handles /v1/rerank requests following the Cohere rerank API format. + * Routes to the appropriate provider based on the model prefix or lookup. + */ + +import { getRerankProvider, parseRerankModel } from "../config/rerankRegistry.js"; +import { errorResponse } from "../utils/error.js"; + +/** + * Build authorization header for a rerank provider + */ +function buildAuthHeader(providerConfig, token) { + if (providerConfig.authHeader === "bearer") { + return { Authorization: `Bearer ${token}` }; + } + return {}; +} + +/** + * Transform request body for provider-specific formats (e.g. NVIDIA ranking API) + */ +function transformRequestForProvider(providerConfig, body) { + if (providerConfig.format === "nvidia") { + return { + model: body.model, + query: { text: body.query }, + passages: (body.documents || []).map((doc) => ({ + text: typeof doc === "string" ? doc : doc.text || "", + })), + top_n: body.top_n, + }; + } + // Default: Cohere-compatible format (used by Together, Fireworks, Cohere) + return body; +} + +/** + * Transform response from provider-specific formats back to Cohere format + */ +function transformResponseFromProvider(providerConfig, data) { + if (providerConfig.format === "nvidia") { + return { + id: data.id || `rerank-${Date.now()}`, + results: (data.rankings || []).map((r) => ({ + index: r.index, + relevance_score: r.logit || r.score || 0, + document: { text: r.text || "" }, + })), + meta: { + api_version: { version: "2" }, + billed_units: { search_units: 1 }, + }, + }; + } + return data; +} + +/** + * Handle a rerank request + * + * @param {Object} options + * @param {string} options.model - Model ID (e.g. "rerank-v3.5" or "cohere/rerank-v3.5") + * @param {string} options.query - Query to rank documents against + * @param {string[]|Object[]} options.documents - Documents to rerank + * @param {number} [options.top_n] - Number of top results to return + * @param {boolean} [options.return_documents] - Whether to include document text in results + * @param {Object} options.credentials - Provider credentials { apiKey, accessToken } + * @returns {Response} + */ +export async function handleRerank({ + model, + query, + documents, + top_n, + return_documents, + credentials, +}) { + if (!model) return errorResponse("model is required", 400); + if (!query) return errorResponse("query is required", 400); + if (!documents || !Array.isArray(documents) || documents.length === 0) { + return errorResponse("documents must be a non-empty array", 400); + } + + const { provider: providerId, model: modelId } = parseRerankModel(model); + const providerConfig = providerId ? getRerankProvider(providerId) : null; + + if (!providerConfig) { + return errorResponse( + `No rerank provider found for model "${model}". Available: cohere, together, nvidia, fireworks`, + 400 + ); + } + + const token = credentials?.apiKey || credentials?.accessToken; + if (!token) { + return errorResponse(`No credentials for rerank provider: ${providerId}`, 401); + } + + const requestBody = transformRequestForProvider(providerConfig, { + model: modelId, + query, + documents, + top_n: top_n || documents.length, + return_documents: return_documents !== false, + }); + + try { + const res = await fetch(providerConfig.baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...buildAuthHeader(providerConfig, token), + }, + body: JSON.stringify(requestBody), + }); + + if (!res.ok) { + const errData = await res.json().catch(() => ({})); + return errorResponse( + errData.message || errData.error?.message || `Provider returned HTTP ${res.status}`, + res.status + ); + } + + const data = await res.json(); + const result = transformResponseFromProvider(providerConfig, data); + + return Response.json(result, { + headers: { "Access-Control-Allow-Origin": "*" }, + }); + } catch (err) { + return errorResponse(`Rerank request failed: ${err.message}`, 500); + } +} diff --git a/open-sse/index.js b/open-sse/index.js index 6a73bef841..1d317170ab 100644 --- a/open-sse/index.js +++ b/open-sse/index.js @@ -108,3 +108,34 @@ export { processStreamingThinkDelta, flushThinkBuffer, } from "./utils/thinkTagParser.js"; + +// Rerank +export { handleRerank } from "./handlers/rerank.js"; +export { + RERANK_PROVIDERS, + getRerankProvider, + parseRerankModel, + getAllRerankModels, +} from "./config/rerankRegistry.js"; + +// Audio (Transcription + Speech) +export { handleAudioTranscription } from "./handlers/audioTranscription.js"; +export { handleAudioSpeech } from "./handlers/audioSpeech.js"; +export { + AUDIO_TRANSCRIPTION_PROVIDERS, + AUDIO_SPEECH_PROVIDERS, + getTranscriptionProvider, + getSpeechProvider, + parseTranscriptionModel, + parseSpeechModel, + getAllAudioModels, +} from "./config/audioRegistry.js"; + +// Moderations +export { handleModeration } from "./handlers/moderations.js"; +export { + MODERATION_PROVIDERS, + getModerationProvider, + parseModerationModel, + getAllModerationModels, +} from "./config/moderationRegistry.js"; diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index d66069b1f7..95de570cd1 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -56,7 +56,13 @@ export default function APIPageClient({ machineId }) { const chat = allModels.filter((m) => !m.type); const embeddings = allModels.filter((m) => m.type === "embedding"); const images = allModels.filter((m) => m.type === "image"); - return { chat, embeddings, images }; + const rerank = allModels.filter((m) => m.type === "rerank"); + const audioTranscription = allModels.filter( + (m) => m.type === "audio" && m.subtype === "transcription" + ); + const audioSpeech = allModels.filter((m) => m.type === "audio" && m.subtype === "speech"); + const moderation = allModels.filter((m) => m.type === "moderation"); + return { chat, embeddings, images, rerank, audioTranscription, audioSpeech, moderation }; }, [allModels]); const providerStats = useMemo(() => { @@ -504,7 +510,21 @@ export default function APIPageClient({ machineId }) {

Available Endpoints

-

{allModels.length} models across 3 endpoints

+

+ {allModels.length} models across{" "} + { + [ + endpointData.chat, + endpointData.embeddings, + endpointData.images, + endpointData.rerank, + endpointData.audioTranscription, + endpointData.audioSpeech, + endpointData.moderation, + ].filter((a) => a.length > 0).length + }{" "} + endpoints +

@@ -558,6 +578,78 @@ export default function APIPageClient({ machineId }) { copied={copied} baseUrl={currentEndpoint} /> + + {/* Rerank */} + setExpandedEndpoint(expandedEndpoint === "rerank" ? null : "rerank")} + copy={copy} + copied={copied} + baseUrl={currentEndpoint} + /> + + {/* Audio Transcription */} + + setExpandedEndpoint( + expandedEndpoint === "audioTranscription" ? null : "audioTranscription" + ) + } + copy={copy} + copied={copied} + baseUrl={currentEndpoint} + /> + + {/* Audio Speech (TTS) */} + + setExpandedEndpoint(expandedEndpoint === "audioSpeech" ? null : "audioSpeech") + } + copy={copy} + copied={copied} + baseUrl={currentEndpoint} + /> + + {/* Moderations */} + + setExpandedEndpoint(expandedEndpoint === "moderation" ? null : "moderation") + } + copy={copy} + copied={copied} + baseUrl={currentEndpoint} + /> diff --git a/src/app/api/v1/audio/speech/route.js b/src/app/api/v1/audio/speech/route.js new file mode 100644 index 0000000000..910a33601d --- /dev/null +++ b/src/app/api/v1/audio/speech/route.js @@ -0,0 +1,57 @@ +import { handleAudioSpeech } from "@omniroute/open-sse/handlers/audioSpeech.js"; +import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth.js"; +import { parseSpeechModel } from "@omniroute/open-sse/config/audioRegistry.js"; +import { errorResponse } from "@omniroute/open-sse/utils/error.js"; +import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.js"; + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "*", + }, + }); +} + +/** + * POST /v1/audio/speech — text-to-speech + * OpenAI TTS API compatible. Returns audio stream. + */ +export async function POST(request) { + if (process.env.REQUIRE_API_KEY === "true") { + 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"); + } + + let body; + try { + body = await request.json(); + } catch { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body"); + } + + if (!body.model) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model"); + } + + const { provider } = parseSpeechModel(body.model); + if (!provider) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `Invalid speech model: ${body.model}. Use format: provider/model` + ); + } + + const credentials = await getProviderCredentials(provider); + if (!credentials) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`); + } + + return handleAudioSpeech({ body, credentials }); +} diff --git a/src/app/api/v1/audio/transcriptions/route.js b/src/app/api/v1/audio/transcriptions/route.js new file mode 100644 index 0000000000..0f8e0ef870 --- /dev/null +++ b/src/app/api/v1/audio/transcriptions/route.js @@ -0,0 +1,59 @@ +import { handleAudioTranscription } from "@omniroute/open-sse/handlers/audioTranscription.js"; +import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth.js"; +import { parseTranscriptionModel } from "@omniroute/open-sse/config/audioRegistry.js"; +import { errorResponse } from "@omniroute/open-sse/utils/error.js"; +import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.js"; + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "*", + }, + }); +} + +/** + * POST /v1/audio/transcriptions — transcribe audio files + * OpenAI Whisper API compatible (multipart/form-data) + */ +export async function POST(request) { + // Optional API key validation + if (process.env.REQUIRE_API_KEY === "true") { + 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"); + } + + let formData; + try { + formData = await request.formData(); + } catch { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid multipart form data"); + } + + const model = formData.get("model"); + if (!model) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model"); + } + + const { provider } = parseTranscriptionModel(model); + if (!provider) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `Invalid transcription model: ${model}. Use format: provider/model` + ); + } + + const credentials = await getProviderCredentials(provider); + if (!credentials) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`); + } + + return handleAudioTranscription({ formData, credentials }); +} diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js index dd61d91158..e7d5058a4f 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -3,6 +3,9 @@ import { AI_PROVIDERS } from "@/shared/constants/providers"; import { getProviderConnections, getCombos, getAllCustomModels } from "@/lib/localDb"; import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry.js"; import { getAllImageModels } from "@omniroute/open-sse/config/imageRegistry.js"; +import { getAllRerankModels } from "@omniroute/open-sse/config/rerankRegistry.js"; +import { getAllAudioModels } from "@omniroute/open-sse/config/audioRegistry.js"; +import { getAllModerationModels } from "@omniroute/open-sse/config/moderationRegistry.js"; const FALLBACK_ALIAS_TO_PROVIDER = { ag: "antigravity", @@ -204,6 +207,40 @@ export async function GET() { }); } + // Add rerank models + for (const rerankModel of getAllRerankModels()) { + models.push({ + id: rerankModel.id, + object: "model", + created: timestamp, + owned_by: rerankModel.provider, + type: "rerank", + }); + } + + // Add audio models (transcription + speech) + for (const audioModel of getAllAudioModels()) { + models.push({ + id: audioModel.id, + object: "model", + created: timestamp, + owned_by: audioModel.provider, + type: "audio", + subtype: audioModel.subtype, + }); + } + + // Add moderation models + for (const modModel of getAllModerationModels()) { + models.push({ + id: modModel.id, + object: "model", + created: timestamp, + owned_by: modModel.provider, + type: "moderation", + }); + } + // Add custom models (user-defined) try { const customModelsMap = await getAllCustomModels(); diff --git a/src/app/api/v1/moderations/route.js b/src/app/api/v1/moderations/route.js new file mode 100644 index 0000000000..87b255b56e --- /dev/null +++ b/src/app/api/v1/moderations/route.js @@ -0,0 +1,53 @@ +import { handleModeration } from "@omniroute/open-sse/handlers/moderations.js"; +import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth.js"; +import { parseModerationModel } from "@omniroute/open-sse/config/moderationRegistry.js"; +import { errorResponse } from "@omniroute/open-sse/utils/error.js"; +import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.js"; + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "*", + }, + }); +} + +/** + * POST /v1/moderations — content moderation + * OpenAI Moderations API compatible. + */ +export async function POST(request) { + if (process.env.REQUIRE_API_KEY === "true") { + 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"); + } + + let body; + try { + body = await request.json(); + } catch { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body"); + } + + const model = body.model || "omni-moderation-latest"; + const { provider } = parseModerationModel(model); + + // Default to openai if no provider prefix + const resolvedProvider = provider || "openai"; + const credentials = await getProviderCredentials(resolvedProvider); + if (!credentials) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `No credentials for provider: ${resolvedProvider}` + ); + } + + return handleModeration({ body: { ...body, model }, credentials }); +} diff --git a/src/app/api/v1/rerank/route.js b/src/app/api/v1/rerank/route.js new file mode 100644 index 0000000000..f483ed7a05 --- /dev/null +++ b/src/app/api/v1/rerank/route.js @@ -0,0 +1,67 @@ +import { handleRerank } from "@omniroute/open-sse/handlers/rerank.js"; +import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth.js"; +import { parseRerankModel } from "@omniroute/open-sse/config/rerankRegistry.js"; +import { errorResponse } from "@omniroute/open-sse/utils/error.js"; +import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.js"; + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "*", + }, + }); +} + +/** + * POST /v1/rerank - Cohere-compatible rerank endpoint + * + * Reranks a list of documents against a query using the specified model. + * Supports providers: Cohere, Together AI, NVIDIA, Fireworks AI. + */ +export async function POST(request) { + // Optional API key validation + if (process.env.REQUIRE_API_KEY === "true") { + 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"); + } + + let body; + try { + body = await request.json(); + } catch { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body"); + } + + if (!body.model) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model"); + } + + const { provider } = parseRerankModel(body.model); + if (!provider) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `Invalid rerank model: ${body.model}. Use format: provider/model` + ); + } + + const credentials = await getProviderCredentials(provider); + if (!credentials) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`); + } + + return handleRerank({ + model: body.model, + query: body.query, + documents: body.documents, + top_n: body.top_n, + return_documents: body.return_documents, + credentials, + }); +} diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js index 28d3192eea..2dbd66cde7 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -235,6 +235,42 @@ export const APIKEY_PROVIDERS = { textIcon: "SF", website: "https://cloud.siliconflow.com", }, + hyperbolic: { + id: "hyperbolic", + alias: "hyp", + name: "Hyperbolic", + icon: "bolt", + color: "#00D4FF", + textIcon: "HY", + website: "https://hyperbolic.xyz", + }, + deepgram: { + id: "deepgram", + alias: "dg", + name: "Deepgram", + icon: "mic", + color: "#13EF93", + textIcon: "DG", + website: "https://deepgram.com", + }, + assemblyai: { + id: "assemblyai", + alias: "aai", + name: "AssemblyAI", + icon: "record_voice_over", + color: "#0062FF", + textIcon: "AA", + website: "https://assemblyai.com", + }, + nanobanana: { + id: "nanobanana", + alias: "nb", + name: "NanoBanana", + icon: "image", + color: "#FFD700", + textIcon: "NB", + website: "https://nanobananaapi.ai", + }, }; export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";