mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user