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:
diegosouzapw
2026-02-13 22:57:32 -03:00
parent 4e6ba7d9cc
commit c6000ecc8e
19 changed files with 1634 additions and 3 deletions

View File

@@ -2,7 +2,6 @@
const nextConfig = {
transpilePackages: ["@omniroute/open-sse"],
allowedDevOrigins: ["192.168.*"],
output: "standalone",
images: {
unoptimized: true,
},

View 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;
}

View File

@@ -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"],
},
};
/**

View 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;
}

View File

@@ -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 ───────────────────────────────────────────────────

View 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;
}

View 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);
}
}

View 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);
}
}

View File

@@ -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}` };
}
}

View 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
View 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);
}
}

View File

@@ -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";

View File

@@ -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>

View 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 });
}

View 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 });
}

View File

@@ -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();

View 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 });
}

View 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,
});
}

View File

@@ -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-";