chore: merge release/v3.8.0 into PR branch

This commit is contained in:
diegosouzapw
2026-05-18 15:48:43 -03:00
28 changed files with 3014 additions and 339 deletions

View File

@@ -35,12 +35,25 @@ function buildComboTestResult(target, partial = {}) {
async function testComboTarget(target, baseInternalUrl, internalApiKey: string | null) {
const startTime = Date.now();
try {
// Issue #2359: combo entries with a malformed/missing modelStr surfaced
// as `e.startsWith is not a function` / similar TypeError 500s. Coerce
// defensively at the boundary so the test path returns a clean error
// instead of crashing the request handler.
const modelStr = typeof target?.modelStr === "string" ? target.modelStr : "";
if (!modelStr) {
return buildComboTestResult(target, {
status: "error",
error: "Combo step is missing a model id (modelStr). Re-save the combo to refresh it.",
latencyMs: 0,
});
}
const modelLower = modelStr.toLowerCase();
const isEmbedding =
target.modelStr.toLowerCase().includes("embedding") ||
target.modelStr.toLowerCase().includes("bge-") ||
target.modelStr.toLowerCase().includes("text-embed");
modelLower.includes("embedding") ||
modelLower.includes("bge-") ||
modelLower.includes("text-embed");
const internalUrl = `${baseInternalUrl}/v1/${isEmbedding ? "embeddings" : "chat/completions"}`;
const testBody = buildComboTestRequestBody(target.modelStr, isEmbedding);
const testBody = buildComboTestRequestBody(modelStr, isEmbedding);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 20000);

View File

@@ -119,8 +119,8 @@ const CACHE_TTL = 60 * 1000; // 1 minute TTL
const LAST_USED_UPDATE_TTL = 5 * 60 * 1000;
const MAX_CACHE_SIZE = 1000;
// Compiled regex cache for wildcard patterns
const _regexCache = new Map<string, RegExp>();
// Wildcard scope matching is now handled by `matchesWildcardPattern`
// (deterministic, no RegExp from dynamic strings).
const API_KEY_COLUMN_FALLBACKS = [
{ name: "allowed_models", definition: "allowed_models TEXT" },
@@ -187,6 +187,7 @@ async function deleteRedisAuthCacheEntry(keyHash: unknown): Promise<void> {
try {
const { getRedisClient } = await import("@/shared/utils/rateLimiter");
const redis = getRedisClient();
if (!redis) return; // #2357: Redis is optional; skip when disabled.
await redis.del(`auth:api_key:${keyHash}`);
} catch {
// Redis is an optimization for auth caching; SQLite remains authoritative.
@@ -235,21 +236,57 @@ function evictIfNeeded<TKey, TValue>(cache: Map<TKey, TValue>) {
}
/**
* Get or compile regex for wildcard pattern
* Match an API-key wildcard scope pattern against a model id without
* compiling a RegExp from string concatenation (avoid ReDoS exposure on
* operator-supplied patterns and silence the Semgrep `js/regex-injection`
* advisory for `new RegExp(<dynamic>)`).
*
* Supported pattern syntax (only what real scopes use):
* - literal segments
* - `*` matches any run of characters, but does NOT cross `/`
*
* Walks the pattern token-by-token: each `*` consumes the longest possible
* run within the current path segment, then the next literal anchor must
* appear before the segment boundary. Worst-case complexity is O(n*m)
* where n = pattern length, m = candidate length — there is no nested
* backtracking that could explode adversarially.
*/
function getWildcardRegex(pattern: string): RegExp {
let regex = _regexCache.get(pattern);
if (!regex) {
const regexStr = pattern.replace(/\*/g, ".*");
regex = new RegExp(`^${regexStr}$`);
_regexCache.set(pattern, regex);
// Prevent unbounded growth
if (_regexCache.size > 100) {
const firstKey = _regexCache.keys().next().value;
if (firstKey) _regexCache.delete(firstKey);
}
function matchesWildcardPattern(pattern: string, candidate: string): boolean {
const pSegs = pattern.split("/");
const cSegs = candidate.split("/");
if (pSegs.length !== cSegs.length) return false;
for (let i = 0; i < pSegs.length; i++) {
if (!segmentMatchesWildcard(pSegs[i], cSegs[i])) return false;
}
return regex;
return true;
}
function segmentMatchesWildcard(pattern: string, segment: string): boolean {
if (pattern === segment) return true;
if (!pattern.includes("*")) return false;
const parts = pattern.split("*");
// Anchor first literal to the start.
let cursor = 0;
const first = parts[0];
if (first) {
if (!segment.startsWith(first)) return false;
cursor = first.length;
}
// Anchor last literal to the end.
const last = parts[parts.length - 1];
const endLimit = segment.length - last.length;
if (last) {
if (!segment.endsWith(last)) return false;
}
// Each middle literal must appear in order between cursor and endLimit.
for (let i = 1; i < parts.length - 1; i++) {
const piece = parts[i];
if (!piece) continue;
const idx = segment.indexOf(piece, cursor);
if (idx === -1 || idx + piece.length > endLimit) return false;
cursor = idx + piece.length;
}
return cursor <= endLimit;
}
function ensureApiKeyColumn(
@@ -858,6 +895,7 @@ export async function validateApiKey(key: string | null | undefined) {
try {
const { getRedisClient } = await import("@/shared/utils/rateLimiter");
const redis = getRedisClient();
if (!redis) throw new Error("redis-disabled"); // #2357: optional
const redisKey = `auth:api_key:${hashedKey}`;
const redisData = await redis.get(redisKey);
if (redisData) {
@@ -909,6 +947,9 @@ export async function validateApiKey(key: string | null | undefined) {
try {
const { getRedisClient } = await import("@/shared/utils/rateLimiter");
const redis = getRedisClient();
// #2357: Redis is optional; throw so the catch below skips the write
// without affecting the function's `Promise<boolean>` return type.
if (!redis) throw new Error("redis-disabled");
const redisKey = `auth:api_key:${hashedKey}`;
await redis.set(
redisKey,
@@ -1081,10 +1122,10 @@ export async function isModelAllowedForKey(
break;
}
}
// Support wildcard patterns using cached regex
// Support wildcard patterns via deterministic matcher (no RegExp
// compilation from operator input — avoids ReDoS exposure).
if (pattern.includes("*")) {
const regex = getWildcardRegex(pattern);
if (regex.test(modelId)) {
if (matchesWildcardPattern(pattern, modelId)) {
allowed = true;
break;
}
@@ -1120,7 +1161,6 @@ export function clearApiKeyCaches() {
invalidateCaches();
_lastUsedUpdateCache.clear();
_modelPermissionCache.clear();
_regexCache.clear();
}
/**

View File

@@ -1,11 +0,0 @@
CREATE TABLE IF NOT EXISTS session_account_affinity (
session_key TEXT NOT NULL,
provider TEXT NOT NULL,
connection_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL,
PRIMARY KEY (session_key, provider)
);
CREATE INDEX IF NOT EXISTS idx_saa_provider ON session_account_affinity(provider);
CREATE INDEX IF NOT EXISTS idx_saa_last_seen ON session_account_affinity(last_seen_at);

View File

@@ -11,7 +11,8 @@ import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import * as log from "@/sse/utils/logger";
import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
import { getProviderCredentials, clearRecoveredProviderState } from "@/sse/services/auth";
import { getProviderNodes } from "@/lib/localDb";
import { getProviderNodes, getComboByName, getCombos, getDatabaseSettings } from "@/lib/localDb";
import { handleComboChat } from "@omniroute/open-sse/services/combo.ts";
type ValidatedEmbeddingBody = Record<string, unknown> & { model: string };
@@ -30,6 +31,42 @@ export async function createEmbeddingResponse(
body: ValidatedEmbeddingBody,
options: EmbeddingHandlerOptions = {}
): Promise<Response> {
const modelStr = body.model;
if (!modelStr.includes("/")) {
try {
const combo = await getComboByName(modelStr);
if (combo) {
let allCombos = [];
try {
allCombos = await getCombos();
} catch {}
let settings = {};
try {
settings = getDatabaseSettings();
} catch {}
return handleComboChat({
body,
combo,
handleSingleModel: async (reqBody: any, targetModelStr: string, target?: any) => {
const newBody = { ...reqBody, model: targetModelStr };
return createEmbeddingResponse(newBody, {
...options,
connectionId: target?.connectionId || options.connectionId,
});
},
log,
settings,
allCombos,
signal: undefined,
});
}
} catch (err) {
log.error("EMBED", `Combo resolution failed for ${modelStr}: ${err}`);
}
}
let dynamicProviders: ReturnType<typeof buildDynamicEmbeddingProvider>[] = [];
try {
const nodes = (await getProviderNodes()) as unknown as EmbeddingProviderNodeRow[];

View File

@@ -260,18 +260,18 @@ function buildTokenHeaders(apiKey: string, providerSpecificData: any = {}) {
return applyCustomUserAgent(headers, providerSpecificData);
}
async function validationRead(url: string, init: RequestInit) {
async function validationRead(url: string, init: RequestInit, isLocal: boolean = false) {
return safeOutboundFetch(url, {
...SAFE_OUTBOUND_FETCH_PRESETS.validationRead,
guard: getProviderOutboundGuard(),
guard: isLocal ? "none" : getProviderOutboundGuard(),
...init,
});
}
async function validationWrite(url: string, init: RequestInit) {
async function validationWrite(url: string, init: RequestInit, isLocal: boolean = false) {
return safeOutboundFetch(url, {
...SAFE_OUTBOUND_FETCH_PRESETS.validationWrite,
guard: getProviderOutboundGuard(),
guard: isLocal ? "none" : getProviderOutboundGuard(),
...init,
});
}
@@ -293,88 +293,112 @@ function toValidationErrorResult(error: unknown) {
}
async function validateOpenAILikeProvider({
provider,
apiKey,
baseUrl,
providerSpecificData = {},
modelId = "gpt-4o-mini",
modelsUrl: customModelsUrl,
}: {
provider: string;
apiKey: string;
baseUrl: string;
providerSpecificData?: any;
modelId?: string;
modelsUrl?: string;
}) {
if (!baseUrl) {
return { valid: false, error: "Missing base URL" };
}
headers = {},
modelId = "gpt-3.5-turbo",
providerSpecificData,
modelsUrl = "",
isLocal = false,
}: any) {
try {
const customModelsUrl = modelsUrl?.trim() || "";
const endpointUrl = customModelsUrl
? customModelsUrl.startsWith("http")
? customModelsUrl
: `${baseUrl.replace(/\/+$/, "")}/${customModelsUrl.replace(/^\/+/, "")}`
: `${baseUrl}/models`;
const modelsUrl = customModelsUrl || addModelsSuffix(baseUrl);
if (!modelsUrl) {
return { valid: false, error: "Invalid models endpoint" };
}
const requestUrl =
typeof providerSpecificData?.modelsUrl === "string" &&
providerSpecificData.modelsUrl.trim() !== ""
? providerSpecificData.modelsUrl.trim()
: endpointUrl;
const modelsRes = await validationRead(modelsUrl, {
method: "GET",
headers: buildBearerHeaders(apiKey, providerSpecificData),
});
const response = await validationRead(
requestUrl,
{
headers: {
...headers,
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
},
isLocal
);
if (response.ok) {
return { valid: true, error: null };
}
if (response.status === 401 || response.status === 403) {
return { valid: false, error: "Invalid API key" };
}
const chatUrl = resolveChatUrl("openai", baseUrl, providerSpecificData);
if (!chatUrl) {
return { valid: false, error: `Validation failed: ${response.status}` };
}
const testModelId = (providerSpecificData as any)?.validationModelId || modelId;
const testBody = {
model: testModelId,
messages: [{ role: "user", content: "test" }],
max_tokens: 1,
};
const chatRes = await validationWrite(
chatUrl,
{
method: "POST",
headers: {
...headers,
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
body: JSON.stringify(testBody),
},
isLocal
);
if (chatRes.ok) {
return { valid: true, error: null };
}
if (chatRes.status === 401 || chatRes.status === 403) {
return { valid: false, error: "Invalid API key" };
}
if (chatRes.status === 404 || chatRes.status === 405) {
return { valid: false, error: "Provider validation endpoint not supported" };
}
if (chatRes.status >= 500) {
return { valid: false, error: `Provider unavailable (${chatRes.status})` };
}
if (modelsRes.ok) {
return { valid: true, error: null };
} catch (error: any) {
return toValidationErrorResult(error);
}
if (modelsRes.status === 401 || modelsRes.status === 403) {
return { valid: false, error: "Invalid API key" };
}
const chatUrl = resolveChatUrl(provider, baseUrl, providerSpecificData);
if (!chatUrl) {
return { valid: false, error: `Validation failed: ${modelsRes.status}` };
}
const testModelId = (providerSpecificData as any)?.validationModelId || modelId;
const testBody = {
model: testModelId,
messages: [{ role: "user", content: "test" }],
max_tokens: 1,
};
const chatRes = await validationWrite(chatUrl, {
method: "POST",
headers: buildBearerHeaders(apiKey, providerSpecificData),
body: JSON.stringify(testBody),
});
if (chatRes.ok) {
return { valid: true, error: null };
}
if (chatRes.status === 401 || chatRes.status === 403) {
return { valid: false, error: "Invalid API key" };
}
if (chatRes.status === 404 || chatRes.status === 405) {
return { valid: false, error: "Provider validation endpoint not supported" };
}
if (chatRes.status >= 500) {
return { valid: false, error: `Provider unavailable (${chatRes.status})` };
}
// 4xx other than auth (e.g., invalid model/body) usually means auth passed.
return { valid: true, error: null };
}
async function validateDirectChatProvider({ url, headers, body, providerSpecificData = {} }: any) {
async function validateDirectChatProvider({
url,
headers,
body,
providerSpecificData = {},
isLocal = false,
}: any) {
try {
const response = await validationWrite(url, {
method: "POST",
headers: applyCustomUserAgent(headers, providerSpecificData),
body: JSON.stringify(body),
});
const response = await validationWrite(
url,
{
method: "POST",
headers: applyCustomUserAgent(headers, providerSpecificData),
body: JSON.stringify(body),
},
isLocal
);
if (response.status === 401 || response.status === 403) {
return { valid: false, error: "Invalid API key" };
@@ -590,59 +614,80 @@ async function validateRerankApiProvider({ apiKey, providerSpecificData = {}, ur
async function validateAnthropicLikeProvider({
apiKey,
baseUrl,
modelId,
modelId = "claude-3-5-sonnet-20240620",
headers = {},
providerSpecificData = {},
isLocal = false,
}: any) {
if (!baseUrl) {
return { valid: false, error: "Missing base URL" };
try {
const requestUrl =
typeof providerSpecificData?.modelsUrl === "string" &&
providerSpecificData.modelsUrl.trim() !== ""
? providerSpecificData.modelsUrl.trim()
: `${baseUrl}/models`;
const response = await validationRead(
requestUrl,
{
headers: {
"anthropic-version": "2023-06-01",
...headers,
},
},
isLocal
);
if (!baseUrl) {
return { valid: false, error: "Missing base URL" };
}
if (typeof apiKey === "string" && apiKey.startsWith("sk-ant-oat")) {
return validateClaudeOAuthInline({ apiKey, modelId, providerSpecificData });
}
const requestHeaders = applyCustomUserAgent(
{
"Content-Type": "application/json",
...headers,
},
providerSpecificData
);
if (!requestHeaders["x-api-key"] && !requestHeaders["X-API-Key"]) {
requestHeaders["x-api-key"] = apiKey;
}
if (!requestHeaders["anthropic-version"] && !requestHeaders["Anthropic-Version"]) {
requestHeaders["anthropic-version"] = "2023-06-01";
}
const testModelId =
providerSpecificData?.validationModelId || modelId || "claude-3-5-sonnet-20241022";
const chatResponse = await validationWrite(
baseUrl,
{
method: "POST",
headers: requestHeaders,
body: JSON.stringify({
model: testModelId,
max_tokens: 1,
messages: [{ role: "user", content: "test" }],
}),
},
isLocal
);
if (chatResponse.status === 401 || chatResponse.status === 403) {
return { valid: false, error: "Invalid API key" };
}
return { valid: true, error: null };
} catch (error: any) {
return toValidationErrorResult(error);
}
// OAuth tokens need the same Claude Code cloak as production traffic in
// base.ts; a bare validation request gets flagged on the user:sessions:
// claude_code scope.
if (typeof apiKey === "string" && apiKey.startsWith("sk-ant-oat")) {
return validateClaudeOAuthInline({ apiKey, modelId, providerSpecificData });
}
const requestHeaders = applyCustomUserAgent(
{
"Content-Type": "application/json",
...headers,
},
providerSpecificData
);
if (!requestHeaders["x-api-key"] && !requestHeaders["X-API-Key"]) {
requestHeaders["x-api-key"] = apiKey;
}
if (!requestHeaders["anthropic-version"] && !requestHeaders["Anthropic-Version"]) {
requestHeaders["anthropic-version"] = "2023-06-01";
}
const testModelId =
providerSpecificData?.validationModelId || modelId || "claude-3-5-sonnet-20241022";
const response = await validationWrite(baseUrl, {
method: "POST",
headers: requestHeaders,
body: JSON.stringify({
model: testModelId,
max_tokens: 1,
messages: [{ role: "user", content: "test" }],
}),
});
if (response.status === 401 || response.status === 403) {
return { valid: false, error: "Invalid API key" };
}
return { valid: true, error: null };
}
// Probe a Claude OAuth credential through the same executor that handles
// production traffic so the cloak/signing/identity logic isn't duplicated.
async function validateClaudeOAuthInline({
apiKey,
modelId,
@@ -673,7 +718,6 @@ async function validateClaudeOAuthInline({
if (response.status >= 500) {
return { valid: false, error: `Provider unavailable (${response.status})` };
}
// 2xx and non-auth 4xx (429 quota, 400 model) both mean the token is valid.
return { valid: true, error: null };
} catch (error: any) {
return toValidationErrorResult(error);
@@ -683,76 +727,79 @@ async function validateClaudeOAuthInline({
async function validateGeminiLikeProvider({
apiKey,
baseUrl,
authType,
providerSpecificData = {},
authType = "query",
isLocal = false,
}: any) {
if (!baseUrl) {
return { valid: false, error: "Missing base URL" };
}
try {
const requestUrl =
typeof providerSpecificData?.modelsUrl === "string" &&
providerSpecificData.modelsUrl.trim() !== ""
? providerSpecificData.modelsUrl.trim()
: `${baseUrl}/models`;
const urlWithKey =
authType === "query" ? `${requestUrl}?key=${encodeURIComponent(apiKey)}` : requestUrl;
const headers = authType === "header" ? { "x-goog-api-key": apiKey } : {};
// Use the correct auth header based on provider config:
// - gemini (API key): x-goog-api-key
// - gemini-cli (OAuth): Bearer token
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (authType === "oauth") {
headers["Authorization"] = `Bearer ${apiKey}`;
} else {
headers["x-goog-api-key"] = apiKey;
}
applyCustomUserAgent(headers, providerSpecificData);
const response = await validationRead(
urlWithKey,
{
headers,
},
isLocal
);
const response = await validationRead(baseUrl, { method: "GET", headers });
if (response.ok) {
return { valid: true, error: null };
}
// 429 = rate limited, but auth is valid
if (response.status === 429) {
return { valid: true, error: null };
}
// Google returns 400 (not 401/403) for invalid API keys on the models endpoint.
// Parse the response body to detect auth failures.
if (response.status === 400 || response.status === 401 || response.status === 403) {
const isAuthError = (body: any) => {
const message = (body?.error?.message || "").toLowerCase();
const reason = body?.error?.details?.[0]?.reason || "";
const status = body?.error?.status || "";
const authPatterns = [
"api key not valid",
"api key expired",
"api key invalid",
"API_KEY_INVALID",
"API_KEY_EXPIRED",
"PERMISSION_DENIED",
"UNAUTHENTICATED",
];
return authPatterns.some(
(p) => message.includes(p.toLowerCase()) || reason === p || status === p
);
};
try {
const body = await response.json();
if (isAuthError(body)) {
return { valid: false, error: "Invalid API key" };
}
// 401/403 are always auth failures even without matching patterns
if (response.status === 401 || response.status === 403) {
return { valid: false, error: "Invalid API key" };
}
} catch {
// Unparseable body — 401/403 are always auth failures
if (response.status === 401 || response.status === 403) {
return { valid: false, error: "Invalid API key" };
}
// 400 without parseable body — likely auth issue for Gemini
return { valid: false, error: "Invalid API key" };
if (!baseUrl) {
return { valid: false, error: "Missing base URL" };
}
}
return { valid: false, error: `Validation failed: ${response.status}` };
if (response.ok) {
return { valid: true, error: null };
}
if (response.status === 429) {
return { valid: true, error: null };
}
if (response.status === 400 || response.status === 401 || response.status === 403) {
const isAuthError = (body: any) => {
const message = (body?.error?.message || "").toLowerCase();
const reason = body?.error?.details?.[0]?.reason || "";
const status = body?.error?.status || "";
const authPatterns = [
"api key not valid",
"api key expired",
"api key invalid",
"API_KEY_INVALID",
"API_KEY_EXPIRED",
"PERMISSION_DENIED",
"UNAUTHENTICATED",
];
return authPatterns.some(
(p) => message.includes(p.toLowerCase()) || reason === p || status === p
);
};
try {
const body = await response.json();
if (isAuthError(body)) {
return { valid: false, error: "Invalid API key" };
}
if (response.status === 401 || response.status === 403) {
return { valid: false, error: "Invalid API key" };
}
} catch {
if (response.status === 401 || response.status === 403) {
return { valid: false, error: "Invalid API key" };
}
return { valid: false, error: "Invalid API key" };
}
}
return { valid: false, error: `Validation failed: ${response.status}` };
} catch (error: any) {
return toValidationErrorResult(error);
}
}
// ── Specialty providers (non-standard APIs) ──
@@ -2134,7 +2181,11 @@ async function validateOpenAICompatibleProvider({ apiKey, providerSpecificData =
}
}
async function validateAnthropicCompatibleProvider({ apiKey, providerSpecificData = {} }: any) {
async function validateAnthropicCompatibleProvider({
apiKey,
providerSpecificData = {},
isLocal = false,
}: any) {
let baseUrl = normalizeAnthropicBaseUrl(providerSpecificData.baseUrl);
if (!baseUrl) {
return { valid: false, error: "No base URL configured for Anthropic compatible provider" };
@@ -2157,7 +2208,8 @@ async function validateAnthropicCompatibleProvider({ apiKey, providerSpecificDat
{
method: "GET",
headers,
}
},
isLocal
);
if (modelsRes.ok) {
@@ -2184,7 +2236,8 @@ async function validateAnthropicCompatibleProvider({ apiKey, providerSpecificDat
max_tokens: 1,
messages: [{ role: "user", content: "test" }],
}),
}
},
isLocal
);
if (messagesRes.status === 401 || messagesRes.status === 403) {
@@ -2280,15 +2333,31 @@ export async function validateClaudeCodeCompatibleProvider({
// ── Search provider validators (factored) ──
async function validateGenericProvider(
baseUrl: string,
apiKey: string,
providerSpecificData: any = {},
provider: string,
isLocal: boolean = false
) {
const config = SEARCH_VALIDATOR_CONFIGS[provider];
if (!config) {
return { valid: false, error: "Validator not found", unsupported: true };
}
const { url, init } = config(apiKey, providerSpecificData);
return validateSearchProvider(url, init, providerSpecificData, isLocal);
}
async function validateSearchProvider(
url: string,
init: RequestInit,
providerSpecificData: any = {}
providerSpecificData: any = {},
isLocal: boolean = false
): Promise<{ valid: boolean; error: string | null; unsupported: false }> {
try {
const response = await safeOutboundFetch(url, {
...SAFE_OUTBOUND_FETCH_PRESETS.validationWrite,
guard: getProviderOutboundGuard(),
guard: isLocal ? "none" : getProviderOutboundGuard(),
...withCustomUserAgent(init, providerSpecificData),
});
if (response.ok) return { valid: true, error: null, unsupported: false };
@@ -2813,7 +2882,7 @@ async function validatePerplexityWebProvider({ apiKey, providerSpecificData = {}
Origin: "https://www.perplexity.ai",
Referer: "https://www.perplexity.ai/",
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/136.0.0.0",
"X-App-ApiClient": "default",
"X-App-ApiVersion": "client-1.11.0",
...(bearerToken
@@ -2882,7 +2951,7 @@ async function validateBlackboxWebProvider({ apiKey, providerSpecificData = {} }
Origin: "https://app.blackbox.ai",
Referer: "https://app.blackbox.ai/",
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/147.0.0.0",
},
providerSpecificData
);
@@ -2912,7 +2981,7 @@ async function validateBlackboxWebProvider({ apiKey, providerSpecificData = {} }
Origin: "https://app.blackbox.ai",
Referer: "https://app.blackbox.ai/",
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/147.0.0.0",
},
providerSpecificData
);
@@ -3048,6 +3117,7 @@ async function validateMuseSparkWebProvider({ apiKey, providerSpecificData = {}
export async function validateProviderApiKey({ provider, apiKey, providerSpecificData = {} }: any) {
const requiresApiKey = !providerAllowsOptionalApiKey(provider);
const isLocal = isLocalProvider(provider);
if (!provider || (requiresApiKey && !apiKey)) {
return { valid: false, error: "Provider and API key required", unsupported: false };
@@ -3066,7 +3136,11 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
if (isClaudeCodeCompatibleProvider(provider)) {
return await validateClaudeCodeCompatibleProvider({ apiKey, providerSpecificData });
}
return await validateAnthropicCompatibleProvider({ apiKey, providerSpecificData });
return await validateAnthropicCompatibleProvider({
apiKey,
providerSpecificData,
isLocal,
});
} catch (error: any) {
return toValidationErrorResult(error);
}
@@ -3112,6 +3186,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
baseUrl,
modelId: getBedrockValidationModelId(baseUrl),
modelsUrl: buildBedrockModelsUrl(baseUrl),
isLocal,
});
},
modal: ({ apiKey, providerSpecificData }: any) =>
@@ -3121,6 +3196,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
providerSpecificData,
baseUrl: normalizeBaseUrl(providerSpecificData?.baseUrl || ""),
modelId: "Qwen/Qwen3-4B-Thinking-2507-FP8",
isLocal,
}),
"nous-research": validateNousResearchProvider,
petals: validatePetalsProvider,
@@ -3164,11 +3240,15 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
? providerSpecificData.baseUrl.trim()
: "";
const root = (configuredBaseUrl || "https://gitlab.com").replace(/\/$/, "");
const res = await validationWrite(`${root}/api/v4/code_suggestions/direct_access`, {
method: "POST",
headers: buildBearerHeaders(apiKey, providerSpecificData),
body: "{}",
});
const res = await validationWrite(
`${root}/api/v4/code_suggestions/direct_access`,
{
method: "POST",
headers: buildBearerHeaders(apiKey, providerSpecificData),
body: "{}",
},
isLocal
);
if (res.status === 401) {
return { valid: false, error: "Invalid API key" };
}
@@ -3182,7 +3262,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
const { parseSAFromApiKey, getAccessToken } =
await import("@omniroute/open-sse/executors/vertex.ts");
const sa = parseSAFromApiKey(apiKey);
// Validates credentials by successfully exchanging them for a JWT from Google Identity
// Validates credentials by successfully successfully exchanging them for a JWT from Google Identity
await getAccessToken(sa);
return { valid: true, error: null };
} catch (error: any) {
@@ -3203,15 +3283,19 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
// LongCat AI — does not expose /v1/models; validate via chat completions directly (#592)
longcat: async ({ apiKey, providerSpecificData }: any) => {
try {
const res = await validationWrite("https://api.longcat.chat/openai/v1/chat/completions", {
method: "POST",
headers: buildBearerHeaders(apiKey, providerSpecificData),
body: JSON.stringify({
model: "longcat",
messages: [{ role: "user", content: "test" }],
max_tokens: 1,
}),
});
const res = await validationWrite(
"https://api.longcat.chat/openai/v1/chat/completions",
{
method: "POST",
headers: buildBearerHeaders(apiKey, providerSpecificData),
body: JSON.stringify({
model: "longcat",
messages: [{ role: "user", content: "test" }],
max_tokens: 1,
}),
},
isLocal
);
if (res.status === 401 || res.status === 403) {
return { valid: false, error: "Invalid API key" };
}
@@ -3230,15 +3314,19 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
providerSpecificData?.baseUrl || "https://api.xiaomimimo.com/v1"
);
const chatUrl = `${baseUrl.replace(/\/chat\/completions$/, "")}/chat/completions`;
const res = await validationWrite(chatUrl, {
method: "POST",
headers: buildBearerHeaders(apiKey, providerSpecificData),
body: JSON.stringify({
model: "mimo-v2.5-pro",
messages: [{ role: "user", content: "test" }],
max_tokens: 1,
}),
});
const res = await validationWrite(
chatUrl,
{
method: "POST",
headers: buildBearerHeaders(apiKey, providerSpecificData),
body: JSON.stringify({
model: "mimo-v2.5-pro",
messages: [{ role: "user", content: "test" }],
max_tokens: 1,
}),
},
isLocal
);
if (res.status === 401 || res.status === 403) {
return { valid: false, error: "Invalid API key" };
}
@@ -3254,7 +3342,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
id,
({ apiKey, providerSpecificData }: any) => {
const { url, init } = configFn(apiKey, providerSpecificData);
return validateSearchProvider(url, init, providerSpecificData);
return validateSearchProvider(url, init, providerSpecificData, isLocal);
},
])
),
@@ -3278,6 +3366,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
providerSpecificData,
modelId: "local-model",
modelsUrl: addModelsSuffix(providerSpecificData?.baseUrl || ""),
isLocal,
});
}
return { valid: false, error: "Provider validation not supported", unsupported: true };
@@ -3294,12 +3383,13 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
try {
if (OPENAI_LIKE_FORMATS.has(entry.format)) {
return await validateOpenAILikeProvider({
provider,
apiKey,
baseUrl,
headers: entry.headers || {},
providerSpecificData,
modelId,
modelsUrl: entry.modelsUrl,
isLocal,
});
}
@@ -3321,6 +3411,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
modelId,
headers: requestHeaders,
providerSpecificData,
isLocal,
});
}
@@ -3330,6 +3421,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
baseUrl,
providerSpecificData,
authType: entry.authType,
isLocal,
});
}

View File

@@ -938,6 +938,56 @@ export const APIKEY_PROVIDERS = {
freeNote: "Free GPT-5, o-series, DeepSeek-R1, Llama 4, Grok 3 — GitHub account only.",
authHint: "Create a GitHub PAT with 'models: read' scope at github.com/settings/tokens",
},
haiper: {
id: "haiper",
alias: "hp",
name: "Haiper",
icon: "videocam",
color: "#6366F1",
textIcon: "HP",
website: "https://haiper.ai",
authHint: "Get API key at haiper.ai/haiper-api",
},
leonardo: {
id: "leonardo",
alias: "leo",
name: "Leonardo AI",
icon: "palette",
color: "#8B5CF6",
textIcon: "LE",
website: "https://leonardo.ai",
authHint: "Get API key at leonardo.ai/developer",
},
ideogram: {
id: "ideogram",
alias: "ideo",
name: "Ideogram",
icon: "image",
color: "#EC4899",
textIcon: "ID",
website: "https://ideogram.ai",
authHint: "Get API key at ideogram.ai/docs/api",
},
suno: {
id: "suno",
alias: "suno",
name: "Suno",
icon: "music_note",
color: "#F59E0B",
textIcon: "SU",
website: "https://suno.ai",
authHint: "Paste session cookie from suno.ai (Clerk auth)",
},
udio: {
id: "udio",
alias: "udio",
name: "Udio",
icon: "music_note",
color: "#10B981",
textIcon: "UD",
website: "https://udio.com",
authHint: "Paste session cookie from udio.com (Supabase auth)",
},
"cloudflare-ai": {
id: "cloudflare-ai",
alias: "cf",
@@ -1667,6 +1717,8 @@ export const VIDEO_PROVIDER_IDS = new Set([
"minimax",
"together",
"replicate",
"haiper",
"leonardo",
]);
export const EMBEDDING_RERANK_PROVIDER_IDS = new Set(["voyage-ai", "jina-ai"]);

View File

@@ -1,28 +1,46 @@
import Redis from "ioredis";
// Reuse existing REDIS_URL if set, or local redis via default docker-compose
// Use REDIS_URL from env (Docker/Production) or fallback to local redis
const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379";
if (process.env.NODE_ENV === "production" && !process.env.REDIS_URL) {
console.warn("[REDIS] REDIS_URL is not set in production. Falling back to default.");
}
// Issue #2357: When OmniRoute runs in Docker without a sibling Redis
// container (the default `docker run` / portainer one-click install), every
// rate-limit lookup hits `redis://localhost:6379` inside the container and
// spams `[REDIS] Error: connect ECONNREFUSED 127.0.0.1:6379`. Rate limiting
// is non-essential for a single-instance deployment, so we now:
//
// 1) Treat `REDIS_URL` as opt-in. If it's not set we silently fall back to
// the in-memory store (same code path used by unit tests).
// 2) Even when set, errors degrade gracefully: a single startup warning,
// then suppress per-request error spam after the first occurrence.
const REDIS_URL = process.env.REDIS_URL;
const REDIS_ENABLED = Boolean(REDIS_URL);
let redisClient: Redis | null = null;
let redisErrorLogged = false;
export function getRedisClient() {
export function getRedisClient(): Redis | null {
if (!REDIS_ENABLED) return null;
if (!redisClient) {
redisClient = new Redis(REDIS_URL, {
redisClient = new Redis(REDIS_URL as string, {
maxRetriesPerRequest: 3,
enableReadyCheck: false,
lazyConnect: false,
retryStrategy(times) {
return Math.min(times * 50, 2000); // Exponential backoff
},
});
redisClient.on("error", (err) => console.error("[REDIS] Error:", err.message));
redisClient.on("error", (err) => {
if (!redisErrorLogged) {
console.warn("[REDIS] Connection error — rate limiter degraded to in-memory:", err.message);
redisErrorLogged = true;
}
});
}
return redisClient;
}
export function isRedisEnabled(): boolean {
return REDIS_ENABLED;
}
export interface RateLimitRule {
limit: number;
window: number; // in seconds
@@ -86,37 +104,48 @@ export function setRateLimiterTestMode(enabled: boolean) {
/**
* Checks multi-window rate limits for an API key atomically via Redis.
*/
function checkRateLimitInMemory(keyId: string, rules: RateLimitRule[]): RateLimitResult {
const now = Math.floor(Date.now() / 1000);
for (const rule of rules) {
const currentWindow = Math.floor(now / rule.window);
const windowKey = `rl:api_key:${keyId}:${rule.window}:${currentWindow}`;
const count = TEST_MEMORY_STORE.get(windowKey) || 0;
if (count >= rule.limit) {
return { allowed: false, failedWindow: rule.window };
}
}
for (const rule of rules) {
const currentWindow = Math.floor(now / rule.window);
const windowKey = `rl:api_key:${keyId}:${rule.window}:${currentWindow}`;
TEST_MEMORY_STORE.set(windowKey, (TEST_MEMORY_STORE.get(windowKey) || 0) + 1);
}
return { allowed: true };
}
export async function checkRateLimit(
keyId: string,
rules: RateLimitRule[]
): Promise<RateLimitResult> {
if (!rules || rules.length === 0) return { allowed: true };
// ── In-memory mock for unit tests ──
// ── In-memory path for unit tests AND single-instance deployments ──
// Issue #2357: when REDIS_URL is unset we used to hammer
// localhost:6379 and surface a stream of ECONNREFUSED errors. Now the
// in-memory fallback handles single-instance setups silently. The
// explicit test-mode flag still wins so suites can opt-in even with
// REDIS_URL set.
const isTestMode =
explicitTestMode ||
process.env.NODE_ENV === "test" ||
process.env.DISABLE_SQLITE_AUTO_BACKUP === "true";
if (isTestMode) {
const now = Math.floor(Date.now() / 1000);
for (const rule of rules) {
const currentWindow = Math.floor(now / rule.window);
const windowKey = `rl:api_key:${keyId}:${rule.window}:${currentWindow}`;
const count = TEST_MEMORY_STORE.get(windowKey) || 0;
if (count >= rule.limit) {
return { allowed: false, failedWindow: rule.window };
}
}
for (const rule of rules) {
const currentWindow = Math.floor(now / rule.window);
const windowKey = `rl:api_key:${keyId}:${rule.window}:${currentWindow}`;
TEST_MEMORY_STORE.set(windowKey, (TEST_MEMORY_STORE.get(windowKey) || 0) + 1);
}
return { allowed: true };
if (isTestMode || !isRedisEnabled()) {
return checkRateLimitInMemory(keyId, rules);
}
const redis = getRedisClient();
if (!redis) return checkRateLimitInMemory(keyId, rules);
const args: (string | number)[] = [Math.floor(Date.now() / 1000)];
for (const rule of rules) {
@@ -135,8 +164,16 @@ export async function checkRateLimit(
return { allowed: true };
} catch (error) {
// Fail-open strategy if Redis goes down to prevent complete API outage
console.error("[RATE_LIMITER] Redis eval failed, bypassing rate limit:", error);
// Fail-open strategy if Redis goes down to prevent complete API outage.
// First failure already logged in the connection error handler — keep
// per-request output to a debug line to avoid log spam.
if (!redisErrorLogged) {
console.warn(
"[RATE_LIMITER] Redis eval failed, bypassing rate limit:",
(error as Error)?.message ?? String(error)
);
redisErrorLogged = true;
}
return { allowed: true };
}
}