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:
diegosouzapw
2026-04-24 13:20:59 -03:00
parent 619d8c937d
commit 7d34b6a7e1
32 changed files with 922 additions and 62 deletions

View File

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

View File

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

View File

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

View File

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