mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-07 15:52:52 +03:00
committed by
GitHub
parent
08e18867fd
commit
3d75fb3fae
@@ -37,126 +37,69 @@ function GenericProviderIcon({ size }: { size: number }) {
|
||||
}
|
||||
|
||||
const KNOWN_PNGS = new Set([
|
||||
"agentrouter",
|
||||
"aimlapi",
|
||||
"alibaba",
|
||||
"alicode-intl",
|
||||
"alicode",
|
||||
"anthropic-m",
|
||||
"anthropic",
|
||||
"antigravity",
|
||||
"bailian-coding-plan",
|
||||
"blackbox",
|
||||
"brave-search",
|
||||
"brave",
|
||||
"cerebras",
|
||||
"claude",
|
||||
"cline",
|
||||
"codex",
|
||||
"cohere",
|
||||
"continue",
|
||||
"copilot",
|
||||
"cursor",
|
||||
"deepgram",
|
||||
"deepseek",
|
||||
"droid",
|
||||
"exa-search",
|
||||
"fireworks",
|
||||
"gemini-cli",
|
||||
"gemini",
|
||||
"github",
|
||||
"glm",
|
||||
"glmt",
|
||||
"groq",
|
||||
"ironclaw",
|
||||
"kilo-gateway",
|
||||
"kilocode",
|
||||
"kimi-coding-apikey",
|
||||
"kimi-coding",
|
||||
"kimi",
|
||||
"kiro",
|
||||
"longcat",
|
||||
"minimax-cn",
|
||||
"minimax",
|
||||
"mistral",
|
||||
"kie",
|
||||
"nanobot",
|
||||
"nebius",
|
||||
"nvidia",
|
||||
"oai-cc",
|
||||
"oai-r",
|
||||
"ollama-cloud",
|
||||
"openai",
|
||||
"openclaw",
|
||||
"openrouter",
|
||||
"perplexity-search",
|
||||
"perplexity",
|
||||
"pollinations",
|
||||
"qwen",
|
||||
"roo",
|
||||
"serper-search",
|
||||
"serper",
|
||||
"siliconflow",
|
||||
"tavily-search",
|
||||
"tavily",
|
||||
"together",
|
||||
"xai",
|
||||
"zeroclaw",
|
||||
"aws-polly",
|
||||
"blackbox-web",
|
||||
"cliproxyapi",
|
||||
"databricks",
|
||||
"empower",
|
||||
"gigachat",
|
||||
"gitlab-duo",
|
||||
"gitlab",
|
||||
"heroku",
|
||||
"lemonade",
|
||||
"linkup-search",
|
||||
"llamafile",
|
||||
"llamagate",
|
||||
"maritalk",
|
||||
"modal",
|
||||
"nanogpt",
|
||||
"nscale",
|
||||
"oci",
|
||||
"ovhcloud",
|
||||
"piapi",
|
||||
"poe",
|
||||
"predibase",
|
||||
"qoder",
|
||||
"recraft",
|
||||
"reka",
|
||||
"runwayml",
|
||||
"triton",
|
||||
"venice",
|
||||
"voyage-ai",
|
||||
"wandb",
|
||||
"youcom-search",
|
||||
]);
|
||||
const KNOWN_SVGS = new Set([
|
||||
"apikey",
|
||||
"assemblyai",
|
||||
"brave",
|
||||
"brave-search",
|
||||
"cartesia",
|
||||
"cloudflare-ai",
|
||||
"comfyui",
|
||||
"elevenlabs",
|
||||
"exa-search",
|
||||
"exa",
|
||||
"huggingface",
|
||||
"hyperbolic",
|
||||
"clarifai",
|
||||
"docker-model-runner",
|
||||
"droid",
|
||||
"gemini-cli",
|
||||
"gitlab",
|
||||
"gitlab-duo",
|
||||
"inworld",
|
||||
"nanobanana",
|
||||
"kiro",
|
||||
"kilo-gateway",
|
||||
"kilocode",
|
||||
"modal",
|
||||
"nlpcloud",
|
||||
"oauth",
|
||||
"opencode-go",
|
||||
"opencode-zen",
|
||||
"oci",
|
||||
"opencode",
|
||||
"playht",
|
||||
"puter",
|
||||
"qianfan",
|
||||
"sap",
|
||||
"scaleway",
|
||||
"sdwebui",
|
||||
"serper-search",
|
||||
"searxng-search",
|
||||
"synthetic",
|
||||
"vertex",
|
||||
"windsurf",
|
||||
"zai",
|
||||
"wandb",
|
||||
"youcom-search",
|
||||
]);
|
||||
|
||||
const ProviderIcon = memo(function ProviderIcon({
|
||||
|
||||
@@ -11,6 +11,7 @@ interface ToggleProps {
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
title?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export default function Toggle({
|
||||
@@ -21,6 +22,8 @@ export default function Toggle({
|
||||
disabled = false,
|
||||
size = "md",
|
||||
className,
|
||||
title,
|
||||
ariaLabel,
|
||||
}: ToggleProps) {
|
||||
const sizes = {
|
||||
sm: {
|
||||
@@ -58,7 +61,8 @@ export default function Toggle({
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={!label ? description || "Toggle" : undefined}
|
||||
aria-label={ariaLabel || label || description || title || "Toggle"}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ProviderCostDonut,
|
||||
ModelOverTimeChart,
|
||||
ProviderTable,
|
||||
ServiceTierBreakdown,
|
||||
ApiKeyFilterDropdown,
|
||||
CustomRangePicker,
|
||||
} from "./analytics";
|
||||
@@ -61,16 +62,15 @@ export default function UsageAnalytics() {
|
||||
setError(null);
|
||||
|
||||
// Update available keys from unfiltered data (only when no filter is active).
|
||||
// Use apiKeyName as the stable identifier — it is always populated
|
||||
// for every OmniRoute API key regardless of the downstream provider.
|
||||
if (selectedApiKeys.length === 0 && data.byApiKey?.length > 0) {
|
||||
const seen = new Set<string>();
|
||||
const keys: { id: string; name: string }[] = [];
|
||||
for (const k of data.byApiKey) {
|
||||
const id = k.apiKeyId || k.apiKeyName || "unknown";
|
||||
const name = k.apiKeyName || k.apiKeyId || "unknown";
|
||||
if (seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
keys.push({ id: name, name });
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
keys.push({ id, name });
|
||||
}
|
||||
setAvailableApiKeys(keys);
|
||||
}
|
||||
@@ -311,10 +311,10 @@ export default function UsageAnalytics() {
|
||||
color: "text-violet-500",
|
||||
},
|
||||
{
|
||||
icon: "swap_horiz",
|
||||
label: "Fallback Rate",
|
||||
value: `${Number(s.fallbackRatePct || 0).toFixed(1)}%`,
|
||||
color: "text-amber-500",
|
||||
icon: "bolt",
|
||||
label: "Fast Requests",
|
||||
value: fmt(s.fastRequests || 0),
|
||||
color: "text-sky-500",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -331,6 +331,12 @@ export default function UsageAnalytics() {
|
||||
value: `${providerDiversity.toFixed(1)}%`,
|
||||
color: "text-sky-500",
|
||||
},
|
||||
{
|
||||
icon: "swap_horiz",
|
||||
label: "Fallback Rate",
|
||||
value: `${Number(s.fallbackRatePct || 0).toFixed(1)}%`,
|
||||
color: "text-amber-500",
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
@@ -351,6 +357,9 @@ export default function UsageAnalytics() {
|
||||
<ProviderCostDonut byProvider={analytics?.byProvider} />
|
||||
</div>
|
||||
|
||||
{/* Fast / Standard service tier split */}
|
||||
<ServiceTierBreakdown byServiceTier={analytics?.byServiceTier} summary={s} />
|
||||
|
||||
{/* Model Usage Over Time (stacked area) */}
|
||||
<ModelOverTimeChart
|
||||
dailyByModel={analytics?.dailyByModel}
|
||||
|
||||
@@ -1081,6 +1081,71 @@ export function ModelTable({ byModel, summary }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ServiceTierBreakdown({ byServiceTier, summary }) {
|
||||
const data = useMemo(() => byServiceTier || [], [byServiceTier]);
|
||||
const totalRequests = Number(summary?.totalRequests || 0);
|
||||
const totalCost = Number(summary?.totalCost || 0);
|
||||
|
||||
if (!data.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="p-4 border-b border-border flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider">
|
||||
Service Tier
|
||||
</h3>
|
||||
<span className="text-[11px] text-text-muted">Fast / Standard cost split</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{data.map((tier) => {
|
||||
const isFast = tier.serviceTier === "priority";
|
||||
const requestPct =
|
||||
totalRequests > 0
|
||||
? ((Number(tier.requests || 0) / totalRequests) * 100).toFixed(1)
|
||||
: "0";
|
||||
const costPct =
|
||||
totalCost > 0 ? ((Number(tier.cost || 0) / totalCost) * 100).toFixed(1) : "0";
|
||||
return (
|
||||
<div key={tier.serviceTier} className="p-4 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`material-symbols-outlined text-[18px] ${
|
||||
isFast ? "text-sky-500" : "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{isFast ? "bolt" : "speed"}
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-text-main">{tier.label}</div>
|
||||
<div className="text-xs text-text-muted">
|
||||
{fmtFull(tier.requests)} requests · {fmt(tier.totalTokens)} tokens
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-mono text-sm font-semibold text-amber-500">
|
||||
{fmtCost(tier.cost)}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">{costPct}% of cost</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-black/5 dark:bg-white/10 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${isFast ? "bg-sky-500" : "bg-text-muted/50"}`}
|
||||
style={{ width: `${requestPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── UsageDetail ────────────────────────────────────────────────────────────
|
||||
|
||||
export function UsageDetail({ summary }) {
|
||||
|
||||
@@ -25,6 +25,7 @@ export {
|
||||
ProviderCostDonut,
|
||||
ModelOverTimeChart,
|
||||
ProviderTable,
|
||||
ServiceTierBreakdown,
|
||||
} from "./charts";
|
||||
|
||||
export { default as ApiKeyFilterDropdown } from "./ApiKeyFilterDropdown";
|
||||
|
||||
@@ -75,6 +75,8 @@ import KimiColorIcon from "@lobehub/icons/es/Kimi/components/Color";
|
||||
import KimiMonoIcon from "@lobehub/icons/es/Kimi/components/Mono";
|
||||
import LambdaMonoIcon from "@lobehub/icons/es/Lambda/components/Mono";
|
||||
import LmStudioMonoIcon from "@lobehub/icons/es/LmStudio/components/Mono";
|
||||
import LongCatColorIcon from "@lobehub/icons/es/LongCat/components/Color";
|
||||
import LongCatMonoIcon from "@lobehub/icons/es/LongCat/components/Mono";
|
||||
import MetaColorIcon from "@lobehub/icons/es/Meta/components/Color";
|
||||
import MetaMonoIcon from "@lobehub/icons/es/Meta/components/Mono";
|
||||
import MetaAIColorIcon from "@lobehub/icons/es/MetaAI/components/Color";
|
||||
@@ -104,12 +106,14 @@ import PerplexityColorIcon from "@lobehub/icons/es/Perplexity/components/Color";
|
||||
import PerplexityMonoIcon from "@lobehub/icons/es/Perplexity/components/Mono";
|
||||
import PoeColorIcon from "@lobehub/icons/es/Poe/components/Color";
|
||||
import PoeMonoIcon from "@lobehub/icons/es/Poe/components/Mono";
|
||||
import PollinationsMonoIcon from "@lobehub/icons/es/Pollinations/components/Mono";
|
||||
import QoderColorIcon from "@lobehub/icons/es/Qoder/components/Color";
|
||||
import QoderMonoIcon from "@lobehub/icons/es/Qoder/components/Mono";
|
||||
import QwenColorIcon from "@lobehub/icons/es/Qwen/components/Color";
|
||||
import QwenMonoIcon from "@lobehub/icons/es/Qwen/components/Mono";
|
||||
import RecraftMonoIcon from "@lobehub/icons/es/Recraft/components/Mono";
|
||||
import ReplicateMonoIcon from "@lobehub/icons/es/Replicate/components/Mono";
|
||||
import RooCodeMonoIcon from "@lobehub/icons/es/RooCode/components/Mono";
|
||||
import RunwayMonoIcon from "@lobehub/icons/es/Runway/components/Mono";
|
||||
import SambaNovaColorIcon from "@lobehub/icons/es/SambaNova/components/Color";
|
||||
import SambaNovaMonoIcon from "@lobehub/icons/es/SambaNova/components/Mono";
|
||||
@@ -139,6 +143,7 @@ import VolcengineColorIcon from "@lobehub/icons/es/Volcengine/components/Color";
|
||||
import VolcengineMonoIcon from "@lobehub/icons/es/Volcengine/components/Mono";
|
||||
import VoyageColorIcon from "@lobehub/icons/es/Voyage/components/Color";
|
||||
import VoyageMonoIcon from "@lobehub/icons/es/Voyage/components/Mono";
|
||||
import WindsurfMonoIcon from "@lobehub/icons/es/Windsurf/components/Mono";
|
||||
import WorkersAIColorIcon from "@lobehub/icons/es/WorkersAI/components/Color";
|
||||
import WorkersAIMonoIcon from "@lobehub/icons/es/WorkersAI/components/Mono";
|
||||
import XAIMonoIcon from "@lobehub/icons/es/XAI/components/Mono";
|
||||
@@ -208,6 +213,7 @@ const LOBE_ICON_COMPONENTS = {
|
||||
Kimi: { mono: KimiMonoIcon, color: KimiColorIcon },
|
||||
Lambda: { mono: LambdaMonoIcon },
|
||||
LmStudio: { mono: LmStudioMonoIcon },
|
||||
LongCat: { mono: LongCatMonoIcon, color: LongCatColorIcon },
|
||||
Meta: { mono: MetaMonoIcon, color: MetaColorIcon },
|
||||
MetaAI: { mono: MetaAIMonoIcon, color: MetaAIColorIcon },
|
||||
Minimax: { mono: MinimaxMonoIcon, color: MinimaxColorIcon },
|
||||
@@ -226,10 +232,12 @@ const LOBE_ICON_COMPONENTS = {
|
||||
OpenRouter: { mono: OpenRouterMonoIcon },
|
||||
Perplexity: { mono: PerplexityMonoIcon, color: PerplexityColorIcon },
|
||||
Poe: { mono: PoeMonoIcon, color: PoeColorIcon },
|
||||
Pollinations: { mono: PollinationsMonoIcon },
|
||||
Qoder: { mono: QoderMonoIcon, color: QoderColorIcon },
|
||||
Qwen: { mono: QwenMonoIcon, color: QwenColorIcon },
|
||||
Recraft: { mono: RecraftMonoIcon },
|
||||
Replicate: { mono: ReplicateMonoIcon },
|
||||
RooCode: { mono: RooCodeMonoIcon },
|
||||
Runway: { mono: RunwayMonoIcon },
|
||||
SambaNova: { mono: SambaNovaMonoIcon, color: SambaNovaColorIcon },
|
||||
SearchApi: { mono: SearchApiMonoIcon },
|
||||
@@ -247,6 +255,7 @@ const LOBE_ICON_COMPONENTS = {
|
||||
Vllm: { mono: VllmMonoIcon, color: VllmColorIcon },
|
||||
Volcengine: { mono: VolcengineMonoIcon, color: VolcengineColorIcon },
|
||||
Voyage: { mono: VoyageMonoIcon, color: VoyageColorIcon },
|
||||
Windsurf: { mono: WindsurfMonoIcon },
|
||||
WorkersAI: { mono: WorkersAIMonoIcon, color: WorkersAIColorIcon },
|
||||
XAI: { mono: XAIMonoIcon },
|
||||
XiaomiMiMo: { mono: XiaomiMiMoMonoIcon },
|
||||
@@ -275,6 +284,7 @@ const LOBE_PROVIDER_ALIASES = {
|
||||
bfl: "Bfl",
|
||||
"black-forest-labs": "Bfl",
|
||||
cerebras: "Cerebras",
|
||||
"chatgpt-web": "OpenAI",
|
||||
claude: "ClaudeCode",
|
||||
cline: "Cline",
|
||||
cloudflare: "Cloudflare",
|
||||
@@ -325,6 +335,7 @@ const LOBE_PROVIDER_ALIASES = {
|
||||
"lambda-ai": "Lambda",
|
||||
"lm-studio": "LmStudio",
|
||||
lmstudio: "LmStudio",
|
||||
longcat: "LongCat",
|
||||
"meta-llama": "Meta",
|
||||
minimax: "Minimax",
|
||||
"minimax-cn": "Minimax",
|
||||
@@ -352,10 +363,12 @@ const LOBE_PROVIDER_ALIASES = {
|
||||
"perplexity-search": "Perplexity",
|
||||
"perplexity-web": "Perplexity",
|
||||
poe: "Poe",
|
||||
pollinations: "Pollinations",
|
||||
qoder: "Qoder",
|
||||
qwen: "Qwen",
|
||||
recraft: "Recraft",
|
||||
replicate: "Replicate",
|
||||
roo: "RooCode",
|
||||
runwayml: "Runway",
|
||||
sambanova: "SambaNova",
|
||||
sdwebui: "Automatic",
|
||||
@@ -369,6 +382,7 @@ const LOBE_PROVIDER_ALIASES = {
|
||||
"tavily-search": "Tavily",
|
||||
together: "Together",
|
||||
topaz: "TopazLabs",
|
||||
triton: "Nvidia",
|
||||
upstage: "Upstage",
|
||||
v0: "V0",
|
||||
"v0-vercel": "V0",
|
||||
@@ -382,6 +396,7 @@ const LOBE_PROVIDER_ALIASES = {
|
||||
voyage: "Voyage",
|
||||
"voyage-ai": "Voyage",
|
||||
watsonx: "IBM",
|
||||
windsurf: "Windsurf",
|
||||
"workers-ai": "WorkersAI",
|
||||
workersai: "WorkersAI",
|
||||
xai: "XAI",
|
||||
|
||||
1
src/shared/constants/batch.ts
Normal file
1
src/shared/constants/batch.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const DEFAULT_BATCH_EXPIRATION_SECONDS = 30 * 24 * 60 * 60;
|
||||
@@ -65,7 +65,6 @@ export const CLI_TOOLS = {
|
||||
codex: {
|
||||
id: "codex",
|
||||
name: "OpenAI Codex CLI",
|
||||
image: "/providers/codex.png",
|
||||
color: "#10A37F",
|
||||
description: "OpenAI Codex CLI",
|
||||
docsUrl: "https://github.com/openai/codex",
|
||||
@@ -75,7 +74,7 @@ export const CLI_TOOLS = {
|
||||
droid: {
|
||||
id: "droid",
|
||||
name: "Factory Droid",
|
||||
image: "/providers/droid.png",
|
||||
image: "/providers/droid.svg",
|
||||
color: "#00D4FF",
|
||||
description: "Factory Droid AI Assistant",
|
||||
docsUrl: "/docs?section=cli-tools&tool=droid",
|
||||
@@ -121,7 +120,6 @@ export const CLI_TOOLS = {
|
||||
windsurf: {
|
||||
id: "windsurf",
|
||||
name: "Windsurf",
|
||||
image: "/providers/windsurf.svg",
|
||||
color: "#4A90E2",
|
||||
description: "Windsurf AI-first IDE by Codeium",
|
||||
docsUrl: "https://windsurf.com/",
|
||||
@@ -151,7 +149,6 @@ export const CLI_TOOLS = {
|
||||
cline: {
|
||||
id: "cline",
|
||||
name: "Cline",
|
||||
image: "/providers/cline.png",
|
||||
color: "#00D1B2",
|
||||
description: "Cline AI Coding Assistant CLI",
|
||||
docsUrl: "https://docs.cline.bot/",
|
||||
@@ -161,7 +158,7 @@ export const CLI_TOOLS = {
|
||||
kilo: {
|
||||
id: "kilo",
|
||||
name: "Kilo Code",
|
||||
image: "/providers/kilocode.png",
|
||||
image: "/providers/kilocode.svg",
|
||||
color: "#FF6B6B",
|
||||
description: "Kilo Code AI Assistant CLI",
|
||||
docsUrl: "/docs?section=cli-tools&tool=kilocode",
|
||||
@@ -200,7 +197,6 @@ export const CLI_TOOLS = {
|
||||
antigravity: {
|
||||
id: "antigravity",
|
||||
name: "Antigravity",
|
||||
image: "/providers/antigravity.png",
|
||||
color: "#4285F4",
|
||||
description: "Google Antigravity IDE with MITM",
|
||||
docsUrl: "/docs?section=cli-tools&tool=antigravity",
|
||||
@@ -381,7 +377,7 @@ amp --model "{{model}}"
|
||||
kiro: {
|
||||
id: "kiro",
|
||||
name: "Kiro AI",
|
||||
image: "/providers/kiro.png",
|
||||
image: "/providers/kiro.svg",
|
||||
icon: "psychology_alt",
|
||||
color: "#FF6B35",
|
||||
description: "Amazon Kiro — AI-powered IDE with MITM",
|
||||
|
||||
@@ -182,7 +182,6 @@ export const APIKEY_PROVIDERS = {
|
||||
color: "#2468F2",
|
||||
textIcon: "BD",
|
||||
website: "https://cloud.baidu.com/product/wenxinworkshop",
|
||||
passthroughModels: true,
|
||||
apiHint:
|
||||
"Use a Qianfan API key from Baidu AI Cloud. The default endpoint is OpenAI-compatible v2.",
|
||||
},
|
||||
@@ -193,7 +192,7 @@ export const APIKEY_PROVIDERS = {
|
||||
icon: "code",
|
||||
color: "#2563EB",
|
||||
textIcon: "GL",
|
||||
website: "https://open.bigmodel.cn",
|
||||
website: "https://z.ai/subscribe",
|
||||
},
|
||||
"glm-cn": {
|
||||
id: "glm-cn",
|
||||
@@ -230,7 +229,7 @@ export const APIKEY_PROVIDERS = {
|
||||
icon: "psychology",
|
||||
color: "#1E3A8A",
|
||||
textIcon: "KM",
|
||||
website: "https://kimi.moonshot.cn",
|
||||
website: "https://platform.moonshot.ai",
|
||||
},
|
||||
"kimi-coding-apikey": {
|
||||
id: "kimi-coding-apikey",
|
||||
@@ -239,7 +238,7 @@ export const APIKEY_PROVIDERS = {
|
||||
icon: "psychology",
|
||||
color: "#1E40AF",
|
||||
textIcon: "KC",
|
||||
website: "https://kimi.com",
|
||||
website: "https://www.kimi.com/code",
|
||||
},
|
||||
minimax: {
|
||||
id: "minimax",
|
||||
@@ -248,7 +247,7 @@ export const APIKEY_PROVIDERS = {
|
||||
icon: "memory",
|
||||
color: "#7C3AED",
|
||||
textIcon: "MM",
|
||||
website: "https://www.minimaxi.com",
|
||||
website: "https://www.minimax.io",
|
||||
},
|
||||
"minimax-cn": {
|
||||
id: "minimax-cn",
|
||||
@@ -314,7 +313,7 @@ export const APIKEY_PROVIDERS = {
|
||||
icon: "cloud",
|
||||
color: "#2563EB",
|
||||
textIcon: "AF",
|
||||
website: "https://learn.microsoft.com/azure/ai-foundry/",
|
||||
website: "https://learn.microsoft.com/azure/ai-foundry",
|
||||
authHint:
|
||||
"Use your Azure AI Foundry key. Base URL can be https://<resource>.services.ai.azure.com/openai/v1/ or https://<resource>.openai.azure.com/openai/v1/.",
|
||||
apiHint:
|
||||
@@ -328,7 +327,7 @@ export const APIKEY_PROVIDERS = {
|
||||
icon: "cloud",
|
||||
color: "#FF9900",
|
||||
textIcon: "BR",
|
||||
website: "https://aws.amazon.com/bedrock/",
|
||||
website: "https://aws.amazon.com/bedrock",
|
||||
authHint:
|
||||
"Use your Amazon Bedrock API key in Authorization: Bearer <key>. OmniRoute defaults to the OpenAI-compatible bedrock-mantle endpoint in us-east-1; set a regional base URL if your account uses another region or the bedrock-runtime /openai/v1 path.",
|
||||
apiHint:
|
||||
@@ -356,7 +355,7 @@ export const APIKEY_PROVIDERS = {
|
||||
icon: "cloud",
|
||||
color: "#C74634",
|
||||
textIcon: "OCI",
|
||||
website: "https://www.oracle.com/artificial-intelligence/generative-ai/",
|
||||
website: "https://www.oracle.com/artificial-intelligence/generative-ai",
|
||||
authHint:
|
||||
"Use your OCI Generative AI API key or IAM bearer token. Base URL can be https://inference.generativeai.<region>.oci.oraclecloud.com/openai/v1/.",
|
||||
apiHint:
|
||||
@@ -444,7 +443,7 @@ export const APIKEY_PROVIDERS = {
|
||||
icon: "smart_toy",
|
||||
color: "#D97757",
|
||||
textIcon: "AN",
|
||||
website: "https://console.anthropic.com",
|
||||
website: "https://platform.claude.com",
|
||||
},
|
||||
gemini: {
|
||||
id: "gemini",
|
||||
@@ -453,7 +452,7 @@ export const APIKEY_PROVIDERS = {
|
||||
icon: "diamond",
|
||||
color: "#4285F4",
|
||||
textIcon: "GE",
|
||||
website: "https://ai.google.dev",
|
||||
website: "https://aistudio.google.com",
|
||||
hasFree: true,
|
||||
freeNote:
|
||||
"Free forever: 1,500 req/day for Gemini 2.5 Flash — no credit card, get key at aistudio.google.com",
|
||||
@@ -465,7 +464,7 @@ export const APIKEY_PROVIDERS = {
|
||||
icon: "bolt",
|
||||
color: "#4D6BFE",
|
||||
textIcon: "DS",
|
||||
website: "https://deepseek.com",
|
||||
website: "https://platform.deepseek.com",
|
||||
hasFree: true,
|
||||
freeNote: "5M free tokens on signup - no credit card required",
|
||||
},
|
||||
@@ -491,6 +490,57 @@ export const APIKEY_PROVIDERS = {
|
||||
hasFree: true,
|
||||
freeNote: "Free tier: unlimited basic chat plus Minimax-M2.5, no credit card required",
|
||||
},
|
||||
bazaarlink: {
|
||||
id: "bazaarlink",
|
||||
alias: "bzl",
|
||||
name: "BazaarLink",
|
||||
icon: "storefront",
|
||||
color: "#6366F1",
|
||||
textIcon: "BZ",
|
||||
website: "https://bazaarlink.ai",
|
||||
hasFree: true,
|
||||
freeNote: "Free tier with auto:free routing — zero-cost inference, no credit card required",
|
||||
apiHint:
|
||||
"Get free API key at https://bazaarlink.ai — use model 'auto:free' for zero-cost inference. OpenAI-compatible.",
|
||||
},
|
||||
completions: {
|
||||
id: "completions",
|
||||
alias: "cpl",
|
||||
name: "Completions.me",
|
||||
icon: "bolt",
|
||||
color: "#F59E0B",
|
||||
textIcon: "CP",
|
||||
website: "https://completions.me",
|
||||
hasFree: true,
|
||||
freeNote: "Free unlimited access to Claude, GPT, Gemini — no credit card, no rate limits",
|
||||
apiHint: "Sign up at https://completions.me for free API key. OpenAI-compatible endpoint.",
|
||||
},
|
||||
enally: {
|
||||
id: "enally",
|
||||
alias: "enly",
|
||||
name: "Enally AI",
|
||||
icon: "school",
|
||||
color: "#8B5CF6",
|
||||
textIcon: "EN",
|
||||
website: "https://ai.enally.in",
|
||||
hasFree: true,
|
||||
freeNote: "Free for students and developers — no credit card, OTP verification",
|
||||
apiHint:
|
||||
"Get free API key at https://ai.enally.in/api — requires email and domain whitelisting.",
|
||||
},
|
||||
freetheai: {
|
||||
id: "freetheai",
|
||||
alias: "fta",
|
||||
name: "FreeTheAi",
|
||||
icon: "lock_open",
|
||||
color: "#10B981",
|
||||
textIcon: "FT",
|
||||
website: "https://freetheai.xyz",
|
||||
hasFree: true,
|
||||
freeNote: "Community-run — free forever, no paid tiers, no credit card",
|
||||
apiHint:
|
||||
"Get free API key via Discord: https://freetheai.xyz — 16,000+ models, OpenAI-compatible.",
|
||||
},
|
||||
xai: {
|
||||
id: "xai",
|
||||
alias: "xai",
|
||||
@@ -618,6 +668,15 @@ export const APIKEY_PROVIDERS = {
|
||||
textIcon: "NB",
|
||||
website: "https://nanobananaapi.ai",
|
||||
},
|
||||
kie: {
|
||||
id: "kie",
|
||||
alias: "kie",
|
||||
name: "KIE.AI",
|
||||
icon: "hub",
|
||||
color: "#2563EB",
|
||||
textIcon: "KIE",
|
||||
website: "https://kie.ai",
|
||||
},
|
||||
"ollama-cloud": {
|
||||
id: "ollama-cloud",
|
||||
alias: "ollamacloud",
|
||||
@@ -702,7 +761,7 @@ export const APIKEY_PROVIDERS = {
|
||||
name: "OpenCode Go",
|
||||
icon: "opencode",
|
||||
color: "#6366f1",
|
||||
website: "https://opencode.ai/zen/go",
|
||||
website: "https://opencode.ai/go",
|
||||
},
|
||||
alibaba: {
|
||||
id: "alibaba",
|
||||
@@ -721,7 +780,7 @@ export const APIKEY_PROVIDERS = {
|
||||
icon: "auto_awesome",
|
||||
color: "#FF6B9D",
|
||||
textIcon: "LC",
|
||||
website: "https://longcat.chat",
|
||||
website: "https://longcat.chat/platform/docs",
|
||||
hasFree: true,
|
||||
freeNote:
|
||||
"50M tokens/day (Flash-Lite) + 500K/day (Chat/Thinking) — 100% free while public beta",
|
||||
@@ -751,6 +810,19 @@ export const APIKEY_PROVIDERS = {
|
||||
passthroughModels: true,
|
||||
authHint: "Get token at puter.com/dashboard → Copy Auth Token",
|
||||
},
|
||||
uncloseai: {
|
||||
id: "uncloseai",
|
||||
alias: "unc",
|
||||
name: "UncloseAI",
|
||||
icon: "auto_awesome",
|
||||
color: "#8B5CF6",
|
||||
textIcon: "UN",
|
||||
website: "https://uncloseai.com",
|
||||
hasFree: true,
|
||||
freeNote: "Free forever — no signup, no credit card. OpenAI-compatible endpoints.",
|
||||
passthroughModels: true,
|
||||
authHint: "No auth required. API accepts any non-empty string as key for identification.",
|
||||
},
|
||||
"cloudflare-ai": {
|
||||
id: "cloudflare-ai",
|
||||
alias: "cf",
|
||||
@@ -758,7 +830,7 @@ export const APIKEY_PROVIDERS = {
|
||||
icon: "cloud",
|
||||
color: "#F48120",
|
||||
textIcon: "CF",
|
||||
website: "https://developers.cloudflare.com/workers-ai/",
|
||||
website: "https://developers.cloudflare.com/workers-ai",
|
||||
hasFree: true,
|
||||
freeNote:
|
||||
"Free 10K Neurons/day: ~150 LLM responses or 500s Whisper audio — edge inference globally",
|
||||
@@ -771,7 +843,7 @@ export const APIKEY_PROVIDERS = {
|
||||
icon: "cloud",
|
||||
color: "#4F0599",
|
||||
textIcon: "SCW",
|
||||
website: "https://www.scaleway.com/en/ai/generative-apis/",
|
||||
website: "https://www.scaleway.com/en/ai/generative-apis",
|
||||
hasFree: true,
|
||||
freeNote: "1M free tokens for new accounts — EU/GDPR compliant (Paris), Qwen3 235B & Llama 70B",
|
||||
},
|
||||
@@ -904,6 +976,42 @@ export const APIKEY_PROVIDERS = {
|
||||
textIcon: "FL",
|
||||
website: "https://featherless.ai",
|
||||
},
|
||||
llm7: {
|
||||
id: "llm7",
|
||||
alias: "llm7",
|
||||
name: "LLM7.io",
|
||||
icon: "hub",
|
||||
color: "#6366F1",
|
||||
textIcon: "LM",
|
||||
website: "https://llm7.io",
|
||||
hasFree: true,
|
||||
freeNote: "No signup required - 2 req/s, 20 RPM, 100 req/hr free tier",
|
||||
apiHint:
|
||||
"Works without API key (use 'unused' as key). Get free token at token.llm7.io for higher limits.",
|
||||
},
|
||||
lepton: {
|
||||
id: "lepton",
|
||||
alias: "lepton",
|
||||
name: "Lepton AI",
|
||||
icon: "bolt",
|
||||
color: "#10B981",
|
||||
textIcon: "LP",
|
||||
website: "https://lepton.ai",
|
||||
hasFree: true,
|
||||
freeNote: "Free tier available - fast inference on custom hardware",
|
||||
},
|
||||
kluster: {
|
||||
id: "kluster",
|
||||
alias: "kluster",
|
||||
name: "Kluster AI",
|
||||
icon: "hub",
|
||||
color: "#8B5CF6",
|
||||
textIcon: "KL",
|
||||
website: "https://kluster.ai",
|
||||
hasFree: true,
|
||||
freeNote: "$5 free credits on signup - DeepSeek R1, Llama 4 Maverick/Scout, Qwen3 235B",
|
||||
apiHint: "Get API key at https://kluster.ai/dashboard/api-keys",
|
||||
},
|
||||
friendliai: {
|
||||
id: "friendliai",
|
||||
alias: "friendli",
|
||||
@@ -1840,11 +1948,13 @@ export const USAGE_SUPPORTED_PROVIDERS = [
|
||||
"claude",
|
||||
"kimi-coding",
|
||||
"glm",
|
||||
"glm-cn",
|
||||
"glmt",
|
||||
"minimax",
|
||||
"minimax-cn",
|
||||
"crof",
|
||||
"nanogpt",
|
||||
"deepseek",
|
||||
];
|
||||
|
||||
// ── Zod validation at module load (Phase 7.2) ──
|
||||
|
||||
@@ -3,16 +3,14 @@ const PUBLIC_API_ROUTE_PREFIXES = [
|
||||
"/api/auth/logout",
|
||||
"/api/auth/status",
|
||||
"/api/init",
|
||||
"/api/settings/require-login",
|
||||
"/api/v1/",
|
||||
"/api/cloud/",
|
||||
"/api/sync/bundle",
|
||||
"/api/oauth/",
|
||||
];
|
||||
|
||||
const PUBLIC_READONLY_API_ROUTE_PREFIXES = [
|
||||
"/api/monitoring/health",
|
||||
"/api/settings/require-login",
|
||||
];
|
||||
const PUBLIC_READONLY_API_ROUTE_PREFIXES = ["/api/monitoring/health"];
|
||||
|
||||
const PUBLIC_READONLY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export const ROUTING_STRATEGY_VALUES = [
|
||||
"random",
|
||||
"least-used",
|
||||
"cost-optimized",
|
||||
"reset-aware",
|
||||
"strict-random",
|
||||
"auto",
|
||||
"lkgp",
|
||||
@@ -123,6 +124,13 @@ export const ROUTING_STRATEGIES: RoutingStrategyOption[] = [
|
||||
settingsDescKey: "costOptDesc",
|
||||
icon: "savings",
|
||||
},
|
||||
{
|
||||
value: "reset-aware",
|
||||
labelKey: "resetAware",
|
||||
combosDescKey: "resetAwareDesc",
|
||||
settingsDescKey: "resetAwareDesc",
|
||||
icon: "event_repeat",
|
||||
},
|
||||
{
|
||||
value: "strict-random",
|
||||
labelKey: "strictRandom",
|
||||
|
||||
@@ -54,6 +54,10 @@ function getRequestPathname(request: RequestLike | Request | null | undefined):
|
||||
}
|
||||
}
|
||||
|
||||
function isOnboardingBootstrapPath(pathname: string | null): boolean {
|
||||
return pathname === "/dashboard/onboarding";
|
||||
}
|
||||
|
||||
function getRequestMethod(request: RequestLike | Request | null | undefined): string {
|
||||
if (
|
||||
request &&
|
||||
@@ -273,6 +277,10 @@ export async function isAuthRequired(
|
||||
if (!request) return false;
|
||||
|
||||
const pathname = getRequestPathname(request);
|
||||
if (isOnboardingBootstrapPath(pathname)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pathname && isPublicApiRoute(pathname, getRequestMethod(request))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,13 @@ import { checkBudget } from "@/domain/costRules";
|
||||
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import * as log from "@/sse/utils/logger";
|
||||
import { checkRateLimit, RateLimitRule } from "./rateLimiter";
|
||||
|
||||
const DEFAULT_RATE_LIMITS: RateLimitRule[] = [
|
||||
{ limit: 1000, window: 86400 }, // 1000 per day
|
||||
{ limit: 5000, window: 604800 }, // 5000 per week
|
||||
{ limit: 20000, window: 2592000 } // 20000 per month
|
||||
];
|
||||
|
||||
interface AccessSchedule {
|
||||
enabled: boolean;
|
||||
@@ -34,10 +41,13 @@ export interface ApiKeyMetadata {
|
||||
budget?: number;
|
||||
usedBudget?: number;
|
||||
isActive?: boolean;
|
||||
isBanned?: boolean;
|
||||
expiresAt?: string | null;
|
||||
accessSchedule?: AccessSchedule | null;
|
||||
maxRequestsPerDay?: number | null;
|
||||
maxRequestsPerMinute?: number | null;
|
||||
maxSessions?: number | null;
|
||||
rateLimits?: RateLimitRule[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,64 +116,7 @@ function isWithinSchedule(schedule: AccessSchedule): boolean {
|
||||
return localMinutes >= fromMinutes && localMinutes < untilMinutes;
|
||||
}
|
||||
|
||||
// ── In-memory request counter for per-key rate limits (#452) ──
|
||||
|
||||
/** Sliding-window request timestamps per API key */
|
||||
const _requestTimestamps = new Map<string, number[]>();
|
||||
const REQUEST_COUNTER_MAX_KEYS = 5000;
|
||||
const REQUEST_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const REQUEST_MINUTE_MS = 60 * 1000;
|
||||
|
||||
/** Record a request and check per-key limits. Returns null if OK, or an error message. */
|
||||
function checkRequestCountLimits(
|
||||
apiKeyId: string,
|
||||
maxPerDay: number | null | undefined,
|
||||
maxPerMinute: number | null | undefined
|
||||
): string | null {
|
||||
if (!maxPerDay && !maxPerMinute) return null;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Get or create timestamp array for this key
|
||||
let timestamps = _requestTimestamps.get(apiKeyId);
|
||||
if (!timestamps) {
|
||||
timestamps = [];
|
||||
_requestTimestamps.set(apiKeyId, timestamps);
|
||||
// Prevent unbounded growth
|
||||
if (_requestTimestamps.size > REQUEST_COUNTER_MAX_KEYS) {
|
||||
const firstKey = _requestTimestamps.keys().next().value;
|
||||
if (firstKey) _requestTimestamps.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Prune timestamps older than 24h
|
||||
const dayAgo = now - REQUEST_DAY_MS;
|
||||
while (timestamps.length > 0 && timestamps[0] < dayAgo) {
|
||||
timestamps.shift();
|
||||
}
|
||||
|
||||
// Check per-minute limit (before recording this request)
|
||||
if (maxPerMinute && maxPerMinute > 0) {
|
||||
const minuteAgo = now - REQUEST_MINUTE_MS;
|
||||
const recentCount = timestamps.filter((t) => t >= minuteAgo).length;
|
||||
if (recentCount >= maxPerMinute) {
|
||||
return `Per-minute request limit exceeded (${maxPerMinute} RPM). Try again in a few seconds.`;
|
||||
}
|
||||
}
|
||||
|
||||
// Check per-day limit
|
||||
if (maxPerDay && maxPerDay > 0) {
|
||||
if (timestamps.length >= maxPerDay) {
|
||||
return `Daily request limit exceeded (${maxPerDay} RPD). Resets in ${Math.ceil(
|
||||
(timestamps[0] + REQUEST_DAY_MS - now) / 60000
|
||||
)} minutes.`;
|
||||
}
|
||||
}
|
||||
|
||||
// All checks passed — record this request
|
||||
timestamps.push(now);
|
||||
return null;
|
||||
}
|
||||
// Legacy in-memory request counter has been replaced by Redis-backed multi-window rate limiter
|
||||
|
||||
export interface ApiKeyPolicyResult {
|
||||
/** API key string (null if no key provided) */
|
||||
@@ -222,7 +175,7 @@ export async function enforceApiKeyPolicy(
|
||||
return { apiKey, apiKeyInfo: null, rejection: null };
|
||||
}
|
||||
|
||||
// ── Check 1: is_active — hard block regardless of schedule ──
|
||||
// ── Check 1: is_active / is_banned ──
|
||||
if (apiKeyInfo.isActive === false) {
|
||||
return {
|
||||
apiKey,
|
||||
@@ -230,6 +183,25 @@ export async function enforceApiKeyPolicy(
|
||||
rejection: errorResponse(HTTP_STATUS.FORBIDDEN, "This API key is disabled"),
|
||||
};
|
||||
}
|
||||
if (apiKeyInfo.isBanned === true) {
|
||||
return {
|
||||
apiKey,
|
||||
apiKeyInfo,
|
||||
rejection: errorResponse(HTTP_STATUS.FORBIDDEN, "This API key is banned due to policy violations"),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Check 1.5: expires_at ──
|
||||
if (apiKeyInfo.expiresAt) {
|
||||
const expiry = new Date(apiKeyInfo.expiresAt).getTime();
|
||||
if (Date.now() > expiry) {
|
||||
return {
|
||||
apiKey,
|
||||
apiKeyInfo,
|
||||
rejection: errorResponse(HTTP_STATUS.FORBIDDEN, "This API key has expired"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 2: access_schedule — time-based access window ──
|
||||
if (apiKeyInfo.accessSchedule && apiKeyInfo.accessSchedule.enabled) {
|
||||
@@ -286,18 +258,31 @@ export async function enforceApiKeyPolicy(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 5: Request-count limits (#452) ──
|
||||
if (apiKeyInfo.id && (apiKeyInfo.maxRequestsPerDay || apiKeyInfo.maxRequestsPerMinute)) {
|
||||
const limitError = checkRequestCountLimits(
|
||||
apiKeyInfo.id,
|
||||
apiKeyInfo.maxRequestsPerDay,
|
||||
apiKeyInfo.maxRequestsPerMinute
|
||||
);
|
||||
if (limitError) {
|
||||
// ── Check 5: Generic Multi-Window Rate Limits ──
|
||||
if (apiKeyInfo.id) {
|
||||
const rulesToApply = (apiKeyInfo.rateLimits && apiKeyInfo.rateLimits.length > 0)
|
||||
? [...apiKeyInfo.rateLimits]
|
||||
: [...DEFAULT_RATE_LIMITS];
|
||||
|
||||
// Combine with legacy limits if they exist and custom rate limits aren't set
|
||||
if (!apiKeyInfo.rateLimits || apiKeyInfo.rateLimits.length === 0) {
|
||||
if (apiKeyInfo.maxRequestsPerDay) {
|
||||
rulesToApply.push({ limit: apiKeyInfo.maxRequestsPerDay, window: 86400 });
|
||||
}
|
||||
if (apiKeyInfo.maxRequestsPerMinute) {
|
||||
rulesToApply.push({ limit: apiKeyInfo.maxRequestsPerMinute, window: 60 });
|
||||
}
|
||||
}
|
||||
|
||||
const rateLimitResult = await checkRateLimit(apiKeyInfo.id, rulesToApply);
|
||||
if (!rateLimitResult.allowed) {
|
||||
const failedWindowStr = rateLimitResult.failedWindow
|
||||
? ` (${rateLimitResult.failedWindow}s window)`
|
||||
: "";
|
||||
return {
|
||||
apiKey,
|
||||
apiKeyInfo,
|
||||
rejection: errorResponse(HTTP_STATUS.RATE_LIMITED, limitError),
|
||||
rejection: errorResponse(HTTP_STATUS.RATE_LIMITED, `Request limit exceeded${failedWindowStr}. Please try again later.`),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
142
src/shared/utils/rateLimiter.ts
Normal file
142
src/shared/utils/rateLimiter.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
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.');
|
||||
}
|
||||
|
||||
|
||||
let redisClient: Redis | null = null;
|
||||
|
||||
export function getRedisClient() {
|
||||
if (!redisClient) {
|
||||
redisClient = new Redis(REDIS_URL, {
|
||||
maxRetriesPerRequest: 3,
|
||||
enableReadyCheck: false,
|
||||
retryStrategy(times) {
|
||||
return Math.min(times * 50, 2000); // Exponential backoff
|
||||
}
|
||||
});
|
||||
redisClient.on('error', (err) => console.error('[REDIS] Error:', err.message));
|
||||
}
|
||||
return redisClient;
|
||||
}
|
||||
|
||||
export interface RateLimitRule {
|
||||
limit: number;
|
||||
window: number; // in seconds
|
||||
}
|
||||
|
||||
export interface RateLimitResult {
|
||||
allowed: boolean;
|
||||
failedWindow?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic Lua script for multi-rule rate limiting using fixed window.
|
||||
* Returns {1, 0} if allowed, or {0, failedWindow} if rejected.
|
||||
*/
|
||||
const RATE_LIMIT_SCRIPT = `
|
||||
local key_prefix = KEYS[1]
|
||||
local current_time = tonumber(ARGV[1])
|
||||
|
||||
local rules = {}
|
||||
for i = 2, #ARGV, 2 do
|
||||
table.insert(rules, {
|
||||
limit = tonumber(ARGV[i]),
|
||||
window = tonumber(ARGV[i+1])
|
||||
})
|
||||
end
|
||||
|
||||
-- First pass: check if any limit is exceeded
|
||||
for i, rule in ipairs(rules) do
|
||||
local current_window = math.floor(current_time / rule.window)
|
||||
local window_key = key_prefix .. ":" .. rule.window .. ":" .. current_window
|
||||
|
||||
local count = tonumber(redis.call("GET", window_key) or "0")
|
||||
if count >= rule.limit then
|
||||
return { 0, rule.window } -- Reject, return which window failed
|
||||
end
|
||||
end
|
||||
|
||||
-- Second pass: increment all rules
|
||||
for i, rule in ipairs(rules) do
|
||||
local current_window = math.floor(current_time / rule.window)
|
||||
local window_key = key_prefix .. ":" .. rule.window .. ":" .. current_window
|
||||
|
||||
local count = redis.call("INCR", window_key)
|
||||
if count == 1 then
|
||||
-- TTL is twice the window size to ensure it covers the current window safely
|
||||
redis.call("EXPIRE", window_key, rule.window * 2)
|
||||
end
|
||||
end
|
||||
|
||||
return { 1, 0 } -- Accepted
|
||||
`;
|
||||
|
||||
const TEST_MEMORY_STORE = new Map<string, number>();
|
||||
let explicitTestMode = false;
|
||||
|
||||
export function setRateLimiterTestMode(enabled: boolean) {
|
||||
explicitTestMode = enabled;
|
||||
if (enabled) TEST_MEMORY_STORE.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks multi-window rate limits for an API key atomically via Redis.
|
||||
*/
|
||||
export async function checkRateLimit(
|
||||
keyId: string,
|
||||
rules: RateLimitRule[]
|
||||
): Promise<RateLimitResult> {
|
||||
if (!rules || rules.length === 0) return { allowed: true };
|
||||
|
||||
// ── In-memory mock for unit tests ──
|
||||
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 };
|
||||
}
|
||||
|
||||
const redis = getRedisClient();
|
||||
const args: (string | number)[] = [Math.floor(Date.now() / 1000)];
|
||||
|
||||
for (const rule of rules) {
|
||||
args.push(rule.limit, rule.window);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await redis.eval(
|
||||
RATE_LIMIT_SCRIPT,
|
||||
1,
|
||||
`rl:api_key:${keyId}`,
|
||||
...args
|
||||
) as [number, number];
|
||||
|
||||
if (result[0] === 0) {
|
||||
return { allowed: false, failedWindow: result[1] };
|
||||
}
|
||||
|
||||
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);
|
||||
return { allowed: true };
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ type ReadTimeoutOptions = {
|
||||
|
||||
export const DEFAULT_FETCH_TIMEOUT_MS = 600_000;
|
||||
export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 600_000;
|
||||
export const DEFAULT_STREAM_READINESS_TIMEOUT_MS = 30_000;
|
||||
export const DEFAULT_FETCH_CONNECT_TIMEOUT_MS = 30_000;
|
||||
export const DEFAULT_FETCH_KEEPALIVE_TIMEOUT_MS = 4_000;
|
||||
export const DEFAULT_API_BRIDGE_PROXY_TIMEOUT_MS = 30_000;
|
||||
@@ -24,6 +25,7 @@ function hasEnvValue(env: EnvSource, name: string): boolean {
|
||||
export type UpstreamTimeoutConfig = {
|
||||
fetchTimeoutMs: number;
|
||||
streamIdleTimeoutMs: number;
|
||||
streamReadinessTimeoutMs: number;
|
||||
fetchHeadersTimeoutMs: number;
|
||||
fetchBodyTimeoutMs: number;
|
||||
fetchConnectTimeoutMs: number;
|
||||
@@ -89,10 +91,20 @@ export function getUpstreamTimeoutConfig(
|
||||
logger,
|
||||
}
|
||||
);
|
||||
const streamReadinessTimeoutMs = readTimeoutMs(
|
||||
env,
|
||||
"STREAM_READINESS_TIMEOUT_MS",
|
||||
DEFAULT_STREAM_READINESS_TIMEOUT_MS,
|
||||
{
|
||||
allowZero: true,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
fetchTimeoutMs,
|
||||
streamIdleTimeoutMs,
|
||||
streamReadinessTimeoutMs,
|
||||
fetchHeadersTimeoutMs: readTimeoutMs(env, "FETCH_HEADERS_TIMEOUT_MS", fetchTimeoutMs, {
|
||||
allowZero: true,
|
||||
logger,
|
||||
|
||||
@@ -292,6 +292,7 @@ export const createProviderSchema = z
|
||||
export const createKeySchema = z.object({
|
||||
name: z.string().min(1, "Name is required").max(200),
|
||||
noLog: z.boolean().optional(),
|
||||
scopes: z.array(z.string().trim().min(1).max(64)).max(16).optional(),
|
||||
});
|
||||
|
||||
export const createSyncTokenSchema = z.object({
|
||||
@@ -375,6 +376,7 @@ const comboRuntimeConfigSchema = z
|
||||
strategy: comboStrategySchema.optional(),
|
||||
maxRetries: z.coerce.number().int().min(0).max(10).optional(),
|
||||
retryDelayMs: z.coerce.number().int().min(0).max(60000).optional(),
|
||||
fallbackDelayMs: z.coerce.number().int().min(0).max(60000).optional(),
|
||||
timeoutMs: z.coerce.number().int().min(1000).optional(),
|
||||
concurrencyPerModel: z.coerce.number().int().min(1).max(20).optional(),
|
||||
queueTimeoutMs: z.coerce.number().int().min(1000).max(120000).optional(),
|
||||
@@ -395,6 +397,10 @@ const comboRuntimeConfigSchema = z
|
||||
explorationRate: z.number().min(0).max(1).optional(),
|
||||
routerStrategy: z.string().optional(),
|
||||
compositeTiers: compositeTiersSchema.optional(),
|
||||
resetAwareSessionWeight: z.coerce.number().min(0).max(100).optional(),
|
||||
resetAwareWeeklyWeight: z.coerce.number().min(0).max(100).optional(),
|
||||
resetAwareTieBandPercent: z.coerce.number().min(0).max(100).optional(),
|
||||
resetAwareExhaustionGuardPercent: z.coerce.number().min(0).max(100).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -439,6 +445,7 @@ export const updateSettingsSchema = z.object({
|
||||
bruteForceProtection: z.boolean().optional(),
|
||||
hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(),
|
||||
comboConfigMode: z.enum(COMBO_CONFIG_MODES).optional(),
|
||||
codexServiceTier: z.object({ enabled: z.boolean() }).optional(),
|
||||
// Routing settings (#134)
|
||||
fallbackStrategy: settingsFallbackStrategySchema.optional(),
|
||||
wildcardAliases: z.array(z.object({ pattern: z.string(), target: z.string() })).optional(),
|
||||
@@ -1325,7 +1332,7 @@ export const updateComboSchema = z
|
||||
system_message: z.string().max(50000).optional(),
|
||||
tool_filter_regex: z.string().max(1000).optional(),
|
||||
context_cache_protection: z.boolean().optional(),
|
||||
context_length: z.number().int().min(1000).max(2000000).optional(),
|
||||
context_length: z.number().int().min(1000).max(2000000).optional().nullable(),
|
||||
compressionOverride: comboCompressionOverrideSchema.optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
@@ -1458,8 +1465,21 @@ export const updateKeyPermissionsSchema = z
|
||||
noLog: z.boolean().optional(),
|
||||
autoResolve: z.boolean().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
isBanned: z.boolean().optional(),
|
||||
expiresAt: z.string().datetime().nullable().optional(),
|
||||
maxSessions: z.number().int().min(0).max(10000).optional(),
|
||||
accessSchedule: z.union([accessScheduleSchema, z.null()]).optional(),
|
||||
rateLimits: z
|
||||
.union([
|
||||
z
|
||||
.array(
|
||||
z.object({ limit: z.number().int().positive(), window: z.number().int().positive() })
|
||||
)
|
||||
.max(50),
|
||||
z.null(),
|
||||
])
|
||||
.optional(),
|
||||
scopes: z.array(z.string().trim().min(1).max(64)).max(16).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (
|
||||
@@ -1469,8 +1489,12 @@ export const updateKeyPermissionsSchema = z
|
||||
value.noLog === undefined &&
|
||||
value.autoResolve === undefined &&
|
||||
value.isActive === undefined &&
|
||||
value.isBanned === undefined &&
|
||||
value.expiresAt === undefined &&
|
||||
value.maxSessions === undefined &&
|
||||
value.accessSchedule === undefined
|
||||
value.accessSchedule === undefined &&
|
||||
value.rateLimits === undefined &&
|
||||
value.scopes === undefined
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
@@ -1557,6 +1581,7 @@ export const updateProviderConnectionSchema = z
|
||||
healthCheckInterval: z.coerce.number().int().min(0).optional(),
|
||||
group: z.union([z.string().max(100), z.null()]).optional(),
|
||||
maxConcurrent: z.union([z.null(), z.coerce.number().int().min(0)]).optional(),
|
||||
projectId: z.union([z.string(), z.null()]).optional(),
|
||||
// Partial patch of per-connection provider-specific settings (e.g. quota toggles)
|
||||
providerSpecificData: z
|
||||
.record(z.string(), z.unknown())
|
||||
|
||||
@@ -37,6 +37,7 @@ export const updateSettingsSchema = z.object({
|
||||
debugMode: z.boolean().optional(),
|
||||
hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(),
|
||||
comboConfigMode: z.enum(COMBO_CONFIG_MODES).optional(),
|
||||
codexServiceTier: z.object({ enabled: z.boolean() }).optional(),
|
||||
// Routing settings (#134)
|
||||
fallbackStrategy: z.enum(ACCOUNT_FALLBACK_STRATEGY_VALUES).optional(),
|
||||
wildcardAliases: z.array(z.object({ pattern: z.string(), target: z.string() })).optional(),
|
||||
|
||||
Reference in New Issue
Block a user