mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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
This commit is contained in:
@@ -2,7 +2,6 @@
|
||||
const nextConfig = {
|
||||
transpilePackages: ["@omniroute/open-sse"],
|
||||
allowedDevOrigins: ["192.168.*"],
|
||||
output: "standalone",
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
|
||||
168
open-sse/config/audioRegistry.js
Normal file
168
open-sse/config/audioRegistry.js
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
64
open-sse/config/moderationRegistry.js
Normal file
64
open-sse/config/moderationRegistry.js
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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 ───────────────────────────────────────────────────
|
||||
|
||||
96
open-sse/config/rerankRegistry.js
Normal file
96
open-sse/config/rerankRegistry.js
Normal file
@@ -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;
|
||||
}
|
||||
173
open-sse/handlers/audioSpeech.js
Normal file
173
open-sse/handlers/audioSpeech.js
Normal file
@@ -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: "<base64>" }, 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);
|
||||
}
|
||||
}
|
||||
226
open-sse/handlers/audioTranscription.js
Normal file
226
open-sse/handlers/audioTranscription.js
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}` };
|
||||
}
|
||||
}
|
||||
|
||||
68
open-sse/handlers/moderations.js
Normal file
68
open-sse/handlers/moderations.js
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
136
open-sse/handlers/rerank.js
Normal file
136
open-sse/handlers/rerank.js
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 }) {
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Available Endpoints</h2>
|
||||
<p className="text-sm text-text-muted">{allModels.length} models across 3 endpoints</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
{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
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -558,6 +578,78 @@ export default function APIPageClient({ machineId }) {
|
||||
copied={copied}
|
||||
baseUrl={currentEndpoint}
|
||||
/>
|
||||
|
||||
{/* Rerank */}
|
||||
<EndpointSection
|
||||
icon="sort"
|
||||
iconColor="text-amber-500"
|
||||
iconBg="bg-amber-500/10"
|
||||
title="Rerank"
|
||||
path="/v1/rerank"
|
||||
description="Rerank documents by relevance to a query"
|
||||
models={endpointData.rerank}
|
||||
expanded={expandedEndpoint === "rerank"}
|
||||
onToggle={() => setExpandedEndpoint(expandedEndpoint === "rerank" ? null : "rerank")}
|
||||
copy={copy}
|
||||
copied={copied}
|
||||
baseUrl={currentEndpoint}
|
||||
/>
|
||||
|
||||
{/* Audio Transcription */}
|
||||
<EndpointSection
|
||||
icon="mic"
|
||||
iconColor="text-rose-500"
|
||||
iconBg="bg-rose-500/10"
|
||||
title="Audio Transcription"
|
||||
path="/v1/audio/transcriptions"
|
||||
description="Transcribe audio files to text (Whisper)"
|
||||
models={endpointData.audioTranscription}
|
||||
expanded={expandedEndpoint === "audioTranscription"}
|
||||
onToggle={() =>
|
||||
setExpandedEndpoint(
|
||||
expandedEndpoint === "audioTranscription" ? null : "audioTranscription"
|
||||
)
|
||||
}
|
||||
copy={copy}
|
||||
copied={copied}
|
||||
baseUrl={currentEndpoint}
|
||||
/>
|
||||
|
||||
{/* Audio Speech (TTS) */}
|
||||
<EndpointSection
|
||||
icon="record_voice_over"
|
||||
iconColor="text-cyan-500"
|
||||
iconBg="bg-cyan-500/10"
|
||||
title="Text to Speech"
|
||||
path="/v1/audio/speech"
|
||||
description="Convert text to natural-sounding speech"
|
||||
models={endpointData.audioSpeech}
|
||||
expanded={expandedEndpoint === "audioSpeech"}
|
||||
onToggle={() =>
|
||||
setExpandedEndpoint(expandedEndpoint === "audioSpeech" ? null : "audioSpeech")
|
||||
}
|
||||
copy={copy}
|
||||
copied={copied}
|
||||
baseUrl={currentEndpoint}
|
||||
/>
|
||||
|
||||
{/* Moderations */}
|
||||
<EndpointSection
|
||||
icon="shield"
|
||||
iconColor="text-orange-500"
|
||||
iconBg="bg-orange-500/10"
|
||||
title="Moderations"
|
||||
path="/v1/moderations"
|
||||
description="Content moderation and safety classification"
|
||||
models={endpointData.moderation}
|
||||
expanded={expandedEndpoint === "moderation"}
|
||||
onToggle={() =>
|
||||
setExpandedEndpoint(expandedEndpoint === "moderation" ? null : "moderation")
|
||||
}
|
||||
copy={copy}
|
||||
copied={copied}
|
||||
baseUrl={currentEndpoint}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
57
src/app/api/v1/audio/speech/route.js
Normal file
57
src/app/api/v1/audio/speech/route.js
Normal file
@@ -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 });
|
||||
}
|
||||
59
src/app/api/v1/audio/transcriptions/route.js
Normal file
59
src/app/api/v1/audio/transcriptions/route.js
Normal file
@@ -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 });
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
53
src/app/api/v1/moderations/route.js
Normal file
53
src/app/api/v1/moderations/route.js
Normal file
@@ -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 });
|
||||
}
|
||||
67
src/app/api/v1/rerank/route.js
Normal file
67
src/app/api/v1/rerank/route.js
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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-";
|
||||
|
||||
Reference in New Issue
Block a user