From 7d34b6a7e1f97dee3ddc8ffce39fac573dbba424 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 24 Apr 2026 13:20:59 -0300 Subject: [PATCH] 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. --- CHANGELOG.md | 35 +++- open-sse/config/audioRegistry.ts | 16 ++ open-sse/config/providerRegistry.ts | 11 ++ open-sse/executors/default.ts | 1 + open-sse/handlers/audioSpeech.ts | 153 +++++++++++++++++- .../translator/request/openai-to-gemini.ts | 11 +- open-sse/utils/awsSigV4.ts | 139 ++++++++++++++++ src/app/api/providers/[id]/models/route.ts | 114 +++++++++++++ src/app/api/providers/[id]/route.ts | 13 +- src/app/api/providers/route.ts | 14 +- src/app/api/v1/models/catalog.ts | 29 ++-- src/lib/logRotation.ts | 1 + src/lib/providers/requestDefaults.ts | 13 ++ src/lib/providers/validation.ts | 69 ++++++++ src/shared/constants/providers.ts | 25 +++ tests/unit/accountSemaphore.test.ts | 7 +- tests/unit/audio-speech-handler.test.ts | 59 +++++++ .../unit/bailian-coding-plan-provider.test.ts | 10 +- tests/unit/batch_api.test.ts | 3 +- tests/unit/chat-context-relay.test.ts | 2 + tests/unit/executor-default-base.test.ts | 3 + tests/unit/executor-gemini-cli.test.ts | 5 +- .../glm-provider-model-import-route.test.ts | 13 +- tests/unit/model-catalog-search.test.ts | 4 +- tests/unit/next-config.test.ts | 1 + tests/unit/oauth-providers-config.test.ts | 14 ++ tests/unit/plan3-p0.test.ts | 9 +- tests/unit/provider-models-route.test.ts | 102 ++++++++++-- .../provider-validation-specialty.test.ts | 51 +++++- tests/unit/providers-page-utils.test.ts | 22 +++ .../providers-route-managed-catalog.test.ts | 33 ++++ tests/unit/sidebar-visibility.test.ts | 2 +- 32 files changed, 922 insertions(+), 62 deletions(-) create mode 100644 open-sse/utils/awsSigV4.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f51512784..3116783bd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/open-sse/config/audioRegistry.ts b/open-sse/config/audioRegistry.ts index d95c1483c2..9841fc7399 100644 --- a/open-sse/config/audioRegistry.ts +++ b/open-sse/config/audioRegistry.ts @@ -246,6 +246,22 @@ export const AUDIO_SPEECH_PROVIDERS: Record = { { 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" }, + ], + }, }; /** diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 445b04c5d0..741f0d5043 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -1420,6 +1420,17 @@ export const REGISTRY: Record = { 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", diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 366808ee9a..f587e11582 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -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": diff --git a/open-sse/handlers/audioSpeech.ts b/open-sse/handlers/audioSpeech.ts index 424ef10774..a867a4a269 100644 --- a/open-sse/handlers/audioSpeech.ts +++ b/open-sse/handlers/audioSpeech.ts @@ -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("; + 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) { + const normalized: Record = {}; + 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 { + 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(" "), + }; +} diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 25fa0b413e..8b42b66170 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -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; diff --git a/src/app/api/providers/[id]/route.ts b/src/app/api/providers/[id]/route.ts index 9755402ac5..e826d35366 100644 --- a/src/app/api/providers/[id]/route.ts +++ b/src/app/api/providers/[id]/route.ts @@ -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 diff --git a/src/app/api/providers/route.ts b/src/app/api/providers/route.ts index 99c9906ad3..55b436b46c 100644 --- a/src/app/api/providers/route.ts +++ b/src/app/api/providers/route.ts @@ -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 = { ...newConnection }; delete result.apiKey; if (result.providerSpecificData) { - delete result.providerSpecificData.consoleApiKey; + result.providerSpecificData = sanitizeProviderSpecificDataForResponse( + result.providerSpecificData + ); } // Auto sync to Cloud if enabled diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 36228393a4..633e2da26c 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -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, }); } } diff --git a/src/lib/logRotation.ts b/src/lib/logRotation.ts index 63e158d163..600ea24fbe 100644 --- a/src/lib/logRotation.ts +++ b/src/lib/logRotation.ts @@ -191,6 +191,7 @@ export function initLogRotation(): void { config.maxFileSize, config.maxFiles ); + rotationTimer.unref?.(); } /** diff --git a/src/lib/providers/requestDefaults.ts b/src/lib/providers/requestDefaults.ts index 18e1e026c0..dd3b32ff82 100644 --- a/src/lib/providers/requestDefaults.ts +++ b/src/lib/providers/requestDefaults.ts @@ -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; } diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 7ca8cd6cd4..e378e9776a 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -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, diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index eaed4b33ee..dbca2f5fb0 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -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", diff --git a/tests/unit/accountSemaphore.test.ts b/tests/unit/accountSemaphore.test.ts index e2f58ae24b..0b8ece6c82 100644 --- a/tests/unit/accountSemaphore.test.ts +++ b/tests/unit/accountSemaphore.test.ts @@ -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); diff --git a/tests/unit/audio-speech-handler.test.ts b/tests/unit/audio-speech-handler.test.ts index 8dc6fcd809..181209dcf6 100644 --- a/tests/unit/audio-speech-handler.test.ts +++ b/tests/unit/audio-speech-handler.test.ts @@ -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, + 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: { diff --git a/tests/unit/bailian-coding-plan-provider.test.ts b/tests/unit/bailian-coding-plan-provider.test.ts index b071511f51..dd31b4c0e3 100644 --- a/tests/unit/bailian-coding-plan-provider.test.ts +++ b/tests/unit/bailian-coding-plan-provider.test.ts @@ -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"] ); }); diff --git a/tests/unit/batch_api.test.ts b/tests/unit/batch_api.test.ts index 0f7b49bb0a..ed9068ea7c 100644 --- a/tests/unit/batch_api.test.ts +++ b/tests/unit/batch_api.test.ts @@ -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); diff --git a/tests/unit/chat-context-relay.test.ts b/tests/unit/chat-context-relay.test.ts index 168acf6a9e..ccb2acb253 100644 --- a/tests/unit/chat-context-relay.test.ts +++ b/tests/unit/chat-context-relay.test.ts @@ -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", diff --git a/tests/unit/executor-default-base.test.ts b/tests/unit/executor-default-base.test.ts index b851cffb2c..704f075507 100644 --- a/tests/unit/executor-default-base.test.ts +++ b/tests/unit/executor-default-base.test.ts @@ -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"); }); diff --git a/tests/unit/executor-gemini-cli.test.ts b/tests/unit/executor-gemini-cli.test.ts index fb28a10ac7..128dbab183 100644 --- a/tests/unit/executor-gemini-cli.test.ts +++ b/tests/unit/executor-gemini-cli.test.ts @@ -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/); }); diff --git a/tests/unit/glm-provider-model-import-route.test.ts b/tests/unit/glm-provider-model-import-route.test.ts index c8d1dfb20e..4d4fe82dde 100644 --- a/tests/unit/glm-provider-model-import-route.test.ts +++ b/tests/unit/glm-provider-model-import-route.test.ts @@ -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; } diff --git a/tests/unit/model-catalog-search.test.ts b/tests/unit/model-catalog-search.test.ts index b0bec7e9f8..00c6d45c79 100644 --- a/tests/unit/model-catalog-search.test.ts +++ b/tests/unit/model-catalog-search.test.ts @@ -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"); diff --git a/tests/unit/next-config.test.ts b/tests/unit/next-config.test.ts index 59daf9d363..96a2fbc4e6 100644 --- a/tests/unit/next-config.test.ts +++ b/tests/unit/next-config.test.ts @@ -110,5 +110,6 @@ test("next config webpack client branch disables Node builtins in browser bundle net: false, tls: false, crypto: false, + process: false, }); }); diff --git a/tests/unit/oauth-providers-config.test.ts b/tests/unit/oauth-providers-config.test.ts index 0d7d8ceace..9366a01519 100644 --- a/tests/unit/oauth-providers-config.test.ts +++ b/tests/unit/oauth-providers-config.test.ts @@ -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", diff --git a/tests/unit/plan3-p0.test.ts b/tests/unit/plan3-p0.test.ts index 5995642f0a..3a722d288b 100644 --- a/tests/unit/plan3-p0.test.ts +++ b/tests/unit/plan3-p0.test.ts @@ -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$/); }); diff --git a/tests/unit/provider-models-route.test.ts b/tests/unit/provider-models-route.test.ts index 1e82627550..7eff46411c 100644 --- a/tests/unit/provider-models-route.test.ts +++ b/tests/unit/provider-models-route.test.ts @@ -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", diff --git a/tests/unit/provider-validation-specialty.test.ts b/tests/unit/provider-validation-specialty.test.ts index 6e6ec8274e..10f59c13fc 100644 --- a/tests/unit/provider-validation-specialty.test.ts +++ b/tests/unit/provider-validation-specialty.test.ts @@ -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; + + 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; diff --git a/tests/unit/providers-page-utils.test.ts b/tests/unit/providers-page-utils.test.ts index 72b2880c6e..b0db8277ff 100644 --- a/tests/unit/providers-page-utils.test.ts +++ b/tests/unit/providers-page-utils.test.ts @@ -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 diff --git a/tests/unit/providers-route-managed-catalog.test.ts b/tests/unit/providers-route-managed-catalog.test.ts index 654c700225..90b4e65003 100644 --- a/tests/unit/providers-route-managed-catalog.test.ts +++ b/tests/unit/providers-route-managed-catalog.test.ts @@ -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: { diff --git a/tests/unit/sidebar-visibility.test.ts b/tests/unit/sidebar-visibility.test.ts index 14a33c10f0..9b899d645a 100644 --- a/tests/unit/sidebar-visibility.test.ts +++ b/tests/unit/sidebar-visibility.test.ts @@ -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"] ); });