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

View File

@@ -191,6 +191,7 @@ export function initLogRotation(): void {
config.maxFileSize,
config.maxFiles
);
rotationTimer.unref?.();
}
/**

View File

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

View File

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

View File

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