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

@@ -2,17 +2,21 @@
## [Unreleased]
### 🐛 Bug Fixes
- **fix(providers):** Preserve OpenAI Chat Completions `reasoning_effort` through assistant-prefill requests and label OpenAI request protocols explicitly as `OpenAI-Chat` or `OpenAI-Responses`.
- _No unreleased changes._
---
## [3.7.0] — 2026-04-23
## [3.7.0] — 2026-04-24
### ✨ New Features
- **feat(sse):** Codex CLI image_generation + DALL-E-style image route (#1544)
- **feat(dashboard):** Complete the reconciled v3.7.0 dashboard task set: MCP cache tools and count, video endpoint visibility, provider taxonomy, upstream proxy visibility, provider count badges, costs overview, eval suite management, Custom CLI builder, ACP-focused Agents copy, Translator stream transformer, logs convergence, learned rate-limit health cards, docs expansion, and active request payload inspection.
- **feat(mcp):** Register `omniroute_cache_stats` and `omniroute_cache_flush` across MCP schemas, server registration, handlers, docs, and tests.
- **feat(providers):** Complete the v3.7.0 provider onboarding wave with self-hosted/local providers (`lm-studio`, `vllm`, `lemonade`, `llamafile`, `triton`, `docker-model-runner`, `xinference`, `oobabooga`), OpenAI-compatible gateways (`glhf`, `cablyai`, `thebai`, `fenayai`, `empower`, `poe`), enterprise providers (`datarobot`, `azure-openai`, `azure-ai`, `bedrock`, `watsonx`, `oci`, `sap`), specialty providers (`clarifai`, `modal`, `reka`, `nous-research`, `nlpcloud`, `petals`, `vertex-partner`), `amazon-q`, GitLab/GitLab Duo, and Chutes.ai.
- **feat(audio):** Add AWS Polly as an audio speech provider with SigV4 request signing, static engine catalog, provider validation, managed-provider UI coverage, and sanitization for AWS secret/session fields.
- **feat(search):** Add You.com search provider support with dashboard discovery, validation, livecrawl option handling, and search handler normalization.
- **feat(video):** Add RunwayML task-based video generation support, task polling, provider catalog metadata, validation, and dashboard/model-list coverage.
- **feat(providers):** Add search functionality to the providers dashboard with i18n support. (#1511 — thanks @th-ch)
- **feat(providers):** Register 6 new models in the opencode-go provider catalog. (#1510 — thanks @kang-heewon)
- **feat(providers):** Add ModelScope provider (Chinese AI marketplace) with Kimi K2.5, GLM-5, and Step-3.5-Flash integration. (#1430 — thanks @clousky2020)
@@ -29,10 +33,22 @@
- **feat(providers):** Fix OpenRouter remote discovery and unify managed model sync. (#1521)
- **feat(providers):** Implement provider and account-level concurrency cap enforcement (`maxConcurrent`) using robust semaphore mechanisms. (#1524)
- **feat(core):** Implement Hermes CLI config generation and message content stripping. (#1475)
- **feat(combos):** Add expert combo configuration mode for advanced routing controls. (#1547)
- **feat(tunnels):** Add Tailscale tunnel management routes and runtime helpers for install, login, daemon start, enable/disable, and health checks.
### 🐛 Bug Fixes
- **fix(claude):** preserve tool_result adjacency in native and CC-compatible paths (#1555)
- **fix(reasoning):** Preserve OpenAI Chat Completions `reasoning_effort` through assistant-prefill requests and label OpenAI request protocols explicitly as `OpenAI-Chat` or `OpenAI-Responses`. (#1550)
- **fix(codex):** Fix Codex auto-review model routing so review traffic resolves to the intended configured model. (#1551)
- **fix(resilience):** Route HTTP 429 cooldowns through runtime settings so cooldown behavior follows the configured resilience profile. (#1548)
- **fix(providers):** Normalize Anthropic header keys to lowercase in the provider registry to avoid duplicate or case-variant upstream headers. (#1527)
- **fix(providers):** Preserve audio, embedding, rerank, image, video, and OpenAI-compatible alias metadata when `/v1/models` merges static and discovered catalogs.
- **fix(providers):** Discover Azure OpenAI deployments from resource endpoints using `api-key` auth and configurable API versions.
- **fix(providers):** Keep local OpenAI-style providers authless when no API key is configured, including the Lemonade Server default endpoint.
- **fix(translator):** Preserve Antigravity default system instructions and caller-provided system prompts as separate Gemini `systemInstruction` parts instead of concatenating them.
- **fix(security):** Sanitize provider-specific AWS secrets and session tokens from provider management API responses.
- **fix(release):** Resolve combo prefixing, Electron packaging, CLI auth, and release-branch integration regressions. (#1471, #1492, #1496, #1497, #1486)
- **fix(providers):** Resolve 400 errors for GLM and Antigravity Claude adapter during request translation by scoping prompt caching to compatible Anthropic endpoints and flattening system instructions. (#1514, #1520, #1522)
- **fix(core):** Strip `reasoning_content` from OpenAI format messages for non-reasoning models to prevent upstream HTTP 400 validation errors. (#1505)
- **fix(sse):** Map Claude `output_config/thinking` to OpenAI `reasoning_effort` for proper Antigravity tool translation. (#1528)
@@ -67,9 +83,12 @@
- **refactor(resilience):** Unify resilience controls across the codebase for consistent circuit breaker and fallback behavior. (#1449)
- **refactor(core):** Implement shared path utilities, add custom date formatting, improve type safety, and unify database imports across modules.
- **refactor(security):** Harden backup archive creation by switching to `execFileSync`, validate ACP agent IDs, expand shared CORS handling.
- **refactor(release):** Remove obsolete agent workflow playbooks and the stale compiled `src/lib/dataPaths.js` artifact. (#1541)
### 🧪 Tests
- **test(providers):** Add targeted coverage for AWS Polly SigV4 speech/validation, Azure OpenAI deployment discovery, Lemonade local discovery, provider dashboard taxonomy, managed provider catalog behavior, and merged `/v1/models` alias metadata.
- **test(catalog):** Add v3.7.0 catalog coverage for Pollinations text models, Perplexity Sonar via Puter, and NVIDIA free-model alias resolution.
- **test(vision-bridge):** Add 51 unit tests covering all VisionBridge spec scenarios (VB-S01 through VB-S10), including helper functions for `callVisionModel`, `extractImageParts`, `replaceImageParts`, and `resolveImageAsDataUri`.
- **test(batch-api):** Isolate batch API unit tests with temp `DATA_DIR` to prevent schema state collisions.
- **test(settings-api):** Add test harness with `createSettingsApiHarness` function for proper temp directory setup and storage reset between tests.
@@ -78,9 +97,17 @@
### 📚 Documentation
- **docs:** Update the root changelog with all release-branch changes through 2026-04-24, including PRs #1544, #1555, #1551, #1550, #1548, #1547, #1541, #1538, #1536, and #1527.
- **docs:** Fix broken README and localized documentation links. (#1536)
- **docs:** Add dashboard docs coverage for current API endpoints, management APIs, ACP, MCP tools, provider onboarding, and v3.7.0 task reconciliation.
- **docs:** Add Arch Linux AUR install notes for community package support. (#1478)
- **docs(i18n):** Improve Ukrainian (uk-UA) translation quality — full Ukrainian translation for README, SECURITY, A2A-SERVER, API_REFERENCE, AUTO-COMBO, and USER_GUIDE documents. Fix mixed Latin/Cyrillic typos, translate model table entries, and standardize section headers.
### 🛠️ Maintenance
- **chore:** Add `.tmp/` to `.gitignore` to keep local build/test artifacts out of release diffs. (#1538)
- **chore(release):** Clarify release version parity and changelog segregation rules for generated release workflows.
### 📦 Dependencies
- **deps:** Bump the development group with 4 updates. (#1464)

View File

@@ -246,6 +246,22 @@ export const AUDIO_SPEECH_PROVIDERS: Record<string, AudioProvider> = {
{ id: "Play3.0-mini", name: "Play3.0 Mini" },
],
},
"aws-polly": {
id: "aws-polly",
// POST https://polly.{region}.amazonaws.com/v1/speech
// Auth: AWS SigV4. The provider apiKey stores Secret Access Key; PSD stores accessKeyId/region.
baseUrl: "https://polly.us-east-1.amazonaws.com",
authType: "apikey",
authHeader: "aws-sigv4",
format: "aws-polly",
models: [
{ id: "standard", name: "Polly Standard" },
{ id: "neural", name: "Polly Neural" },
{ id: "long-form", name: "Polly Long-Form" },
{ id: "generative", name: "Polly Generative" },
],
},
};
/**

View File

@@ -1420,6 +1420,17 @@ export const REGISTRY: Record<string, RegistryEntry> = {
models: [],
passthroughModels: true,
},
lemonade: {
id: "lemonade",
alias: "lemonade",
format: "openai",
executor: "default",
baseUrl: "http://localhost:13305/api/v1",
authType: "apikey",
authHeader: "bearer",
models: [],
passthroughModels: true,
},
llamafile: {
id: "llamafile",
alias: "llamafile",

View File

@@ -180,6 +180,7 @@ export class DefaultExecutor extends BaseExecutor {
case "modal":
case "reka":
case "vllm":
case "lemonade":
case "llamafile":
case "triton":
case "docker-model-runner":

View File

@@ -20,6 +20,7 @@ import { getCorsOrigin } from "../utils/cors.ts";
import { getSpeechProvider, parseSpeechModel } from "../config/audioRegistry.ts";
import { buildAuthHeaders } from "../config/registryUtils.ts";
import { errorResponse } from "../utils/error.ts";
import { signAwsRequest } from "../utils/awsSigV4.ts";
/**
* Return a CORS error response from an upstream fetch failure
@@ -76,6 +77,78 @@ function isValidPathSegment(segment: string): boolean {
return !segment.includes("..") && !segment.includes("//");
}
function getStringValue(value): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function getAwsPollyProviderData(credentials) {
return credentials?.providerSpecificData &&
typeof credentials.providerSpecificData === "object" &&
!Array.isArray(credentials.providerSpecificData)
? credentials.providerSpecificData
: {};
}
function resolveAwsPollyRegion(providerSpecificData) {
return (
getStringValue(providerSpecificData.region) ||
getStringValue(providerSpecificData.awsRegion) ||
process.env.AWS_REGION ||
process.env.AWS_DEFAULT_REGION ||
"us-east-1"
);
}
function resolveAwsPollyBaseUrl(providerSpecificData, region) {
const configuredBaseUrl = getStringValue(providerSpecificData.baseUrl);
const baseUrl = configuredBaseUrl || `https://polly.${region}.amazonaws.com`;
return baseUrl.replace(/\/v1\/speech\/?$/i, "").replace(/\/+$/, "");
}
function normalizeAwsPollyEngine(modelId) {
const engine = getStringValue(modelId) || "standard";
return ["standard", "neural", "long-form", "generative"].includes(engine) ? engine : "standard";
}
function normalizeAwsPollyOutputFormat(responseFormat) {
const format = getStringValue(responseFormat)?.toLowerCase();
switch (format) {
case "pcm":
case "wav":
return "pcm";
case "opus":
case "ogg_opus":
return "ogg_opus";
case "ogg":
case "ogg_vorbis":
return "ogg_vorbis";
case "json":
return "json";
case "mp3":
default:
return "mp3";
}
}
function normalizeAwsPollyTextType(body) {
const explicitTextType = getStringValue(body.text_type || body.textType)?.toLowerCase();
if (explicitTextType === "ssml") return "ssml";
if (explicitTextType === "text") return "text";
const input = getStringValue(body.input) || "";
return input.trim().startsWith("<speak") ? "ssml" : "text";
}
function getAwsPollySampleRate(responseFormat, sampleRate) {
const explicit = getStringValue(sampleRate || null);
if (explicit) return explicit;
const outputFormat = normalizeAwsPollyOutputFormat(responseFormat);
if (outputFormat === "ogg_opus") return "48000";
if (outputFormat === "pcm") return "16000";
return undefined;
}
/**
* Handle Hyperbolic TTS (returns base64 audio in JSON)
*/
@@ -321,6 +394,80 @@ async function handlePlayHtSpeech(providerConfig, body, modelId, token) {
return audioStreamResponse(res);
}
/**
* Handle AWS Polly TTS
* POST /v1/speech signed with AWS SigV4.
* The configured apiKey stores AWS Secret Access Key; providerSpecificData.accessKeyId stores
* AWS Access Key ID, with optional region/baseUrl/defaultVoice/sessionToken.
*/
async function handleAwsPollySpeech(providerConfig, body, modelId, token, credentials) {
const providerSpecificData = getAwsPollyProviderData(credentials);
const accessKeyId =
getStringValue(providerSpecificData.accessKeyId) ||
getStringValue(providerSpecificData.awsAccessKeyId);
const secretAccessKey = getStringValue(token);
if (!accessKeyId) {
return errorResponse(400, "AWS Polly requires providerSpecificData.accessKeyId");
}
if (!secretAccessKey) {
return errorResponse(401, "No AWS Secret Access Key for AWS Polly");
}
const region = resolveAwsPollyRegion(providerSpecificData);
const baseUrl = resolveAwsPollyBaseUrl(providerSpecificData, region);
const url = `${baseUrl}/v1/speech`;
const outputFormat = normalizeAwsPollyOutputFormat(body.response_format);
const sampleRate = getAwsPollySampleRate(
body.response_format,
body.sample_rate || body.sampleRate
);
const requestBody = {
Engine: normalizeAwsPollyEngine(modelId),
OutputFormat: outputFormat,
Text: body.input,
TextType: normalizeAwsPollyTextType(body),
VoiceId:
getStringValue(body.voice) || getStringValue(providerSpecificData.defaultVoice) || "Joanna",
...(getStringValue(body.language_code || body.languageCode)
? { LanguageCode: getStringValue(body.language_code || body.languageCode) }
: {}),
...(sampleRate ? { SampleRate: sampleRate } : {}),
};
const serializedBody = JSON.stringify(requestBody);
const signedHeaders = signAwsRequest({
method: "POST",
url,
region,
service: "polly",
headers: {
"content-type": "application/json",
},
body: serializedBody,
credentials: {
accessKeyId,
secretAccessKey,
sessionToken:
getStringValue(providerSpecificData.sessionToken) ||
getStringValue(providerSpecificData.awsSessionToken),
},
});
const res = await fetch(url, {
method: "POST",
headers: signedHeaders,
body: serializedBody,
});
if (!res.ok) {
return upstreamErrorResponse(res, await res.text());
}
return audioStreamResponse(res, outputFormat === "pcm" ? "audio/pcm" : "audio/mpeg");
}
/**
* Handle Coqui TTS (local, no auth)
* POST {baseUrl} with { text, speaker_id } → WAV audio
@@ -412,7 +559,7 @@ export async function handleAudioSpeech({
if (!providerConfig) {
return errorResponse(
400,
`No speech provider found for model "${body.model}". Use format provider/model. Available: openai, hyperbolic, deepgram, nvidia, elevenlabs, huggingface, inworld, cartesia, playht, coqui, tortoise, qwen`
`No speech provider found for model "${body.model}". Use format provider/model. Available: openai, hyperbolic, deepgram, nvidia, elevenlabs, huggingface, inworld, cartesia, playht, aws-polly, coqui, tortoise, qwen`
);
}
@@ -457,6 +604,10 @@ export async function handleAudioSpeech({
return handlePlayHtSpeech(providerConfig, body, modelId, token);
}
if (providerConfig.format === "aws-polly") {
return handleAwsPollySpeech(providerConfig, body, modelId, token, credentials);
}
if (providerConfig.format === "coqui") {
return handleCoquiSpeech(providerConfig, body);
}

View File

@@ -541,20 +541,21 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu
}
}
// Add system instruction (Antigravity default)
let combinedSystemText = ANTIGRAVITY_DEFAULT_SYSTEM;
// Keep Antigravity's default and caller-provided system rules as distinct parts,
// matching the Gemini bridge and avoiding accidental prompt concatenation.
const systemParts: GeminiPart[] = [{ text: ANTIGRAVITY_DEFAULT_SYSTEM }];
if (claudeRequest.system) {
if (Array.isArray(claudeRequest.system)) {
for (const block of claudeRequest.system) {
if (block.text) combinedSystemText += "\n\n" + block.text;
if (block.text) systemParts.push({ text: block.text });
}
} else if (typeof claudeRequest.system === "string") {
combinedSystemText += "\n\n" + claudeRequest.system;
systemParts.push({ text: claudeRequest.system });
}
}
envelope.request.systemInstruction = { role: "user", parts: [{ text: combinedSystemText }] };
envelope.request.systemInstruction = { role: "user", parts: systemParts };
const changedToolNameMap = buildChangedToolNameMap(toolNameMap);
if (changedToolNameMap) {

139
open-sse/utils/awsSigV4.ts Normal file
View File

@@ -0,0 +1,139 @@
import { createHash, createHmac } from "node:crypto";
export interface AwsSigV4Credentials {
accessKeyId: string;
secretAccessKey: string;
sessionToken?: string | null;
}
export interface AwsSigV4Request {
method: string;
url: string;
region: string;
service: string;
headers?: Record<string, string>;
body?: string | Uint8Array | null;
credentials: AwsSigV4Credentials;
now?: Date;
}
function sha256Hex(value: string | Uint8Array) {
return createHash("sha256").update(value).digest("hex");
}
function hmac(key: string | Buffer, value: string) {
return createHmac("sha256", key).update(value, "utf8").digest();
}
function hmacHex(key: string | Buffer, value: string) {
return createHmac("sha256", key).update(value, "utf8").digest("hex");
}
function awsEncode(value: string) {
return encodeURIComponent(value).replace(
/[!'()*]/g,
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
);
}
function canonicalUri(pathname: string) {
const path = pathname || "/";
return path
.split("/")
.map((segment) => awsEncode(decodeURIComponent(segment)))
.join("/");
}
function canonicalQuery(searchParams: URLSearchParams) {
return [...searchParams.entries()]
.sort(([keyA, valueA], [keyB, valueB]) =>
keyA === keyB ? valueA.localeCompare(valueB) : keyA.localeCompare(keyB)
)
.map(([key, value]) => `${awsEncode(key)}=${awsEncode(value)}`)
.join("&");
}
function amzDateParts(now: Date) {
const iso = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
return {
amzDate: iso,
dateStamp: iso.slice(0, 8),
};
}
function normalizeHeaders(headers: Record<string, string>) {
const normalized: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
if (value === undefined || value === null) continue;
normalized[key.toLowerCase()] = String(value).trim().replace(/\s+/g, " ");
}
return normalized;
}
function signingKey(secretAccessKey: string, dateStamp: string, region: string, service: string) {
const kDate = hmac(`AWS4${secretAccessKey}`, dateStamp);
const kRegion = hmac(kDate, region);
const kService = hmac(kRegion, service);
return hmac(kService, "aws4_request");
}
export function signAwsRequest({
method,
url,
region,
service,
headers = {},
body = "",
credentials,
now = new Date(),
}: AwsSigV4Request): Record<string, string> {
const parsedUrl = new URL(url);
const payload = body ?? "";
const payloadHash = sha256Hex(payload);
const { amzDate, dateStamp } = amzDateParts(now);
const canonicalHeadersMap = normalizeHeaders({
...headers,
host: parsedUrl.host,
"x-amz-content-sha256": payloadHash,
"x-amz-date": amzDate,
...(credentials.sessionToken ? { "x-amz-security-token": credentials.sessionToken } : {}),
});
const signedHeaderNames = Object.keys(canonicalHeadersMap).sort();
const canonicalHeaders = signedHeaderNames
.map((name) => `${name}:${canonicalHeadersMap[name]}\n`)
.join("");
const signedHeaders = signedHeaderNames.join(";");
const canonicalRequest = [
method.toUpperCase(),
canonicalUri(parsedUrl.pathname),
canonicalQuery(parsedUrl.searchParams),
canonicalHeaders,
signedHeaders,
payloadHash,
].join("\n");
const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
const stringToSign = [
"AWS4-HMAC-SHA256",
amzDate,
credentialScope,
sha256Hex(canonicalRequest),
].join("\n");
const signature = hmacHex(
signingKey(credentials.secretAccessKey, dateStamp, region, service),
stringToSign
);
return {
...canonicalHeadersMap,
Authorization: [
"AWS4-HMAC-SHA256",
`Credential=${credentials.accessKeyId}/${credentialScope},`,
`SignedHeaders=${signedHeaders},`,
`Signature=${signature}`,
].join(" "),
};
}

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

View File

@@ -76,6 +76,7 @@ describe("accountSemaphore", async () => {
const releaseA = await acquire(key, { maxConcurrency: 1, timeoutMs: 200 });
const queued = acquire(key, { maxConcurrency: 1, timeoutMs: 200 });
const keepAlive = setTimeout(() => {}, 250);
try {
await queued;
@@ -84,6 +85,8 @@ describe("accountSemaphore", async () => {
assert.ok(err instanceof Error);
const error = err as Error & { code?: string };
assert.equal(error.code, "SEMAPHORE_TIMEOUT");
} finally {
clearTimeout(keepAlive);
}
releaseA();
@@ -136,7 +139,7 @@ describe("accountSemaphore", async () => {
blockedUntil: null,
});
markBlocked(key, Date.now() + 50);
markBlocked(key, 50);
// Should block even though slot is available
const acquired = acquire(key, { maxConcurrency: 1, timeoutMs: 100 });
@@ -164,7 +167,7 @@ describe("accountSemaphore", async () => {
});
await acquire(key, { maxConcurrency: 2, timeoutMs: 200 });
markBlocked(key, Date.now() + 50);
markBlocked(key, 50);
const stats = getStats()[key];
assert.equal(stats.running, 1);

View File

@@ -208,6 +208,65 @@ test("handleAudioSpeech maps PlayHT credentials, output format, and speed", asyn
}
});
test("handleAudioSpeech signs AWS Polly synthesize requests with SigV4", async () => {
const originalFetch = globalThis.fetch;
let captured;
globalThis.fetch = async (url, options = {}) => {
captured = {
url: String(url),
headers: options.headers as Record<string, string>,
body: JSON.parse(String(options.body || "{}")),
};
return new Response(new Uint8Array([8, 8, 8]), {
status: 200,
headers: { "content-type": "audio/ogg" },
});
};
try {
const response = await handleAudioSpeech({
body: {
model: "aws-polly/neural",
input: "hello from polly",
voice: "Joanna",
response_format: "opus",
language_code: "en-US",
sample_rate: "48000",
},
credentials: {
apiKey: "aws-secret-key",
providerSpecificData: {
accessKeyId: "AKIA_TEST",
region: "us-west-2",
},
},
});
assert.equal(captured.url, "https://polly.us-west-2.amazonaws.com/v1/speech");
assert.equal(captured.headers["content-type"], "application/json");
assert.equal(captured.headers["x-amz-content-sha256"].length, 64);
assert.match(
captured.headers.Authorization,
/^AWS4-HMAC-SHA256 Credential=AKIA_TEST\/\d{8}\/us-west-2\/polly\/aws4_request,/
);
assert.deepEqual(captured.body, {
Engine: "neural",
OutputFormat: "ogg_opus",
Text: "hello from polly",
TextType: "text",
VoiceId: "Joanna",
LanguageCode: "en-US",
SampleRate: "48000",
});
assert.equal(response.status, 200);
assert.equal(response.headers.get("content-type"), "audio/ogg");
} finally {
globalThis.fetch = originalFetch;
}
});
test("handleAudioSpeech requires credentials for authenticated providers", async () => {
const response = await handleAudioSpeech({
body: {

View File

@@ -260,7 +260,7 @@ test("getStaticModelsForProvider returns models with correct structure", () => {
});
test("getStaticModelsForProvider returns undefined for non-static providers", () => {
const nonStaticProviders = ["anthropic", "deepseek", "groq", "unknown-provider"];
const nonStaticProviders = ["anthropic", "deepseek", "unknown-provider"];
for (const provider of nonStaticProviders) {
const models = getStaticModelsForProvider(provider);
@@ -268,13 +268,13 @@ test("getStaticModelsForProvider returns undefined for non-static providers", ()
}
});
test("getStaticModelsForProvider returns local image catalogs for image-capable providers", () => {
const models = getStaticModelsForProvider("openai");
test("getStaticModelsForProvider returns local image catalogs for image-only providers", () => {
const models = getStaticModelsForProvider("xai");
assert.ok(models, "OpenAI should expose local image models");
assert.ok(models, "xAI should expose local image models");
assert.deepEqual(
models.map((model) => model.id),
["gpt-image-1", "dall-e-3", "dall-e-2"]
["grok-2-image-1212"]
);
});

View File

@@ -917,7 +917,8 @@ test("Batch processor keeps cancelled status for in-flight batches", async () =>
while (
remainingAttempts > 0 &&
currentBatch &&
!["cancelled", "completed", "failed", "expired"].includes(currentBatch.status)
(!["cancelled", "completed", "failed", "expired"].includes(currentBatch.status) ||
getFile(file.id)?.status !== "processed")
) {
await new Promise((resolve) => setTimeout(resolve, 50));
currentBatch = getBatch(batch.id);

View File

@@ -275,6 +275,7 @@ test("handleChat injects context-relay handoffs during live failover for Respons
const firstResponse = await handleChat(
buildRequest({
url: "http://localhost/v1/responses",
headers: {
"X-Session-Id": "relay-live-session",
"X-OmniRoute-No-Cache": "true",
@@ -306,6 +307,7 @@ test("handleChat injects context-relay handoffs during live failover for Respons
const secondResponse = await handleChat(
buildRequest({
url: "http://localhost/v1/responses",
headers: {
"X-Session-Id": "relay-live-session",
"X-OmniRoute-No-Cache": "true",

View File

@@ -387,13 +387,16 @@ test("DefaultExecutor.buildHeaders handles GLM, default auth and anthropic-compa
test("DefaultExecutor local OpenAI-style providers honor custom base URLs and skip empty bearer headers", () => {
const lmStudio = new DefaultExecutor("lm-studio");
const vllm = new DefaultExecutor("vllm");
const lemonade = new DefaultExecutor("lemonade");
const lmStudioUrl = lmStudio.buildUrl("local-model", true, 0, {
providerSpecificData: { baseUrl: "http://127.0.0.1:4321/v1" },
});
const lemonadeUrl = lemonade.buildUrl("Llama-3.2-1B-Instruct-Hybrid", true);
const vllmHeaders = vllm.buildHeaders({}, false);
assert.equal(lmStudioUrl, "http://127.0.0.1:4321/v1/chat/completions");
assert.equal(lemonadeUrl, "http://localhost:13305/api/v1/chat/completions");
assert.equal(vllmHeaders.Authorization, undefined);
assert.equal(vllmHeaders.Accept, "application/json");
});

View File

@@ -2,6 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import { GeminiCLIExecutor } from "../../open-sse/executors/gemini-cli.ts";
import { GEMINI_CLI_VERSION } from "../../open-sse/services/antigravityHeaders.ts";
test("GeminiCLIExecutor.buildUrl and buildHeaders match the native Gemini CLI fingerprint", () => {
const executor = new GeminiCLIExecutor();
@@ -20,7 +21,9 @@ test("GeminiCLIExecutor.buildUrl and buildHeaders match the native Gemini CLI fi
assert.equal(headers.Accept, "text/event-stream");
assert.match(
headers["User-Agent"],
/^GeminiCLI\/1\.0\.0\/gemini-2\.5-flash \((linux|macos|windows); (x64|arm64|x86)\)$/
new RegExp(
`^GeminiCLI/${GEMINI_CLI_VERSION.replaceAll(".", "\\.")}/gemini-2\\.5-flash \\((linux|macos|windows); (x64|arm64|x86)\\)$`
)
);
assert.match(headers["X-Goog-Api-Client"], /google-genai-sdk/);
});

View File

@@ -51,6 +51,7 @@ test("GLM import uses international coding endpoint when apiRegion is internatio
provider: "glm",
connectionId: connection.id,
models: [{ id: "glm-5", name: "GLM 5" }],
source: "api",
});
} finally {
globalThis.fetch = originalFetch;
@@ -84,6 +85,7 @@ test("GLMT import shares the GLM coding models endpoint and surfaces provider me
provider: "glmt",
connectionId: connection.id,
models: [{ id: "glm-5.1", name: "GLM 5.1" }],
source: "api",
});
} finally {
globalThis.fetch = originalFetch;
@@ -231,7 +233,7 @@ test("GLM import falls back to accessToken when apiKey is absent", async () => {
}
});
test("GLM import surfaces upstream non-OK status codes", async () => {
test("GLM import falls back to the local catalog on upstream non-OK status codes", async () => {
await resetStorage();
const connection = await providersDb.createProviderConnection({
provider: "glm",
@@ -249,8 +251,13 @@ test("GLM import surfaces upstream non-OK status codes", async () => {
new Request(`http://localhost/api/providers/${connection.id}/models`),
{ params: { id: connection.id } }
);
assert.equal(response.status, 502);
assert.deepEqual(await response.json(), { error: "Failed to fetch models: 502" });
assert.equal(response.status, 200);
const body = (await response.json()) as any;
assert.equal(body.provider, "glm");
assert.equal(body.connectionId, connection.id);
assert.equal(body.source, "local_catalog");
assert.match(body.warning, /API unavailable/i);
assert.ok(body.models.some((model) => model.id === "glm-5.1"));
} finally {
globalThis.fetch = originalFetch;
}

View File

@@ -7,9 +7,9 @@ import {
normalizeModelCatalogSource,
} from "../../src/shared/utils/modelCatalogSearch.ts";
test("model catalog source normalization groups manual/imported rows as custom", () => {
test("model catalog source normalization groups manual and synced rows separately", () => {
assert.equal(normalizeModelCatalogSource("manual"), "custom");
assert.equal(normalizeModelCatalogSource("imported"), "custom");
assert.equal(normalizeModelCatalogSource("imported"), "api-sync");
assert.equal(normalizeModelCatalogSource("api-sync"), "api-sync");
assert.equal(normalizeModelCatalogSource("fallback"), "fallback");
assert.equal(normalizeModelCatalogSource("alias"), "alias");

View File

@@ -110,5 +110,6 @@ test("next config webpack client branch disables Node builtins in browser bundle
net: false,
tls: false,
crypto: false,
process: false,
});
});

View File

@@ -11,6 +11,7 @@ Object.assign(process.env, {
GEMINI_CLI_OAUTH_CLIENT_ID:
"681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
GEMINI_CLI_OAUTH_CLIENT_SECRET: "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",
GITLAB_DUO_OAUTH_CLIENT_ID: "gitlab-duo-client-id",
QWEN_OAUTH_CLIENT_ID: "f0304373b74a44d2b584a3fb70ca9e56",
KIMI_CODING_OAUTH_CLIENT_ID: "17e5f671-d194-4dfb-9706-5516cb48c098",
ANTIGRAVITY_OAUTH_CLIENT_ID:
@@ -32,6 +33,7 @@ const {
CURSOR_CONFIG,
GEMINI_CONFIG,
GITHUB_CONFIG,
GITLAB_DUO_CONFIG,
KILOCODE_CONFIG,
KIMI_CODING_CONFIG,
KIRO_CONFIG,
@@ -53,6 +55,7 @@ const EXPECTED_PROVIDER_KEYS = [
"qwen",
"kimi-coding",
"github",
"gitlab-duo",
"kiro",
"amazon-q",
"cursor",
@@ -69,6 +72,7 @@ const EXPECTED_CONFIG_BY_PROVIDER = {
qwen: QWEN_CONFIG,
"kimi-coding": KIMI_CODING_CONFIG,
github: GITHUB_CONFIG,
"gitlab-duo": GITLAB_DUO_CONFIG,
kiro: KIRO_CONFIG,
"amazon-q": KIRO_CONFIG,
cursor: CURSOR_CONFIG,
@@ -85,6 +89,16 @@ const REQUIRED_FIELDS_BY_PROVIDER = {
qwen: ["deviceCodeUrl", "tokenUrl", "scope", "clientId"],
"kimi-coding": ["deviceCodeUrl", "tokenUrl", "clientId"],
github: ["deviceCodeUrl", "tokenUrl", "userInfoUrl", "copilotTokenUrl", "clientId"],
"gitlab-duo": [
"baseUrl",
"authorizeUrl",
"tokenUrl",
"userInfoUrl",
"directAccessUrl",
"scope",
"codeChallengeMethod",
"clientId",
],
kiro: [
"registerClientUrl",
"deviceAuthUrl",

View File

@@ -28,10 +28,13 @@ test("getModelInfoCore keeps openai fallback for gpt-4o", async () => {
assert.equal(info.model, "gpt-4o");
});
test("getModelInfoCore resolves gpt-5.4 to codex", async () => {
test("getModelInfoCore reports ambiguous unprefixed GPT Codex models", async () => {
const info = await getModelInfoCore("gpt-5.4", {});
assert.equal(info.provider, "codex");
assert.equal(info.provider, null);
assert.equal(info.model, "gpt-5.4");
assert.equal(info.errorType, "ambiguous_model");
assert.ok(info.candidateProviders.includes("codex"));
assert.ok(info.candidateProviders.includes("github"));
});
test("getModelInfoCore resolves codex-auto-review to codex", async () => {
@@ -57,7 +60,7 @@ test("getModelInfoCore canonicalizes github legacy alias with explicit provider
test("GithubExecutor routes codex-family model to /responses", () => {
const executor = new GithubExecutor();
const url = executor.buildUrl("gpt-5.1-codex", true);
const url = executor.buildUrl("gpt-5.3-codex", true);
assert.match(url, /\/responses$/);
});

View File

@@ -208,6 +208,27 @@ test("provider models route returns static catalog entries for providers with ha
assert.equal(body.models.length, 8);
});
test("provider models route returns AWS Polly speech engines from the audio registry", async () => {
const connection = await seedConnection("aws-polly", {
apiKey: "aws-secret-key",
providerSpecificData: {
accessKeyId: "AKIA_TEST",
region: "us-east-1",
},
});
const response = await callRoute(connection.id);
const body = (await response.json()) as any;
assert.equal(response.status, 200);
assert.equal(body.provider, "aws-polly");
assert.equal(body.source, "local_catalog");
assert.deepEqual(
body.models.map((model) => model.id),
["standard", "neural", "long-form", "generative"]
);
});
test("provider models route returns the local catalog for GitLab Duo fallback models", async () => {
const connection = await seedConnection("gitlab", {
apiKey: "glpat-test",
@@ -248,27 +269,49 @@ test("provider models route returns the local catalog for GitLab Duo OAuth fallb
test("provider models route discovers local OpenAI-style models without requiring an API key", async () => {
process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS = "true";
const connection = await seedConnection("lm-studio", {
const lmStudioConnection = await seedConnection("lm-studio", {
providerSpecificData: {
baseUrl: "http://localhost:1234/v1",
},
});
const lemonadeConnection = await seedConnection("lemonade", {
providerSpecificData: {
baseUrl: "http://localhost:13305/api/v1",
},
});
globalThis.fetch = async (url, init = {}) => {
assert.equal(String(url), "http://localhost:1234/v1/models");
const target = String(url);
assert.equal(init.headers.Authorization, undefined);
return Response.json({
data: [{ id: "local-model", name: "Local Model" }],
});
if (target === "http://localhost:1234/v1/models") {
return Response.json({
data: [{ id: "local-model", name: "Local Model" }],
});
}
if (target === "http://localhost:13305/api/v1/models") {
return Response.json({
data: [{ id: "Llama-3.2-1B-Instruct-Hybrid", name: "Lemonade Llama" }],
});
}
throw new Error(`unexpected fetch: ${target}`);
};
const response = await callRoute(connection.id);
const body = (await response.json()) as any;
const lmStudioResponse = await callRoute(lmStudioConnection.id);
const lmStudioBody = (await lmStudioResponse.json()) as any;
const lemonadeResponse = await callRoute(lemonadeConnection.id);
const lemonadeBody = (await lemonadeResponse.json()) as any;
assert.equal(response.status, 200);
assert.equal(body.provider, "lm-studio");
assert.equal(body.source, "api");
assert.deepEqual(body.models, [{ id: "local-model", name: "Local Model" }]);
assert.equal(lmStudioResponse.status, 200);
assert.equal(lmStudioBody.provider, "lm-studio");
assert.equal(lmStudioBody.source, "api");
assert.deepEqual(lmStudioBody.models, [{ id: "local-model", name: "Local Model" }]);
assert.equal(lemonadeResponse.status, 200);
assert.equal(lemonadeBody.provider, "lemonade");
assert.equal(lemonadeBody.source, "api");
assert.deepEqual(lemonadeBody.models, [
{ id: "Llama-3.2-1B-Instruct-Hybrid", name: "Lemonade Llama" },
]);
});
test("provider models route returns the local catalog for built-in image providers", async () => {
@@ -1125,6 +1168,43 @@ test("provider models route discovers Azure AI Foundry deployments through the v
]);
});
test("provider models route discovers Azure OpenAI deployments from the resource endpoint", async () => {
const connection = await seedConnection("azure-openai", {
apiKey: "azure-openai-key",
providerSpecificData: {
baseUrl: "https://my-resource.openai.azure.com/openai",
apiVersion: "2024-12-01-preview",
},
});
globalThis.fetch = async (url, init = {}) => {
assert.equal(
String(url),
"https://my-resource.openai.azure.com/openai/deployments?api-version=2024-12-01-preview"
);
assert.equal(init.method, "GET");
assert.equal(init.headers["api-key"], "azure-openai-key");
return Response.json({
data: [
{ id: "gpt4o-prod", model: "gpt-4o", display_name: "GPT-4o Production" },
{ id: "o3-mini-staging", model: "o3-mini" },
],
});
};
const response = await callRoute(connection.id);
const body = (await response.json()) as any;
assert.equal(response.status, 200);
assert.equal(body.provider, "azure-openai");
assert.equal(body.source, "api");
assert.deepEqual(body.models, [
{ id: "gpt4o-prod", name: "GPT-4o Production", owned_by: "azure-openai" },
{ id: "o3-mini-staging", name: "o3-mini-staging", owned_by: "azure-openai" },
]);
});
test("provider models route discovers Bedrock mantle models from the OpenAI-compatible models endpoint", async () => {
const connection = await seedConnection("bedrock", {
apiKey: "bedrock-key",

View File

@@ -137,6 +137,45 @@ test("embedding and rerank specialty validators cover Voyage AI and Jina AI", as
assert.equal(jina.valid, true);
});
test("AWS Polly specialty validator signs DescribeVoices with SigV4", async () => {
globalThis.fetch = async (url, init = {}) => {
const target = String(url);
const headers = init.headers as Record<string, string>;
assert.equal(target, "https://polly.us-east-2.amazonaws.com/v1/voices?Engine=standard");
assert.match(
headers.Authorization,
/^AWS4-HMAC-SHA256 Credential=AKIA_POLLY\/\d{8}\/us-east-2\/polly\/aws4_request,/
);
assert.equal(headers.host, "polly.us-east-2.amazonaws.com");
assert.equal(headers["x-amz-content-sha256"].length, 64);
return new Response(JSON.stringify({ Voices: [] }), { status: 200 });
};
const result = await validateProviderApiKey({
provider: "aws-polly",
apiKey: "aws-secret",
providerSpecificData: {
accessKeyId: "AKIA_POLLY",
region: "us-east-2",
},
});
assert.equal(result.valid, true);
});
test("AWS Polly specialty validator requires an access key id", async () => {
const result = await validateProviderApiKey({
provider: "aws-polly",
apiKey: "aws-secret",
providerSpecificData: {
region: "us-east-2",
},
});
assert.equal(result.error, "Missing AWS accessKeyId");
});
test("embedding and rerank specialty validators surface auth failures for Voyage AI and Jina AI", async () => {
globalThis.fetch = async (url) => {
const target = String(url);
@@ -460,15 +499,25 @@ test("local OpenAI-style providers validate without sending Authorization when a
provider: "vllm",
providerSpecificData: { baseUrl: "http://localhost:8000/v1" },
});
const lemonade = await validateProviderApiKey({
provider: "lemonade",
providerSpecificData: { baseUrl: "http://localhost:13305/api/v1" },
});
assert.equal(lmStudio.valid, true);
assert.equal(vllm.valid, true);
assert.equal(lemonade.valid, true);
assert.deepEqual(
calls.map((call) => call.url),
["http://localhost:1234/v1/models", "http://localhost:8000/v1/models"]
[
"http://localhost:1234/v1/models",
"http://localhost:8000/v1/models",
"http://localhost:13305/api/v1/models",
]
);
assert.equal(calls[0].headers.Authorization, undefined);
assert.equal(calls[1].headers.Authorization, undefined);
assert.equal(calls[2].headers.Authorization, undefined);
} finally {
if (originalAllowPrivateProviderUrls === undefined) {
delete process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS;

View File

@@ -218,9 +218,11 @@ test("static catalog entries resolve local, search, audio, web-cookie and upstre
const freeProvider = providerPageUtils.resolveDashboardProviderInfo("amazon-q");
const localProvider = providerPageUtils.resolveDashboardProviderInfo("sdwebui");
const localChatProvider = providerPageUtils.resolveDashboardProviderInfo("lm-studio");
const lemonadeProvider = providerPageUtils.resolveDashboardProviderInfo("lemonade");
const searchProvider = providerPageUtils.resolveDashboardProviderInfo("brave-search");
const youcomSearchProvider = providerPageUtils.resolveDashboardProviderInfo("youcom-search");
const audioProvider = providerPageUtils.resolveDashboardProviderInfo("assemblyai");
const awsPollyProvider = providerPageUtils.resolveDashboardProviderInfo("aws-polly");
const webCookieProvider = providerPageUtils.resolveDashboardProviderInfo("grok-web");
const apiKeyProvider = providerPageUtils.resolveDashboardProviderInfo("glhf");
const gitlabProvider = providerPageUtils.resolveDashboardProviderInfo("gitlab");
@@ -232,6 +234,7 @@ test("static catalog entries resolve local, search, audio, web-cookie and upstre
const nousProvider = providerPageUtils.resolveDashboardProviderInfo("nous-research");
const petalsProvider = providerPageUtils.resolveDashboardProviderInfo("petals");
const poeProvider = providerPageUtils.resolveDashboardProviderInfo("poe");
const azureOpenAiProvider = providerPageUtils.resolveDashboardProviderInfo("azure-openai");
const azureAiProvider = providerPageUtils.resolveDashboardProviderInfo("azure-ai");
const watsonxProvider = providerPageUtils.resolveDashboardProviderInfo("watsonx");
const ociProvider = providerPageUtils.resolveDashboardProviderInfo("oci");
@@ -254,6 +257,8 @@ test("static catalog entries resolve local, search, audio, web-cookie and upstre
assert.equal(localProvider?.name, providers.LOCAL_PROVIDERS.sdwebui.name);
assert.equal(localChatProvider?.category, "local");
assert.equal(localChatProvider?.name, providers.LOCAL_PROVIDERS["lm-studio"].name);
assert.equal(lemonadeProvider?.category, "local");
assert.equal(lemonadeProvider?.name, providers.LOCAL_PROVIDERS.lemonade.name);
assert.equal(searchProvider?.category, "search");
assert.equal(searchProvider?.name, providers.SEARCH_PROVIDERS["brave-search"].name);
@@ -262,6 +267,8 @@ test("static catalog entries resolve local, search, audio, web-cookie and upstre
assert.equal(audioProvider?.category, "audio");
assert.equal(audioProvider?.name, providers.AUDIO_ONLY_PROVIDERS.assemblyai.name);
assert.equal(awsPollyProvider?.category, "audio");
assert.equal(awsPollyProvider?.name, providers.AUDIO_ONLY_PROVIDERS["aws-polly"].name);
assert.equal(apiKeyProvider?.category, "apikey");
assert.equal(apiKeyProvider?.name, providers.APIKEY_PROVIDERS.glhf.name);
@@ -283,6 +290,8 @@ test("static catalog entries resolve local, search, audio, web-cookie and upstre
assert.equal(petalsProvider?.name, providers.APIKEY_PROVIDERS.petals.name);
assert.equal(poeProvider?.category, "apikey");
assert.equal(poeProvider?.name, providers.APIKEY_PROVIDERS.poe.name);
assert.equal(azureOpenAiProvider?.category, "apikey");
assert.equal(azureOpenAiProvider?.name, providers.APIKEY_PROVIDERS["azure-openai"].name);
assert.equal(azureAiProvider?.category, "apikey");
assert.equal(azureAiProvider?.name, providers.APIKEY_PROVIDERS["azure-ai"].name);
assert.equal(watsonxProvider?.category, "apikey");
@@ -339,6 +348,7 @@ test("managed provider connection ids include supported static categories and ex
assert.equal(providerCatalog.isManagedProviderConnectionId("nous-research"), true);
assert.equal(providerCatalog.isManagedProviderConnectionId("petals"), true);
assert.equal(providerCatalog.isManagedProviderConnectionId("poe"), true);
assert.equal(providerCatalog.isManagedProviderConnectionId("azure-openai"), true);
assert.equal(providerCatalog.isManagedProviderConnectionId("azure-ai"), true);
assert.equal(providerCatalog.isManagedProviderConnectionId("bedrock"), true);
assert.equal(providerCatalog.isManagedProviderConnectionId("watsonx"), true);
@@ -353,7 +363,9 @@ test("managed provider connection ids include supported static categories and ex
assert.equal(providerCatalog.isManagedProviderConnectionId("sdwebui"), true);
assert.equal(providerCatalog.isManagedProviderConnectionId("lm-studio"), true);
assert.equal(providerCatalog.isManagedProviderConnectionId("vllm"), true);
assert.equal(providerCatalog.isManagedProviderConnectionId("lemonade"), true);
assert.equal(providerCatalog.isManagedProviderConnectionId("assemblyai"), true);
assert.equal(providerCatalog.isManagedProviderConnectionId("aws-polly"), true);
assert.equal(providerCatalog.isManagedProviderConnectionId("grok-web"), true);
assert.equal(providerCatalog.isManagedProviderConnectionId("perplexity-web"), true);
assert.equal(providerCatalog.isManagedProviderConnectionId("blackbox-web"), true);
@@ -373,6 +385,8 @@ test("grok-web taxonomy stays web-cookie only and does not leak into api-key ent
assert.equal("lm-studio" in providers.LOCAL_PROVIDERS, true);
assert.equal("vllm" in providers.APIKEY_PROVIDERS, false);
assert.equal("vllm" in providers.LOCAL_PROVIDERS, true);
assert.equal("lemonade" in providers.APIKEY_PROVIDERS, false);
assert.equal("lemonade" in providers.LOCAL_PROVIDERS, true);
assert.equal("comfyui" in providers.APIKEY_PROVIDERS, false);
assert.equal("comfyui" in providers.LOCAL_PROVIDERS, true);
assert.equal("blackbox-web" in providers.APIKEY_PROVIDERS, false);
@@ -430,6 +444,10 @@ test("grok-web taxonomy stays web-cookie only and does not leak into api-key ent
apiKeyEntries.some((entry) => entry.providerId === "vllm"),
false
);
assert.equal(
apiKeyEntries.some((entry) => entry.providerId === "lemonade"),
false
);
assert.equal(
apiKeyEntries.some((entry) => entry.providerId === "grok-web"),
false
@@ -542,6 +560,10 @@ test("grok-web taxonomy stays web-cookie only and does not leak into api-key ent
localEntries.some((entry) => entry.providerId === "vllm"),
true
);
assert.equal(
localEntries.some((entry) => entry.providerId === "lemonade"),
true
);
assert.equal(
webCookieEntries.some((entry) => entry.providerId === "grok-web"),
true

View File

@@ -133,6 +133,17 @@ test("providers route accepts managed local, audio, web-cookie and search provid
name: "Azure AI Foundry Primary",
},
},
{
provider: "azure-openai",
body: {
provider: "azure-openai",
apiKey: "azure-openai-key",
name: "Azure OpenAI Primary",
providerSpecificData: {
baseUrl: "https://my-resource.openai.azure.com",
},
},
},
{
provider: "bedrock",
body: {
@@ -249,6 +260,16 @@ test("providers route accepts managed local, audio, web-cookie and search provid
},
},
},
{
provider: "lemonade",
body: {
provider: "lemonade",
name: "Lemonade Local",
providerSpecificData: {
baseUrl: "http://localhost:13305/api/v1",
},
},
},
{
provider: "llamafile",
body: {
@@ -307,6 +328,18 @@ test("providers route accepts managed local, audio, web-cookie and search provid
name: "AssemblyAI Primary",
},
},
{
provider: "aws-polly",
body: {
provider: "aws-polly",
apiKey: "aws-secret-key",
name: "AWS Polly Primary",
providerSpecificData: {
accessKeyId: "AKIA_TEST",
region: "us-east-1",
},
},
},
{
provider: "grok-web",
body: {

View File

@@ -11,7 +11,7 @@ test("system sidebar items place logs before health", () => {
assert.ok(systemSection, "expected system sidebar section to exist");
assert.deepEqual(
systemSection.items.map((item) => item.id),
["logs", "health", "audit", "settings"]
["logs", "health", "settings"]
);
});