mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(providers): add AWS Polly and Lemonade provider support
Register AWS Polly as an audio provider with SigV4 request signing, speech engine discovery, and API-key validation for managed provider flows. Add Lemonade as a self-hosted OpenAI-compatible provider, expand static model discovery to audio registries, and support Azure OpenAI deployment discovery from resource endpoints. Sanitize sensitive provider-specific AWS fields in API responses and update related tests and release notes.
This commit is contained in:
35
CHANGELOG.md
35
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)
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
139
open-sse/utils/awsSigV4.ts
Normal 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(" "),
|
||||
};
|
||||
}
|
||||
@@ -52,6 +52,10 @@ import {
|
||||
} from "@omniroute/open-sse/config/antigravityModelAliases.ts";
|
||||
import { getEmbeddingProvider } from "@omniroute/open-sse/config/embeddingRegistry.ts";
|
||||
import { getRerankProvider } from "@omniroute/open-sse/config/rerankRegistry.ts";
|
||||
import {
|
||||
getSpeechProvider,
|
||||
getTranscriptionProvider,
|
||||
} from "@omniroute/open-sse/config/audioRegistry.ts";
|
||||
import {
|
||||
getCachedDiscoveredModels,
|
||||
isAutoFetchModelsEnabled,
|
||||
@@ -76,6 +80,21 @@ function getProviderBaseUrl(providerSpecificData: unknown): string | null {
|
||||
return typeof baseUrl === "string" && baseUrl.trim().length > 0 ? baseUrl : null;
|
||||
}
|
||||
|
||||
function normalizeAzureOpenAIBaseUrl(baseUrl: string) {
|
||||
return baseUrl
|
||||
.trim()
|
||||
.replace(/\/+$/, "")
|
||||
.replace(/\/openai$/i, "")
|
||||
.replace(/\/openai\/deployments\/[^/]+\/chat\/completions.*$/i, "");
|
||||
}
|
||||
|
||||
function getAzureOpenAIApiVersion(providerSpecificData: unknown) {
|
||||
const data = asRecord(providerSpecificData);
|
||||
const apiVersion =
|
||||
toNonEmptyString(data.apiVersion) || toNonEmptyString(data.validationApiVersion);
|
||||
return apiVersion || "2024-12-01-preview";
|
||||
}
|
||||
|
||||
function isLocalOpenAIStyleProvider(provider: string): boolean {
|
||||
return isSelfHostedChatProvider(provider);
|
||||
}
|
||||
@@ -353,6 +372,22 @@ export function getStaticModelsForProvider(
|
||||
}));
|
||||
}
|
||||
|
||||
const speechProvider = getSpeechProvider(provider);
|
||||
if (speechProvider) {
|
||||
return speechProvider.models.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
}));
|
||||
}
|
||||
|
||||
const transcriptionProvider = getTranscriptionProvider(provider);
|
||||
if (transcriptionProvider) {
|
||||
return transcriptionProvider.models.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
}));
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1116,6 +1151,85 @@ export async function GET(
|
||||
return buildApiDiscoveryResponse(models.filter((model) => model.id));
|
||||
}
|
||||
|
||||
if (provider === "azure-openai") {
|
||||
const cachedResponse = maybeReturnCachedDiscovery();
|
||||
if (cachedResponse) return cachedResponse;
|
||||
|
||||
const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled();
|
||||
if (autoFetchDisabledResponse) return autoFetchDisabledResponse;
|
||||
|
||||
const token = accessToken || apiKey;
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"No API key configured for this provider. Please add an API key in the provider settings.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const rawBaseUrl = getProviderBaseUrl(connection.providerSpecificData);
|
||||
if (!rawBaseUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: "No Azure OpenAI resource endpoint configured" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = normalizeAzureOpenAIBaseUrl(rawBaseUrl);
|
||||
const apiVersion = encodeURIComponent(
|
||||
getAzureOpenAIApiVersion(connection.providerSpecificData)
|
||||
);
|
||||
const discoveryUrls = [
|
||||
`${baseUrl}/openai/deployments?api-version=${apiVersion}`,
|
||||
`${baseUrl}/openai/models?api-version=${apiVersion}`,
|
||||
];
|
||||
|
||||
let lastStatus = 0;
|
||||
for (const modelsUrl of discoveryUrls) {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await safeOutboundFetch(modelsUrl, {
|
||||
...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery,
|
||||
guard: getProviderOutboundGuard(),
|
||||
proxyConfig: proxy,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"api-key": token,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const fallback = buildDiscoveryErrorFallbackResponse(error, {
|
||||
cacheWarning: "Azure OpenAI models API unavailable — using cached catalog",
|
||||
localWarning: "Azure OpenAI models API unavailable — using local catalog",
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return buildApiDiscoveryResponse(
|
||||
normalizeOpenAiLikeModelsResponse(await response.json(), "azure-openai")
|
||||
);
|
||||
}
|
||||
|
||||
lastStatus = response.status;
|
||||
if (response.status === 401 || response.status === 403) break;
|
||||
}
|
||||
|
||||
const fallback = buildDiscoveryFallbackResponse({
|
||||
cacheWarning: `Azure OpenAI models probe failed (${lastStatus}) — using cached catalog`,
|
||||
localWarning: `Azure OpenAI models probe failed (${lastStatus}) — using local catalog`,
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to fetch models: ${lastStatus || "unknown"}` },
|
||||
{ status: lastStatus || 502 }
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === "watsonx") {
|
||||
const cachedResponse = maybeReturnCachedDiscovery();
|
||||
if (cachedResponse) return cachedResponse;
|
||||
|
||||
@@ -14,7 +14,10 @@ import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/lib/cloudSync";
|
||||
import { updateProviderConnectionSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { normalizeProviderSpecificData } from "@/lib/providers/requestDefaults";
|
||||
import {
|
||||
normalizeProviderSpecificData,
|
||||
sanitizeProviderSpecificDataForResponse,
|
||||
} from "@/lib/providers/requestDefaults";
|
||||
import {
|
||||
buildClaudeExtraUsageStateClearUpdate,
|
||||
isClaudeExtraUsageBlockEnabled,
|
||||
@@ -65,7 +68,9 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
|
||||
delete result.refreshToken;
|
||||
delete result.idToken;
|
||||
if (result.providerSpecificData) {
|
||||
delete result.providerSpecificData.consoleApiKey;
|
||||
result.providerSpecificData = sanitizeProviderSpecificDataForResponse(
|
||||
result.providerSpecificData
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ connection: result });
|
||||
@@ -193,7 +198,9 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||
delete result.refreshToken;
|
||||
delete result.idToken;
|
||||
if (result.providerSpecificData) {
|
||||
delete result.providerSpecificData.consoleApiKey;
|
||||
result.providerSpecificData = sanitizeProviderSpecificDataForResponse(
|
||||
result.providerSpecificData
|
||||
);
|
||||
}
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
|
||||
@@ -20,7 +20,10 @@ import { syncToCloud } from "@/lib/cloudSync";
|
||||
import { createProviderSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { normalizeQoderPatProviderData } from "@omniroute/open-sse/services/qoderCli";
|
||||
import { normalizeProviderSpecificData } from "@/lib/providers/requestDefaults";
|
||||
import {
|
||||
normalizeProviderSpecificData,
|
||||
sanitizeProviderSpecificDataForResponse,
|
||||
} from "@/lib/providers/requestDefaults";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { isManagedProviderConnectionId } from "@/lib/providers/catalog";
|
||||
|
||||
@@ -40,10 +43,7 @@ export async function GET(request: Request) {
|
||||
refreshToken: undefined,
|
||||
idToken: undefined,
|
||||
providerSpecificData: c.providerSpecificData
|
||||
? {
|
||||
...c.providerSpecificData,
|
||||
consoleApiKey: undefined,
|
||||
}
|
||||
? sanitizeProviderSpecificDataForResponse(c.providerSpecificData)
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
@@ -173,7 +173,9 @@ export async function POST(request: Request) {
|
||||
const result: Record<string, any> = { ...newConnection };
|
||||
delete result.apiKey;
|
||||
if (result.providerSpecificData) {
|
||||
delete result.providerSpecificData.consoleApiKey;
|
||||
result.providerSpecificData = sanitizeProviderSpecificDataForResponse(
|
||||
result.providerSpecificData
|
||||
);
|
||||
}
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
|
||||
@@ -371,13 +371,25 @@ export async function getUnifiedModelsResponse(
|
||||
if (getModelIsHidden(providerId, sm.id)) continue;
|
||||
|
||||
const aliasId = `${alias}/${sm.id}`;
|
||||
if (models.some((model) => model.id === aliasId)) continue;
|
||||
|
||||
const endpoints = Array.isArray(sm.supportedEndpoints) ? sm.supportedEndpoints : ["chat"];
|
||||
let modelType: string | undefined;
|
||||
if (endpoints.includes("embeddings")) modelType = "embedding";
|
||||
else if (endpoints.includes("images")) modelType = "image";
|
||||
else if (endpoints.includes("audio")) modelType = "audio";
|
||||
const syncedFields = {
|
||||
...(modelType ? { type: modelType } : {}),
|
||||
...(modelType === "audio" ? { subtype: "transcription" } : {}),
|
||||
...(sm.inputTokenLimit ? { context_length: sm.inputTokenLimit } : {}),
|
||||
...(endpoints.length > 1 || !endpoints.includes("chat")
|
||||
? { supported_endpoints: endpoints }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const existingAliasModel = models.find((model) => model.id === aliasId);
|
||||
if (existingAliasModel) {
|
||||
Object.assign(existingAliasModel, syncedFields);
|
||||
continue;
|
||||
}
|
||||
|
||||
models.push({
|
||||
id: aliasId,
|
||||
@@ -387,12 +399,7 @@ export async function getUnifiedModelsResponse(
|
||||
permission: [],
|
||||
root: sm.id,
|
||||
parent: null,
|
||||
...(modelType ? { type: modelType } : {}),
|
||||
...(modelType === "audio" ? { subtype: "transcription" } : {}),
|
||||
...(sm.inputTokenLimit ? { context_length: sm.inputTokenLimit } : {}),
|
||||
...(endpoints.length > 1 || !endpoints.includes("chat")
|
||||
? { supported_endpoints: endpoints }
|
||||
: {}),
|
||||
...syncedFields,
|
||||
});
|
||||
|
||||
if (modelType === "audio") {
|
||||
@@ -424,11 +431,7 @@ export async function getUnifiedModelsResponse(
|
||||
permission: [],
|
||||
root: sm.id,
|
||||
parent: aliasId,
|
||||
...(modelType ? { type: modelType } : {}),
|
||||
...(sm.inputTokenLimit ? { context_length: sm.inputTokenLimit } : {}),
|
||||
...(endpoints.length > 1 || !endpoints.includes("chat")
|
||||
? { supported_endpoints: endpoints }
|
||||
: {}),
|
||||
...syncedFields,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +191,7 @@ export function initLogRotation(): void {
|
||||
config.maxFileSize,
|
||||
config.maxFiles
|
||||
);
|
||||
rotationTimer.unref?.();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"]
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -110,5 +110,6 @@ test("next config webpack client branch disables Node builtins in browser bundle
|
||||
net: false,
|
||||
tls: false,
|
||||
crypto: false,
|
||||
process: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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$/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"]
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user