mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 13:22:11 +03:00
feat(providers): add AWS Polly and Lemonade provider support
Register AWS Polly as an audio provider with SigV4 request signing, speech engine discovery, and API-key validation for managed provider flows. Add Lemonade as a self-hosted OpenAI-compatible provider, expand static model discovery to audio registries, and support Azure OpenAI deployment discovery from resource endpoints. Sanitize sensitive provider-specific AWS fields in API responses and update related tests and release notes.
This commit is contained in:
@@ -52,6 +52,10 @@ import {
|
||||
} from "@omniroute/open-sse/config/antigravityModelAliases.ts";
|
||||
import { getEmbeddingProvider } from "@omniroute/open-sse/config/embeddingRegistry.ts";
|
||||
import { getRerankProvider } from "@omniroute/open-sse/config/rerankRegistry.ts";
|
||||
import {
|
||||
getSpeechProvider,
|
||||
getTranscriptionProvider,
|
||||
} from "@omniroute/open-sse/config/audioRegistry.ts";
|
||||
import {
|
||||
getCachedDiscoveredModels,
|
||||
isAutoFetchModelsEnabled,
|
||||
@@ -76,6 +80,21 @@ function getProviderBaseUrl(providerSpecificData: unknown): string | null {
|
||||
return typeof baseUrl === "string" && baseUrl.trim().length > 0 ? baseUrl : null;
|
||||
}
|
||||
|
||||
function normalizeAzureOpenAIBaseUrl(baseUrl: string) {
|
||||
return baseUrl
|
||||
.trim()
|
||||
.replace(/\/+$/, "")
|
||||
.replace(/\/openai$/i, "")
|
||||
.replace(/\/openai\/deployments\/[^/]+\/chat\/completions.*$/i, "");
|
||||
}
|
||||
|
||||
function getAzureOpenAIApiVersion(providerSpecificData: unknown) {
|
||||
const data = asRecord(providerSpecificData);
|
||||
const apiVersion =
|
||||
toNonEmptyString(data.apiVersion) || toNonEmptyString(data.validationApiVersion);
|
||||
return apiVersion || "2024-12-01-preview";
|
||||
}
|
||||
|
||||
function isLocalOpenAIStyleProvider(provider: string): boolean {
|
||||
return isSelfHostedChatProvider(provider);
|
||||
}
|
||||
@@ -353,6 +372,22 @@ export function getStaticModelsForProvider(
|
||||
}));
|
||||
}
|
||||
|
||||
const speechProvider = getSpeechProvider(provider);
|
||||
if (speechProvider) {
|
||||
return speechProvider.models.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
}));
|
||||
}
|
||||
|
||||
const transcriptionProvider = getTranscriptionProvider(provider);
|
||||
if (transcriptionProvider) {
|
||||
return transcriptionProvider.models.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
}));
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1116,6 +1151,85 @@ export async function GET(
|
||||
return buildApiDiscoveryResponse(models.filter((model) => model.id));
|
||||
}
|
||||
|
||||
if (provider === "azure-openai") {
|
||||
const cachedResponse = maybeReturnCachedDiscovery();
|
||||
if (cachedResponse) return cachedResponse;
|
||||
|
||||
const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled();
|
||||
if (autoFetchDisabledResponse) return autoFetchDisabledResponse;
|
||||
|
||||
const token = accessToken || apiKey;
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"No API key configured for this provider. Please add an API key in the provider settings.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const rawBaseUrl = getProviderBaseUrl(connection.providerSpecificData);
|
||||
if (!rawBaseUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: "No Azure OpenAI resource endpoint configured" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = normalizeAzureOpenAIBaseUrl(rawBaseUrl);
|
||||
const apiVersion = encodeURIComponent(
|
||||
getAzureOpenAIApiVersion(connection.providerSpecificData)
|
||||
);
|
||||
const discoveryUrls = [
|
||||
`${baseUrl}/openai/deployments?api-version=${apiVersion}`,
|
||||
`${baseUrl}/openai/models?api-version=${apiVersion}`,
|
||||
];
|
||||
|
||||
let lastStatus = 0;
|
||||
for (const modelsUrl of discoveryUrls) {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await safeOutboundFetch(modelsUrl, {
|
||||
...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery,
|
||||
guard: getProviderOutboundGuard(),
|
||||
proxyConfig: proxy,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"api-key": token,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const fallback = buildDiscoveryErrorFallbackResponse(error, {
|
||||
cacheWarning: "Azure OpenAI models API unavailable — using cached catalog",
|
||||
localWarning: "Azure OpenAI models API unavailable — using local catalog",
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return buildApiDiscoveryResponse(
|
||||
normalizeOpenAiLikeModelsResponse(await response.json(), "azure-openai")
|
||||
);
|
||||
}
|
||||
|
||||
lastStatus = response.status;
|
||||
if (response.status === 401 || response.status === 403) break;
|
||||
}
|
||||
|
||||
const fallback = buildDiscoveryFallbackResponse({
|
||||
cacheWarning: `Azure OpenAI models probe failed (${lastStatus}) — using cached catalog`,
|
||||
localWarning: `Azure OpenAI models probe failed (${lastStatus}) — using local catalog`,
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to fetch models: ${lastStatus || "unknown"}` },
|
||||
{ status: lastStatus || 502 }
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === "watsonx") {
|
||||
const cachedResponse = maybeReturnCachedDiscovery();
|
||||
if (cachedResponse) return cachedResponse;
|
||||
|
||||
@@ -14,7 +14,10 @@ import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/lib/cloudSync";
|
||||
import { updateProviderConnectionSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { normalizeProviderSpecificData } from "@/lib/providers/requestDefaults";
|
||||
import {
|
||||
normalizeProviderSpecificData,
|
||||
sanitizeProviderSpecificDataForResponse,
|
||||
} from "@/lib/providers/requestDefaults";
|
||||
import {
|
||||
buildClaudeExtraUsageStateClearUpdate,
|
||||
isClaudeExtraUsageBlockEnabled,
|
||||
@@ -65,7 +68,9 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
|
||||
delete result.refreshToken;
|
||||
delete result.idToken;
|
||||
if (result.providerSpecificData) {
|
||||
delete result.providerSpecificData.consoleApiKey;
|
||||
result.providerSpecificData = sanitizeProviderSpecificDataForResponse(
|
||||
result.providerSpecificData
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ connection: result });
|
||||
@@ -193,7 +198,9 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||
delete result.refreshToken;
|
||||
delete result.idToken;
|
||||
if (result.providerSpecificData) {
|
||||
delete result.providerSpecificData.consoleApiKey;
|
||||
result.providerSpecificData = sanitizeProviderSpecificDataForResponse(
|
||||
result.providerSpecificData
|
||||
);
|
||||
}
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
|
||||
@@ -20,7 +20,10 @@ import { syncToCloud } from "@/lib/cloudSync";
|
||||
import { createProviderSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { normalizeQoderPatProviderData } from "@omniroute/open-sse/services/qoderCli";
|
||||
import { normalizeProviderSpecificData } from "@/lib/providers/requestDefaults";
|
||||
import {
|
||||
normalizeProviderSpecificData,
|
||||
sanitizeProviderSpecificDataForResponse,
|
||||
} from "@/lib/providers/requestDefaults";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { isManagedProviderConnectionId } from "@/lib/providers/catalog";
|
||||
|
||||
@@ -40,10 +43,7 @@ export async function GET(request: Request) {
|
||||
refreshToken: undefined,
|
||||
idToken: undefined,
|
||||
providerSpecificData: c.providerSpecificData
|
||||
? {
|
||||
...c.providerSpecificData,
|
||||
consoleApiKey: undefined,
|
||||
}
|
||||
? sanitizeProviderSpecificDataForResponse(c.providerSpecificData)
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
@@ -173,7 +173,9 @@ export async function POST(request: Request) {
|
||||
const result: Record<string, any> = { ...newConnection };
|
||||
delete result.apiKey;
|
||||
if (result.providerSpecificData) {
|
||||
delete result.providerSpecificData.consoleApiKey;
|
||||
result.providerSpecificData = sanitizeProviderSpecificDataForResponse(
|
||||
result.providerSpecificData
|
||||
);
|
||||
}
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
|
||||
@@ -371,13 +371,25 @@ export async function getUnifiedModelsResponse(
|
||||
if (getModelIsHidden(providerId, sm.id)) continue;
|
||||
|
||||
const aliasId = `${alias}/${sm.id}`;
|
||||
if (models.some((model) => model.id === aliasId)) continue;
|
||||
|
||||
const endpoints = Array.isArray(sm.supportedEndpoints) ? sm.supportedEndpoints : ["chat"];
|
||||
let modelType: string | undefined;
|
||||
if (endpoints.includes("embeddings")) modelType = "embedding";
|
||||
else if (endpoints.includes("images")) modelType = "image";
|
||||
else if (endpoints.includes("audio")) modelType = "audio";
|
||||
const syncedFields = {
|
||||
...(modelType ? { type: modelType } : {}),
|
||||
...(modelType === "audio" ? { subtype: "transcription" } : {}),
|
||||
...(sm.inputTokenLimit ? { context_length: sm.inputTokenLimit } : {}),
|
||||
...(endpoints.length > 1 || !endpoints.includes("chat")
|
||||
? { supported_endpoints: endpoints }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const existingAliasModel = models.find((model) => model.id === aliasId);
|
||||
if (existingAliasModel) {
|
||||
Object.assign(existingAliasModel, syncedFields);
|
||||
continue;
|
||||
}
|
||||
|
||||
models.push({
|
||||
id: aliasId,
|
||||
@@ -387,12 +399,7 @@ export async function getUnifiedModelsResponse(
|
||||
permission: [],
|
||||
root: sm.id,
|
||||
parent: null,
|
||||
...(modelType ? { type: modelType } : {}),
|
||||
...(modelType === "audio" ? { subtype: "transcription" } : {}),
|
||||
...(sm.inputTokenLimit ? { context_length: sm.inputTokenLimit } : {}),
|
||||
...(endpoints.length > 1 || !endpoints.includes("chat")
|
||||
? { supported_endpoints: endpoints }
|
||||
: {}),
|
||||
...syncedFields,
|
||||
});
|
||||
|
||||
if (modelType === "audio") {
|
||||
@@ -424,11 +431,7 @@ export async function getUnifiedModelsResponse(
|
||||
permission: [],
|
||||
root: sm.id,
|
||||
parent: aliasId,
|
||||
...(modelType ? { type: modelType } : {}),
|
||||
...(sm.inputTokenLimit ? { context_length: sm.inputTokenLimit } : {}),
|
||||
...(endpoints.length > 1 || !endpoints.includes("chat")
|
||||
? { supported_endpoints: endpoints }
|
||||
: {}),
|
||||
...syncedFields,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +191,7 @@ export function initLogRotation(): void {
|
||||
config.maxFileSize,
|
||||
config.maxFiles
|
||||
);
|
||||
rotationTimer.unref?.();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -153,6 +153,19 @@ export function normalizeProviderSpecificData(
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
export function sanitizeProviderSpecificDataForResponse(value: unknown): JsonRecord | undefined {
|
||||
const record = asRecord(value);
|
||||
if (Object.keys(record).length === 0) return undefined;
|
||||
|
||||
const sanitized: JsonRecord = { ...record };
|
||||
delete sanitized.consoleApiKey;
|
||||
delete sanitized.secretAccessKey;
|
||||
delete sanitized.awsSecretAccessKey;
|
||||
delete sanitized.sessionToken;
|
||||
delete sanitized.awsSessionToken;
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function isOpenAIResponsesStoreEnabled(providerSpecificData: unknown): boolean {
|
||||
return asRecord(providerSpecificData).openaiStoreEnabled === true;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
normalizeRunwayBaseUrl,
|
||||
} from "@omniroute/open-sse/config/runway.ts";
|
||||
import { PETALS_DEFAULT_MODEL, normalizePetalsBaseUrl } from "@omniroute/open-sse/config/petals.ts";
|
||||
import { signAwsRequest } from "@omniroute/open-sse/utils/awsSigV4.ts";
|
||||
|
||||
const OPENAI_LIKE_FORMATS = new Set(["openai", "openai-responses"]);
|
||||
const GEMINI_LIKE_FORMATS = new Set(["gemini", "gemini-cli"]);
|
||||
@@ -757,6 +758,73 @@ async function validateInworldProvider({ apiKey, providerSpecificData = {} }: an
|
||||
}
|
||||
}
|
||||
|
||||
function getAwsProviderString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function getAwsPollyRegion(providerSpecificData: any = {}) {
|
||||
return (
|
||||
getAwsProviderString(providerSpecificData.region) ||
|
||||
getAwsProviderString(providerSpecificData.awsRegion) ||
|
||||
process.env.AWS_REGION ||
|
||||
process.env.AWS_DEFAULT_REGION ||
|
||||
"us-east-1"
|
||||
);
|
||||
}
|
||||
|
||||
function getAwsPollyBaseUrl(providerSpecificData: any = {}, region: string) {
|
||||
return (
|
||||
getAwsProviderString(providerSpecificData.baseUrl) || `https://polly.${region}.amazonaws.com`
|
||||
).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
async function validateAwsPollyProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const accessKeyId =
|
||||
getAwsProviderString(providerSpecificData.accessKeyId) ||
|
||||
getAwsProviderString(providerSpecificData.awsAccessKeyId);
|
||||
const secretAccessKey = getAwsProviderString(apiKey);
|
||||
|
||||
if (!accessKeyId) {
|
||||
return { valid: false, error: "Missing AWS accessKeyId" };
|
||||
}
|
||||
if (!secretAccessKey) {
|
||||
return { valid: false, error: "Missing AWS Secret Access Key" };
|
||||
}
|
||||
|
||||
const region = getAwsPollyRegion(providerSpecificData);
|
||||
const baseUrl = getAwsPollyBaseUrl(providerSpecificData, region).replace(/\/v1\/voices$/i, "");
|
||||
const url = `${baseUrl}/v1/voices?Engine=standard`;
|
||||
|
||||
try {
|
||||
const signedHeaders = signAwsRequest({
|
||||
method: "GET",
|
||||
url,
|
||||
region,
|
||||
service: "polly",
|
||||
credentials: {
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
sessionToken:
|
||||
getAwsProviderString(providerSpecificData.sessionToken) ||
|
||||
getAwsProviderString(providerSpecificData.awsSessionToken),
|
||||
},
|
||||
});
|
||||
|
||||
const response = await validationRead(url, {
|
||||
method: "GET",
|
||||
headers: applyCustomUserAgent(signedHeaders, providerSpecificData),
|
||||
});
|
||||
|
||||
if (response.ok) return { valid: true, error: null };
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
return { valid: false, error: `Validation failed: ${response.status}` };
|
||||
} catch (error: any) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateBailianCodingPlanProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
try {
|
||||
const rawBaseUrl =
|
||||
@@ -2635,6 +2703,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
|
||||
nanobanana: validateNanoBananaProvider,
|
||||
elevenlabs: validateElevenLabsProvider,
|
||||
inworld: validateInworldProvider,
|
||||
"aws-polly": validateAwsPollyProvider,
|
||||
"bailian-coding-plan": validateBailianCodingPlanProvider,
|
||||
heroku: validateHerokuProvider,
|
||||
databricks: validateDatabricksProvider,
|
||||
|
||||
@@ -1297,6 +1297,19 @@ export const LOCAL_PROVIDERS = {
|
||||
localDefault: "http://localhost:8000/v1",
|
||||
passthroughModels: true,
|
||||
},
|
||||
lemonade: {
|
||||
id: "lemonade",
|
||||
alias: "lemonade",
|
||||
name: "Lemonade Server",
|
||||
icon: "bolt",
|
||||
color: "#F59E0B",
|
||||
textIcon: "LM",
|
||||
website: "https://lemonade-server.ai",
|
||||
authHint:
|
||||
"API key optional. Configure the local Lemonade OpenAI-compatible base URL (default: http://localhost:13305/api/v1).",
|
||||
localDefault: "http://localhost:13305/api/v1",
|
||||
passthroughModels: true,
|
||||
},
|
||||
llamafile: {
|
||||
id: "llamafile",
|
||||
alias: "llamafile",
|
||||
@@ -1548,6 +1561,17 @@ export const AUDIO_ONLY_PROVIDERS = {
|
||||
textIcon: "IW",
|
||||
website: "https://inworld.ai",
|
||||
},
|
||||
"aws-polly": {
|
||||
id: "aws-polly",
|
||||
alias: "polly",
|
||||
name: "AWS Polly",
|
||||
icon: "record_voice_over",
|
||||
color: "#FF9900",
|
||||
textIcon: "PL",
|
||||
website: "https://aws.amazon.com/polly/",
|
||||
authHint:
|
||||
"Use AWS Secret Access Key as API key; set providerSpecificData.accessKeyId and optional region.",
|
||||
},
|
||||
};
|
||||
|
||||
export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
|
||||
@@ -1594,6 +1618,7 @@ export function isLocalProvider(providerId) {
|
||||
export const SELF_HOSTED_CHAT_PROVIDER_IDS = new Set([
|
||||
"lm-studio",
|
||||
"vllm",
|
||||
"lemonade",
|
||||
"llamafile",
|
||||
"triton",
|
||||
"docker-model-runner",
|
||||
|
||||
Reference in New Issue
Block a user