mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 22:32:12 +03:00
chore: merge release/v3.8.0 into PR branch
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
@@ -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[];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user