Merge pull request #363 from AndersonFirmino/feat/strict-random-i18n-ux

Merged! Excellent contribution @AndersonFirmino 🎉

This PR delivers four major improvements:
- **strict-random** strategy — Fisher-Yates shuffle deck with anti-repeat guarantee and mutex serialization for concurrent safety
- **API key controls** — allowedConnections, is_active, accessSchedule, autoResolve
- **Connection groups** — environment-based grouping view in Limits page with localStorage persistence  
- **i18n** — 30 languages fully updated, pt-BR fully translated

655 tests passing. Merged with main (v2.4.4) — no conflicts. Thank you for the exceptional quality!
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-03-14 15:30:19 -03:00
committed by GitHub
46 changed files with 2198 additions and 206 deletions

View File

@@ -9,6 +9,7 @@ import { recordComboRequest, getComboMetrics } from "./comboMetrics.ts";
import { resolveComboConfig, getDefaultComboConfig } from "./comboConfig.ts";
import * as semaphore from "./rateLimitSemaphore.ts";
import { getCircuitBreaker } from "../../src/shared/utils/circuitBreaker";
import { fisherYatesShuffle, getNextFromDeck } from "../../src/shared/utils/shuffleDeck";
import { parseModel } from "./model.ts";
// Status codes that should mark semaphore + record circuit breaker failures
@@ -150,18 +151,8 @@ function orderModelsForWeightedFallback(models, selectedModel) {
return [selected, ...rest].filter(Boolean).map((e) => e.model);
}
/**
* Fisher-Yates shuffle (in-place)
* @param {Array} arr
* @returns {Array} The shuffled array
*/
function shuffleArray(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
// shuffleArray and getNextModelFromDeck moved to src/shared/utils/shuffleDeck.ts
// combo.ts now uses the shared, mutex-protected getNextFromDeck with "combo:" namespace.
/**
* Sort models by pricing (cheapest first) for cost-optimized strategy
@@ -287,8 +278,17 @@ export async function handleComboChat({
}
// Apply strategy-specific ordering
if (strategy === "random") {
orderedModels = shuffleArray([...orderedModels]);
if (strategy === "strict-random") {
const selectedId = await getNextFromDeck(`combo:${combo.name}`, orderedModels);
// Put selected model first so the fallback loop tries it first
const rest = orderedModels.filter((m) => m !== selectedId);
orderedModels = [selectedId, ...rest];
log.info(
"COMBO",
`Strict-random deck: ${selectedId} selected (${orderedModels.length} models)`
);
} else if (strategy === "random") {
orderedModels = fisherYatesShuffle([...orderedModels]);
log.info("COMBO", `Random shuffle: ${orderedModels.length} models`);
} else if (strategy === "least-used") {
orderedModels = sortModelsByUsage(orderedModels, combo.name);

View File

@@ -52,15 +52,34 @@ function validateKeyName(
return { valid: true };
}
interface AccessSchedule {
enabled: boolean;
from: string;
until: string;
days: number[];
tz: string;
}
interface ApiKey {
id: string;
name: string;
key: string;
allowedModels: string[] | null;
allowedConnections: string[] | null;
noLog?: boolean;
autoResolve?: boolean;
isActive?: boolean;
accessSchedule?: AccessSchedule | null;
createdAt: string;
}
interface ProviderConnection {
id: string;
name: string;
provider: string;
isActive: boolean;
}
interface KeyUsageStats {
totalRequests: number;
lastUsed: string | null;
@@ -79,6 +98,7 @@ export default function ApiManagerPageClient() {
const tc = useTranslations("common");
const [keys, setKeys] = useState<ApiKey[]>([]);
const [allModels, setAllModels] = useState<Model[]>([]);
const [allConnections, setAllConnections] = useState<ProviderConnection[]>([]);
const [loading, setLoading] = useState(true);
const [showAddModal, setShowAddModal] = useState(false);
const [newKeyName, setNewKeyName] = useState("");
@@ -95,6 +115,7 @@ export default function ApiManagerPageClient() {
useEffect(() => {
fetchData();
fetchModels();
fetchConnections();
}, []);
const fetchModels = async () => {
@@ -109,6 +130,18 @@ export default function ApiManagerPageClient() {
}
};
const fetchConnections = async () => {
try {
const res = await fetch("/api/providers");
if (res.ok) {
const data = await res.json();
setAllConnections(data.connections || []);
}
} catch (error) {
console.log("Error fetching connections:", error);
}
};
const fetchData = async () => {
try {
const res = await fetch("/api/keys");
@@ -227,7 +260,14 @@ export default function ApiManagerPageClient() {
setShowPermissionsModal(true);
};
const handleUpdatePermissions = async (allowedModels: string[], noLog: boolean) => {
const handleUpdatePermissions = async (
allowedModels: string[],
noLog: boolean,
allowedConnections: string[],
autoResolve: boolean,
isActive: boolean,
accessSchedule: AccessSchedule | null
) => {
if (!editingKey || !editingKey.id) return;
// Validate models array
@@ -247,6 +287,11 @@ export default function ApiManagerPageClient() {
(id) => typeof id === "string" && id.length > 0 && id.length < 200
);
// Validate connections (must be UUIDs)
const validConnections = allowedConnections.filter(
(id) => typeof id === "string" && /^[0-9a-f-]{36}$/i.test(id)
);
setIsSubmitting(true);
clearError();
@@ -254,7 +299,14 @@ export default function ApiManagerPageClient() {
const res = await fetch(`/api/keys/${encodeURIComponent(editingKey.id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ allowedModels: validModels, noLog }),
body: JSON.stringify({
allowedModels: validModels,
allowedConnections: validConnections,
noLog,
autoResolve,
isActive,
accessSchedule,
}),
});
if (res.ok) {
@@ -449,7 +501,11 @@ export default function ApiManagerPageClient() {
{keys.map((key) => {
const stats = usageStats[key.id];
const isRestricted = Array.isArray(key.allowedModels) && key.allowedModels.length > 0;
const hasConnectionRestrictions =
Array.isArray(key.allowedConnections) && key.allowedConnections.length > 0;
const noLogEnabled = key.noLog === true;
const keyIsActive = key.isActive !== false; // default true
const hasSchedule = key.accessSchedule?.enabled === true;
return (
<div
key={key.id}
@@ -496,6 +552,15 @@ export default function ApiManagerPageClient() {
{t("allModels")}
</button>
)}
{hasConnectionRestrictions && (
<button
onClick={() => handleOpenPermissions(key)}
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-blue-500/10 text-blue-600 dark:text-blue-400 text-xs font-medium hover:bg-blue-500/20 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">cable</span>
{key.allowedConnections.length} conn
</button>
)}
{noLogEnabled && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-violet-500/10 text-violet-600 dark:text-violet-400 text-[11px] font-medium">
<span className="material-symbols-outlined text-[12px]">
@@ -504,6 +569,26 @@ export default function ApiManagerPageClient() {
No-Log
</span>
)}
{key.autoResolve && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 text-[11px] font-medium">
<span className="material-symbols-outlined text-[12px]">
auto_fix_high
</span>
Auto-Resolve
</span>
)}
{!keyIsActive && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-red-500/10 text-red-600 dark:text-red-400 text-[11px] font-medium">
<span className="material-symbols-outlined text-[12px]">block</span>
{t("disabled")}
</span>
)}
{hasSchedule && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-orange-500/10 text-orange-600 dark:text-orange-400 text-[11px] font-medium">
<span className="material-symbols-outlined text-[12px]">schedule</span>
{t("scheduleActive")}
</span>
)}
</div>
</div>
<div className="col-span-2 flex flex-col justify-center">
@@ -659,6 +744,7 @@ export default function ApiManagerPageClient() {
apiKey={editingKey}
modelsByProvider={filteredModelsByProvider}
allModels={allModels}
allConnections={allConnections}
searchModel={searchModel}
onSearchChange={setSearchModel}
onSave={handleUpdatePermissions}
@@ -676,6 +762,7 @@ const PermissionsModal = memo(function PermissionsModal({
apiKey,
modelsByProvider,
allModels,
allConnections,
searchModel,
onSearchChange,
onSave,
@@ -685,18 +772,42 @@ const PermissionsModal = memo(function PermissionsModal({
apiKey: ApiKey;
modelsByProvider: ProviderGroup[];
allModels: Model[];
allConnections: ProviderConnection[];
searchModel: string;
onSearchChange: (v: string) => void;
onSave: (models: string[], noLog: boolean) => void;
onSave: (
models: string[],
noLog: boolean,
connections: string[],
autoResolve: boolean,
isActive: boolean,
accessSchedule: AccessSchedule | null
) => void;
}) {
const t = useTranslations("apiManager");
const tc = useTranslations("common");
// Initialize state from props - component remounts when key prop changes
const initialModels = Array.isArray(apiKey?.allowedModels) ? apiKey.allowedModels : [];
const initialConnections = Array.isArray(apiKey?.allowedConnections)
? apiKey.allowedConnections
: [];
const [selectedModels, setSelectedModels] = useState<string[]>(initialModels);
const [allowAll, setAllowAll] = useState(initialModels.length === 0);
const [noLogEnabled, setNoLogEnabled] = useState(apiKey?.noLog === true);
const [autoResolveEnabled, setAutoResolveEnabled] = useState(apiKey?.autoResolve === true);
const [keyIsActive, setKeyIsActive] = useState(apiKey?.isActive !== false);
const [scheduleEnabled, setScheduleEnabled] = useState(apiKey?.accessSchedule?.enabled === true);
const [scheduleFrom, setScheduleFrom] = useState(apiKey?.accessSchedule?.from ?? "08:00");
const [scheduleUntil, setScheduleUntil] = useState(apiKey?.accessSchedule?.until ?? "18:00");
const [scheduleDays, setScheduleDays] = useState<number[]>(
apiKey?.accessSchedule?.days ?? [1, 2, 3, 4, 5]
);
const [scheduleTz, setScheduleTz] = useState(
apiKey?.accessSchedule?.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone
);
const [selectedConnections, setSelectedConnections] = useState<string[]>(initialConnections);
const [allowAllConnections, setAllowAllConnections] = useState(initialConnections.length === 0);
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(() => {
// Expand all providers by default when in restrict mode with existing selections
if (initialModels.length > 0) {
@@ -769,9 +880,51 @@ const PermissionsModal = memo(function PermissionsModal({
setSelectedModels([]);
}, []);
const handleToggleConnection = useCallback(
(connectionId: string) => {
if (allowAllConnections) return;
setSelectedConnections((prev) =>
prev.includes(connectionId)
? prev.filter((c) => c !== connectionId)
: [...prev, connectionId]
);
},
[allowAllConnections]
);
const handleSave = useCallback(() => {
onSave(allowAll ? [] : selectedModels, noLogEnabled);
}, [onSave, allowAll, selectedModels, noLogEnabled]);
const schedule: AccessSchedule | null = scheduleEnabled
? {
enabled: true,
from: scheduleFrom,
until: scheduleUntil,
days: scheduleDays,
tz: scheduleTz,
}
: null;
onSave(
allowAll ? [] : selectedModels,
noLogEnabled,
allowAllConnections ? [] : selectedConnections,
autoResolveEnabled,
keyIsActive,
schedule
);
}, [
onSave,
allowAll,
selectedModels,
noLogEnabled,
allowAllConnections,
selectedConnections,
autoResolveEnabled,
keyIsActive,
scheduleEnabled,
scheduleFrom,
scheduleUntil,
scheduleDays,
scheduleTz,
]);
const selectedCount = selectedModels.length;
const totalModels = allModels.length;
@@ -833,6 +986,129 @@ const PermissionsModal = memo(function PermissionsModal({
</p>
</div>
{/* Key Active Toggle */}
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{t("keyActive")}</p>
<p className="text-xs text-text-muted">{t("keyActiveDesc")}</p>
</div>
<button
type="button"
role="switch"
aria-checked={keyIsActive}
onClick={() => setKeyIsActive((prev) => !prev)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
keyIsActive
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border border-emerald-500/30"
: "bg-red-500/15 text-red-700 dark:text-red-300 border border-red-500/30"
}`}
>
<span className="material-symbols-outlined text-[14px]">
{keyIsActive ? "check_circle" : "block"}
</span>
{keyIsActive ? tc("enabled") : tc("disabled")}
</button>
</div>
{/* Access Schedule */}
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{t("accessSchedule")}</p>
<p className="text-xs text-text-muted">{t("accessScheduleDesc")}</p>
</div>
<button
type="button"
role="switch"
aria-checked={scheduleEnabled}
onClick={() => setScheduleEnabled((prev) => !prev)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors shrink-0 ${
scheduleEnabled
? "bg-orange-500/15 text-orange-700 dark:text-orange-300 border border-orange-500/30"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
}`}
>
<span className="material-symbols-outlined text-[14px]">schedule</span>
{scheduleEnabled ? tc("enabled") : tc("disabled")}
</button>
</div>
{scheduleEnabled && (
<div className="flex flex-col gap-3 pt-1">
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-text-muted mb-1 block">{t("scheduleFrom")}</label>
<input
type="time"
value={scheduleFrom}
onChange={(e) => setScheduleFrom(e.target.value)}
className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main"
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">{t("scheduleUntil")}</label>
<input
type="time"
value={scheduleUntil}
onChange={(e) => setScheduleUntil(e.target.value)}
className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main"
/>
</div>
</div>
<div>
<label className="text-xs text-text-muted mb-1.5 block">{t("scheduleDays")}</label>
<div className="flex gap-1 flex-wrap">
{(
[
[0, t("daySun")],
[1, t("dayMon")],
[2, t("dayTue")],
[3, t("dayWed")],
[4, t("dayThu")],
[5, t("dayFri")],
[6, t("daySat")],
] as [number, string][]
).map(([dayIdx, label]) => {
const selected = scheduleDays.includes(dayIdx);
return (
<button
key={dayIdx}
type="button"
onClick={() =>
setScheduleDays((prev) =>
prev.includes(dayIdx)
? prev.filter((d) => d !== dayIdx)
: [...prev, dayIdx].sort((a, b) => a - b)
)
}
className={`px-2 py-1 text-[11px] font-medium rounded transition-all ${
selected
? "bg-primary text-white"
: "bg-surface border border-border text-text-muted hover:border-primary/50"
}`}
>
{label}
</button>
);
})}
</div>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">
{t("scheduleTimezone")}
</label>
<input
type="text"
value={scheduleTz}
onChange={(e) => setScheduleTz(e.target.value)}
placeholder="America/Sao_Paulo"
className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main font-mono"
/>
<p className="text-[10px] text-text-muted mt-1">{t("scheduleTimezoneHint")}</p>
</div>
</div>
)}
</div>
{/* Privacy Toggle */}
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
@@ -859,6 +1135,30 @@ const PermissionsModal = memo(function PermissionsModal({
</button>
</div>
{/* Auto-Resolve Toggle */}
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{t("autoResolve")}</p>
<p className="text-xs text-text-muted">{t("autoResolveDesc")}</p>
</div>
<button
type="button"
role="switch"
aria-checked={autoResolveEnabled}
onClick={() => setAutoResolveEnabled((prev) => !prev)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
autoResolveEnabled
? "bg-cyan-500/15 text-cyan-700 dark:text-cyan-300 border border-cyan-500/30"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
}`}
>
<span className="material-symbols-outlined text-[14px]">
{autoResolveEnabled ? "auto_fix_high" : "auto_fix_normal"}
</span>
{autoResolveEnabled ? tc("enabled") : tc("disabled")}
</button>
</div>
{/* Selected Models Summary (only in restrict mode) */}
{!allowAll && selectedCount > 0 && (
<div className="flex flex-col gap-1.5 p-2 bg-primary/5 rounded-lg border border-primary/20">
@@ -1024,6 +1324,97 @@ const PermissionsModal = memo(function PermissionsModal({
</>
)}
{/* Allowed Connections Section */}
{allConnections.length > 0 && (
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-text-main">Allowed Connections</p>
<div className="flex gap-1 p-0.5 bg-surface rounded-md">
<button
onClick={() => {
setAllowAllConnections(true);
setSelectedConnections([]);
}}
className={`px-2 py-1 rounded text-xs font-medium transition-all ${
allowAllConnections
? "bg-primary text-white"
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
All
</button>
<button
onClick={() => setAllowAllConnections(false)}
className={`px-2 py-1 rounded text-xs font-medium transition-all ${
!allowAllConnections
? "bg-primary text-white"
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
Restrict
</button>
</div>
</div>
<p className="text-xs text-text-muted">
{allowAllConnections
? "This key can use any active connection."
: `Restricted to ${selectedConnections.length} connection${selectedConnections.length !== 1 ? "s" : ""}.`}
</p>
{!allowAllConnections && (
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto">
{Object.entries(
allConnections.reduce<Record<string, ProviderConnection[]>>((acc, conn) => {
const p = conn.provider || "Other";
if (!acc[p]) acc[p] = [];
acc[p].push(conn);
return acc;
}, {})
)
.sort(([a], [b]) => a.localeCompare(b))
.map(([provider, conns]) => (
<div key={provider}>
<p className="text-[10px] font-semibold text-text-muted uppercase tracking-wider px-1 py-0.5">
{provider}
</p>
{conns.map((conn) => {
const isSelected = selectedConnections.includes(conn.id);
return (
<button
key={conn.id}
onClick={() => handleToggleConnection(conn.id)}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs transition-all ${
isSelected
? "bg-primary/10 text-primary"
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
}`}
>
<div
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
isSelected ? "bg-primary border-primary" : "border-border"
}`}
>
{isSelected && (
<span className="material-symbols-outlined text-white text-[10px]">
check
</span>
)}
</div>
<span className="truncate flex-1">
{conn.name || conn.id.slice(0, 8)}
</span>
{!conn.isActive && (
<span className="text-[9px] text-red-400 shrink-0">inactive</span>
)}
</button>
);
})}
</div>
))}
</div>
)}
</div>
)}
{/* Actions */}
<div className="flex gap-2">
<Button onClick={handleSave} fullWidth>

View File

@@ -34,6 +34,7 @@ const STRATEGY_OPTIONS = [
icon: "stacked_bar_chart",
},
{ value: "p2c", labelKey: "p2c", descKey: "p2cDesc", icon: "compare_arrows" },
{ value: "strict-random", labelKey: "strictRandom", descKey: "strictRandomDesc", icon: "casino" },
];
const STRATEGY_GUIDANCE_FALLBACK = {
@@ -77,6 +78,11 @@ const STRATEGY_GUIDANCE_FALLBACK = {
avoid: "Avoid for small combos with 2 or fewer models — no benefit over round-robin.",
example: "Example: High-throughput inference across 4+ equivalent model endpoints.",
},
"strict-random": {
when: "Use when you want perfectly even spread — each model used once before repeating.",
avoid: "Avoid when models have different quality or latency and order matters.",
example: "Example: Multiple accounts of the same model to distribute usage evenly.",
},
};
const ADVANCED_FIELD_HELP_FALLBACK = {
@@ -162,6 +168,15 @@ const STRATEGY_RECOMMENDATIONS_FALLBACK = {
"Great replacement for round-robin in high-throughput combos.",
],
},
"strict-random": {
title: "Shuffle deck distribution",
description: "Each model is used exactly once per cycle before reshuffling.",
tips: [
"Use at least 2 models for meaningful distribution.",
"Ideal for same-model accounts to evenly spread quota.",
"Guarantees no model is skipped or repeated within a cycle.",
],
},
};
const COMBO_USAGE_GUIDE_STORAGE_KEY = "omniroute:combos:hide-usage-guide";

View File

@@ -12,8 +12,8 @@ export default function LimitsPage() {
<Suspense fallback={<CardSkeleton />}>
<ProviderLimits />
</Suspense>
<RateLimitStatus />
<SessionsTab />
<RateLimitStatus />
</div>
);
}

View File

@@ -10,6 +10,10 @@ import Badge from "@/shared/components/Badge";
import { CardSkeleton } from "@/shared/components/Loading";
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
const LS_GROUP_BY = "omniroute:limits:groupBy";
const LS_AUTO_REFRESH = "omniroute:limits:autoRefresh";
const LS_EXPANDED_GROUPS = "omniroute:limits:expandedGroups";
const REFRESH_INTERVAL_MS = 120000;
const MIN_FETCH_INTERVAL_MS = 30000; // Debounce per-connection fetches
@@ -20,6 +24,7 @@ const PROVIDER_CONFIG = {
kiro: { label: "Kiro AI", color: "#FF6B35" },
codex: { label: "OpenAI Codex", color: "#10A37F" },
claude: { label: "Claude Code", color: "#D97757" },
glm: { label: "GLM (Z.AI)", color: "#4A90D9" },
"kimi-coding": { label: "Kimi Coding", color: "#1E3A8A" },
};
@@ -89,12 +94,30 @@ export default function ProviderLimits() {
const [quotaData, setQuotaData] = useState({});
const [loading, setLoading] = useState({});
const [errors, setErrors] = useState({});
const [autoRefresh, setAutoRefresh] = useState(true);
const [autoRefresh, setAutoRefresh] = useState(() => {
if (typeof window === "undefined") return false;
return localStorage.getItem(LS_AUTO_REFRESH) === "true";
});
const [lastUpdated, setLastUpdated] = useState(null);
const [refreshingAll, setRefreshingAll] = useState(false);
const [countdown, setCountdown] = useState(120);
const [initialLoading, setInitialLoading] = useState(true);
const [tierFilter, setTierFilter] = useState("all");
const [groupBy, setGroupBy] = useState<"none" | "environment">(() => {
if (typeof window === "undefined") return "none";
const saved = localStorage.getItem(LS_GROUP_BY);
if (saved === "environment" || saved === "none") return saved;
return "none";
});
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => {
if (typeof window === "undefined") return new Set();
try {
const saved = localStorage.getItem(LS_EXPANDED_GROUPS);
return saved ? new Set(JSON.parse(saved)) : new Set();
} catch {
return new Set();
}
});
const intervalRef = useRef(null);
const countdownRef = useRef(null);
@@ -175,10 +198,12 @@ export default function ProviderLimits() {
setCountdown(120);
try {
const conns = await fetchConnections();
const oauthConnections = conns.filter(
(conn) => USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && conn.authType === "oauth"
const usageConnections = conns.filter(
(conn) =>
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) &&
(conn.authType === "oauth" || conn.authType === "apikey")
);
await Promise.all(oauthConnections.map((conn) => fetchQuota(conn.id, conn.provider)));
await Promise.all(usageConnections.map((conn) => fetchQuota(conn.id, conn.provider)));
setLastUpdated(new Date());
} catch (error) {
console.error("Error refreshing all:", error);
@@ -231,13 +256,23 @@ export default function ProviderLimits() {
const filteredConnections = useMemo(
() =>
connections.filter(
(conn) => USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && conn.authType === "oauth"
(conn) =>
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) &&
(conn.authType === "oauth" || conn.authType === "apikey")
),
[connections]
);
const sortedConnections = useMemo(() => {
const priority = { antigravity: 1, github: 2, codex: 3, claude: 4, kiro: 5, "kimi-coding": 6 };
const priority = {
antigravity: 1,
github: 2,
codex: 3,
claude: 4,
kiro: 5,
glm: 6,
"kimi-coding": 7,
};
return [...filteredConnections].sort(
(a, b) => (priority[a.provider] || 9) - (priority[b.provider] || 9)
);
@@ -276,6 +311,50 @@ export default function ProviderLimits() {
);
}, [sortedConnections, tierByConnection, tierFilter]);
const groupedConnections = useMemo(() => {
if (groupBy !== "environment") return null;
const groups = new Map();
for (const conn of visibleConnections) {
const key = conn.group || t("ungrouped");
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(conn);
}
return groups;
}, [groupBy, visibleConnections, t]);
const handleSetGroupBy = (value: "none" | "environment") => {
setGroupBy(value);
localStorage.setItem(LS_GROUP_BY, value);
};
const toggleGroup = (groupName: string) => {
setExpandedGroups((prev) => {
const next = new Set(prev);
next.has(groupName) ? next.delete(groupName) : next.add(groupName);
localStorage.setItem(LS_EXPANDED_GROUPS, JSON.stringify([...next]));
return next;
});
};
// Default inteligente: se não há preferência salva e há connections com grupo, abre em Por Ambiente
useEffect(() => {
if (typeof window === "undefined") return;
const hasSaved = localStorage.getItem(LS_GROUP_BY) !== null;
if (!hasSaved && connections.some((c) => c.group)) {
setGroupBy("environment");
}
}, [connections]);
// Quando entra em modo environment pela primeira vez sem estado salvo, abre todos os grupos
useEffect(() => {
if (groupBy !== "environment" || !groupedConnections) return;
if (expandedGroups.size === 0) {
const allGroups = new Set([...groupedConnections.keys()]);
setExpandedGroups(allGroups);
localStorage.setItem(LS_EXPANDED_GROUPS, JSON.stringify([...allGroups]));
}
}, [groupBy, groupedConnections]); // eslint-disable-line react-hooks/exhaustive-deps
if (initialLoading) {
return (
<div className="flex flex-col gap-4">
@@ -313,8 +392,37 @@ export default function ProviderLimits() {
</div>
<div className="flex items-center gap-2">
{/* Group by toggle */}
<div className="flex rounded-lg border border-white/[0.08] overflow-hidden">
<button
onClick={() => handleSetGroupBy("none")}
className="px-2.5 py-1.5 text-[12px] font-medium cursor-pointer border-none"
style={{
background: groupBy === "none" ? "rgba(255,255,255,0.1)" : "transparent",
color: groupBy === "none" ? "var(--text-main)" : "var(--text-muted)",
}}
>
{t("viewFlat")}
</button>
<button
onClick={() => handleSetGroupBy("environment")}
className="px-2.5 py-1.5 text-[12px] font-medium cursor-pointer border-none border-l border-white/[0.08]"
style={{
background: groupBy === "environment" ? "rgba(255,255,255,0.1)" : "transparent",
color: groupBy === "environment" ? "var(--text-main)" : "var(--text-muted)",
borderLeft: "1px solid rgba(255,255,255,0.08)",
}}
>
{t("viewByEnvironment")}
</button>
</div>
<button
onClick={() => setAutoRefresh((p) => !p)}
onClick={() => {
const next = !autoRefresh;
setAutoRefresh(next);
localStorage.setItem(LS_AUTO_REFRESH, String(next));
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-white/[0.08] bg-transparent cursor-pointer text-text-main text-[13px]"
>
<span
@@ -382,157 +490,196 @@ export default function ProviderLimits() {
<div className="text-center">{t("actions")}</div>
</div>
{visibleConnections.map((conn, idx) => {
const quota = quotaData[conn.id];
const isLoading = loading[conn.id];
const error = errors[conn.id];
const config = PROVIDER_CONFIG[conn.provider] || { label: conn.provider, color: "#666" };
const tierMeta = tierByConnection[conn.id] || normalizePlanTier(null);
{(() => {
const renderRow = (conn, isLast) => {
const quota = quotaData[conn.id];
const isLoading = loading[conn.id];
const error = errors[conn.id];
const config = PROVIDER_CONFIG[conn.provider] || {
label: conn.provider,
color: "#666",
};
const tierMeta = tierByConnection[conn.id] || normalizePlanTier(null);
return (
<div
key={conn.id}
className="items-center px-4 py-3.5 transition-[background] duration-150 hover:bg-white/[0.02]"
style={{
display: "grid",
gridTemplateColumns: "280px 1fr 100px 48px",
borderBottom:
idx < visibleConnections.length - 1 ? "1px solid rgba(255,255,255,0.04)" : "none",
}}
>
{/* Account Info */}
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-8 h-8 rounded-lg flex items-center justify-center overflow-hidden shrink-0">
<Image
src={`/providers/${conn.provider}.png`}
alt={conn.provider}
width={32}
height={32}
className="object-contain"
sizes="32px"
/>
</div>
<div className="min-w-0">
<div className="text-[13px] font-semibold text-text-main truncate">
{conn.name || config.label}
return (
<div
key={conn.id}
className="items-center px-4 py-3.5 transition-[background] duration-150 hover:bg-white/[0.02]"
style={{
display: "grid",
gridTemplateColumns: "280px 1fr 100px 48px",
borderBottom: !isLast ? "1px solid rgba(255,255,255,0.04)" : "none",
}}
>
{/* Account Info */}
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-8 h-8 rounded-lg flex items-center justify-center overflow-hidden shrink-0">
<Image
src={`/providers/${conn.provider}.png`}
alt={conn.provider}
width={32}
height={32}
className="object-contain"
sizes="32px"
/>
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span
title={
quota?.plan
? t("rawPlanWithValue", { plan: quota.plan })
: t("noPlanFromProvider")
}
>
<Badge variant={tierMeta.variant} size="sm" dot>
{tierMeta.label}
</Badge>
</span>
<span className="text-[11px] text-text-muted">{config.label}</span>
<div className="min-w-0">
<div className="text-[13px] font-semibold text-text-main truncate">
{conn.name || config.label}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span
title={
quota?.plan
? t("rawPlanWithValue", { plan: quota.plan })
: t("noPlanFromProvider")
}
>
<Badge variant={tierMeta.variant} size="sm" dot>
{tierMeta.label}
</Badge>
</span>
<span className="text-[11px] text-text-muted">{config.label}</span>
</div>
</div>
</div>
</div>
{/* Quota Bars */}
<div className="flex flex-wrap gap-x-3 gap-y-1.5 pr-3">
{isLoading ? (
<div className="flex items-center gap-1.5 text-text-muted text-xs">
<span className="material-symbols-outlined animate-spin text-[14px]">
progress_activity
</span>
{t("loadingQuotas")}
</div>
) : error ? (
<div className="flex items-center gap-1.5 text-xs text-red-500">
<span className="material-symbols-outlined text-[14px]">error</span>
<span className="overflow-hidden text-ellipsis whitespace-nowrap max-w-[300px]">
{error}
</span>
</div>
) : quota?.message && (!quota.quotas || quota.quotas.length === 0) ? (
<div className="text-xs text-text-muted italic">{quota.message}</div>
) : quota?.quotas?.length > 0 ? (
quota.quotas.map((q, i) => {
const remaining =
q.remainingPercentage !== undefined
? Math.round(q.remainingPercentage)
: calculatePercentage(q.used, q.total);
const colors = getBarColor(remaining);
const cd = formatCountdown(q.resetAt);
const shortName = getShortModelName(q.name);
{/* Quota Bars */}
<div className="flex flex-wrap gap-x-3 gap-y-1.5 pr-3">
{isLoading ? (
<div className="flex items-center gap-1.5 text-text-muted text-xs">
<span className="material-symbols-outlined animate-spin text-[14px]">
progress_activity
</span>
{t("loadingQuotas")}
</div>
) : error ? (
<div className="flex items-center gap-1.5 text-xs text-red-500">
<span className="material-symbols-outlined text-[14px]">error</span>
<span className="overflow-hidden text-ellipsis whitespace-nowrap max-w-[300px]">
{error}
</span>
</div>
) : quota?.message && (!quota.quotas || quota.quotas.length === 0) ? (
<div className="text-xs text-text-muted italic">{quota.message}</div>
) : quota?.quotas?.length > 0 ? (
quota.quotas.map((q, i) => {
const remaining =
q.remainingPercentage !== undefined
? Math.round(q.remainingPercentage)
: calculatePercentage(q.used, q.total);
const colors = getBarColor(remaining);
const cd = formatCountdown(q.resetAt);
const shortName = getShortModelName(q.name);
return (
<div key={i} className="flex items-center gap-1.5 min-w-[200px] shrink-0">
{/* Model label */}
<span
className="text-[11px] font-semibold py-0.5 px-2 rounded whitespace-nowrap min-w-[60px] text-center"
style={{ background: colors.bg, color: colors.text }}
>
{shortName}
</span>
{/* Countdown */}
{cd && (
<span className="text-[10px] text-text-muted whitespace-nowrap">
{cd}
return (
<div key={i} className="flex items-center gap-1.5 min-w-[200px] shrink-0">
{/* Model label */}
<span
className="text-[11px] font-semibold py-0.5 px-2 rounded whitespace-nowrap min-w-[60px] text-center"
style={{ background: colors.bg, color: colors.text }}
>
{shortName}
</span>
)}
{/* Progress bar */}
<div className="flex-1 h-1.5 rounded-sm bg-white/[0.06] min-w-[60px] overflow-hidden">
<div
className="h-full rounded-sm transition-[width] duration-300 ease-out"
style={{
width: `${Math.min(remaining, 100)}%`,
background: colors.bar,
}}
/>
{/* Countdown */}
{cd && (
<span className="text-[10px] text-text-muted whitespace-nowrap">
{cd}
</span>
)}
{/* Progress bar */}
<div className="flex-1 h-1.5 rounded-sm bg-white/[0.06] min-w-[60px] overflow-hidden">
<div
className="h-full rounded-sm transition-[width] duration-300 ease-out"
style={{
width: `${Math.min(remaining, 100)}%`,
background: colors.bar,
}}
/>
</div>
{/* Percentage */}
<span
className="text-[11px] font-semibold min-w-[32px] text-right"
style={{ color: colors.text }}
>
{remaining}%
</span>
</div>
);
})
) : (
<div className="text-xs text-text-muted italic">{t("noQuotaData")}</div>
)}
</div>
{/* Percentage */}
<span
className="text-[11px] font-semibold min-w-[32px] text-right"
style={{ color: colors.text }}
>
{remaining}%
</span>
</div>
);
})
) : (
<div className="text-xs text-text-muted italic">{t("noQuotaData")}</div>
)}
</div>
{/* Last Used */}
<div className="text-center text-[11px] text-text-muted">
{lastUpdated ? (
<span>
{lastUpdated.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</span>
) : (
"-"
)}
</div>
{/* Last Used */}
<div className="text-center text-[11px] text-text-muted">
{lastUpdated ? (
<span>
{lastUpdated.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</span>
) : (
"-"
)}
</div>
{/* Actions */}
<div className="flex justify-center gap-0.5">
<button
onClick={() => refreshProvider(conn.id, conn.provider)}
disabled={isLoading}
title={t("refreshQuota")}
className="p-1 rounded-md border-none bg-transparent cursor-pointer disabled:cursor-not-allowed disabled:opacity-30 opacity-60 hover:opacity-100 flex items-center justify-center transition-opacity duration-150"
>
<span
className={`material-symbols-outlined text-[16px] text-text-muted ${isLoading ? "animate-spin" : ""}`}
{/* Actions */}
<div className="flex justify-center gap-0.5">
<button
onClick={() => refreshProvider(conn.id, conn.provider)}
disabled={isLoading}
title={t("refreshQuota")}
className="p-1 rounded-md border-none bg-transparent cursor-pointer disabled:cursor-not-allowed disabled:opacity-30 opacity-60 hover:opacity-100 flex items-center justify-center transition-opacity duration-150"
>
refresh
<span
className={`material-symbols-outlined text-[16px] text-text-muted ${isLoading ? "animate-spin" : ""}`}
>
refresh
</span>
</button>
</div>
</div>
);
};
if (groupedConnections) {
const entries = [...groupedConnections.entries()];
return entries.map(([groupName, conns]) => (
<div
key={groupName}
className="border border-white/[0.08] rounded-lg overflow-hidden mb-2"
>
<button
onClick={() => toggleGroup(groupName)}
className="w-full flex items-center gap-2 px-4 py-2.5 bg-white/[0.03] hover:bg-white/[0.05] transition-colors text-left border-none cursor-pointer"
>
<span className="material-symbols-outlined text-[16px] text-text-muted">
{expandedGroups.has(groupName) ? "expand_less" : "expand_more"}
</span>
<span className="material-symbols-outlined text-[16px] text-text-muted">
folder
</span>
<span className="text-[12px] font-semibold text-text-main uppercase tracking-wider flex-1">
{groupName}
</span>
<span className="text-[11px] text-text-muted bg-white/[0.06] px-2 py-0.5 rounded-full">
{conns.length}
</span>
</button>
{expandedGroups.has(groupName) && (
<div>{conns.map((conn, idx) => renderRow(conn, idx === conns.length - 1))}</div>
)}
</div>
</div>
));
}
return visibleConnections.map((conn, idx) =>
renderRow(conn, idx === visibleConnections.length - 1)
);
})}
})()}
{visibleConnections.length === 0 && (
<div className="py-6 px-4 text-center text-text-muted text-[13px]">

View File

@@ -604,6 +604,8 @@
"randomDesc": "اختيار عشوائي موحد، ثم الرجوع إلى النماذج المتبقية",
"leastUsedDesc": "يختار النموذج الذي يحتوي على أقل عدد من الطلبات، مع موازنة الحمل مع مرور الوقت",
"costOptimizedDesc": "الطرق إلى النموذج الأرخص تعتمد أولاً على التسعير",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "نماذج",
"autoBalance": "التوازن التلقائي",
"advancedSettings": "الإعدادات المتقدمة",
@@ -1378,6 +1380,8 @@
"email": "البريد الإلكتروني",
"healthCheckMinutes": "فحص الصحة (دقيقة)",
"healthCheckHint": "الفاصل الزمني لتحديث الرمز المميز. 0 = معطل.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "فشل في اختبار الاتصال",
"failed": "فشل",
"leaveBlankKeepCurrentApiKey": "اتركه فارغًا للاحتفاظ بمفتاح API الحالي.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "اختر الحساب الأقل استخدامًا مؤخرًا",
"costOpt": "خيار التكلفة",
"costOptDesc": "تفضل أرخص حساب متاح",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "الحد اللزج",
"stickyLimitDesc": "المكالمات لكل حساب قبل التبديل",
"modelAliases": "الأسماء المستعارة النموذجية",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "الخطة الأولية: {plan}",
"noPlanFromProvider": "لا توجد خطة من المزود",
"noQuotaData": "لا توجد بيانات الحصص",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "لا توجد بيانات الحصص المتاحة",
"noAccountsForTierFilter": "لم يتم العثور على حسابات لمرشح الطبقة",
"tierAll": "الكل",

View File

@@ -604,6 +604,8 @@
"randomDesc": "Единен случаен избор, след което се връща към останалите модели",
"leastUsedDesc": "Избира модела с най-малко заявки, като балансира натоварването във времето",
"costOptimizedDesc": "Първо маршрути към най-евтиния модел въз основа на ценообразуването",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Модели",
"autoBalance": "Автоматичен баланс",
"advancedSettings": "Разширени настройки",
@@ -1378,6 +1380,8 @@
"email": "Имейл",
"healthCheckMinutes": "Проверка на здравето (мин.)",
"healthCheckHint": "Интервал за опресняване на проактивен токен. 0 = забранено.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Неуспешно тестване на връзката",
"failed": "Неуспешно",
"leaveBlankKeepCurrentApiKey": "Оставете празно, за да запазите текущия API ключ.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Изберете най-малко използван акаунт",
"costOpt": "Цена Опт",
"costOptDesc": "Предпочитайте най-евтиния наличен акаунт",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Лепкава граница",
"stickyLimitDesc": "Обаждания на акаунт преди превключване",
"modelAliases": "Псевдоними на модела",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Необработен план: {plan}",
"noPlanFromProvider": "Няма план от доставчика",
"noQuotaData": "Няма данни за квоти",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Няма налични данни за квота",
"noAccountsForTierFilter": "Няма намерени акаунти за филтър за ниво",
"tierAll": "Всички",

View File

@@ -604,6 +604,8 @@
"randomDesc": "Ensartet tilfældig udvælgelse, derefter tilbagevenden til de resterende modeller",
"leastUsedDesc": "Vælger modellen med færrest anmodninger, balancerer belastningen over tid",
"costOptimizedDesc": "Ruter til den billigste model først baseret på priser",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Modeller",
"autoBalance": "Auto-balance",
"advancedSettings": "Avancerede indstillinger",
@@ -1378,6 +1380,8 @@
"email": "E-mail",
"healthCheckMinutes": "Sundhedstjek (min)",
"healthCheckHint": "Proaktivt token-opdateringsinterval. 0 = deaktiveret.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Forbindelsen kunne ikke testes",
"failed": "Mislykkedes",
"leaveBlankKeepCurrentApiKey": "Lad stå tomt for at beholde den aktuelle API-nøgle.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Vælg den mindst brugte konto",
"costOpt": "Omkostningsopt",
"costOptDesc": "Foretrækker den billigste tilgængelige konto",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Sticky Limit",
"stickyLimitDesc": "Opkald pr. konto før skift",
"modelAliases": "Model aliaser",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Rå plan: {plan}",
"noPlanFromProvider": "Ingen plan fra udbyderen",
"noQuotaData": "Ingen kvotedata",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Ingen tilgængelige kvotedata",
"noAccountsForTierFilter": "Der blev ikke fundet nogen konti til niveaufilter",
"tierAll": "Alle",

View File

@@ -604,6 +604,8 @@
"randomDesc": "Einheitliche Zufallsauswahl, dann Rückgriff auf verbleibende Modelle",
"leastUsedDesc": "Wählt das Modell mit den wenigsten Anfragen aus und gleicht die Last über die Zeit aus",
"costOptimizedDesc": "Leitet basierend auf dem Preis zuerst zum günstigsten Modell weiter",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Modelle",
"autoBalance": "Automatischer Ausgleich",
"advancedSettings": "Erweiterte Einstellungen",
@@ -1378,6 +1380,8 @@
"email": "E-Mail",
"healthCheckMinutes": "Gesundheitscheck (Min.)",
"healthCheckHint": "Proaktives Token-Aktualisierungsintervall. 0 = deaktiviert.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Die Verbindung konnte nicht getestet werden",
"failed": "Fehlgeschlagen",
"leaveBlankKeepCurrentApiKey": "Lassen Sie das Feld leer, um den aktuellen API-Schlüssel beizubehalten.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Wählen Sie das zuletzt verwendete Konto aus",
"costOpt": "Kosten Opt",
"costOptDesc": "Bevorzugen Sie das günstigste verfügbare Konto",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Sticky-Limit",
"stickyLimitDesc": "Anrufe pro Konto vor dem Wechsel",
"modelAliases": "Modell-Aliase",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Rohplan: {plan}",
"noPlanFromProvider": "Kein Plan vom Anbieter",
"noQuotaData": "Keine Quotendaten",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Keine Quotendaten verfügbar",
"noAccountsForTierFilter": "Für den Stufenfilter wurden keine Konten gefunden",
"tierAll": "Alle",

View File

@@ -63,6 +63,7 @@
"dashboard": "Dashboard",
"providers": "Providers",
"combos": "Combos",
"autoCombo": "Auto Combo",
"usage": "Usage",
"analytics": "Analytics",
"costs": "Costs",
@@ -235,6 +236,26 @@
"keyCreatedNote": "Copy and store this key now — it won't be shown again.",
"done": "Done",
"savePermissions": "Save Permissions",
"autoResolve": "Auto-Resolve",
"autoResolveDesc": "Auto-resolve ambiguous model names to native provider for this API key.",
"keyActive": "Key Active",
"keyActiveDesc": "Enable or disable this API key. Disabled keys are immediately rejected with 403.",
"accessSchedule": "Access Schedule",
"accessScheduleDesc": "Restrict access to specific hours and days of the week.",
"scheduleFrom": "From",
"scheduleUntil": "Until",
"scheduleDays": "Days",
"scheduleTimezone": "Timezone",
"scheduleTimezoneHint": "Use IANA timezone names, e.g. America/New_York, Europe/Berlin",
"scheduleActive": "Schedule",
"disabled": "Disabled",
"daySun": "Sun",
"dayMon": "Mon",
"dayTue": "Tue",
"dayWed": "Wed",
"dayThu": "Thu",
"dayFri": "Fri",
"daySat": "Sat",
"allowAll": "Allow All",
"restrict": "Restrict",
"allowAllInfo": "This key can access all available models.",
@@ -604,6 +625,8 @@
"randomDesc": "Uniform random selection, then fallback to remaining models",
"leastUsedDesc": "Picks the model with fewest requests, balancing load over time",
"costOptimizedDesc": "Routes to the cheapest model first based on pricing",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Models",
"autoBalance": "Auto-balance",
"advancedSettings": "Advanced Settings",
@@ -1391,6 +1414,8 @@
"email": "Email",
"healthCheckMinutes": "Health Check (min)",
"healthCheckHint": "Proactive token refresh interval. 0 = disabled.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Failed to test connection",
"failed": "Failed",
"leaveBlankKeepCurrentApiKey": "Leave blank to keep the current API key.",
@@ -1562,6 +1587,8 @@
"leastUsedDesc": "Pick least recently used account",
"costOpt": "Cost Opt",
"costOptDesc": "Prefer cheapest available account",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Sticky Limit",
"stickyLimitDesc": "Calls per account before switching",
"modelAliases": "Model Aliases",
@@ -2086,6 +2113,9 @@
"rawPlanWithValue": "Raw plan: {plan}",
"noPlanFromProvider": "No plan from provider",
"noQuotaData": "No quota data",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "No quota data available",
"noAccountsForTierFilter": "No accounts found for tier filter",
"tierAll": "All",

View File

@@ -604,6 +604,8 @@
"randomDesc": "Selección aleatoria uniforme y luego recurrir a los modelos restantes.",
"leastUsedDesc": "Elige el modelo con menos solicitudes y equilibra la carga a lo largo del tiempo.",
"costOptimizedDesc": "Rutas al modelo más barato primero según el precio",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Modelos",
"autoBalance": "Equilibrio automático",
"advancedSettings": "Configuración avanzada",
@@ -1378,6 +1380,8 @@
"email": "Correo electrónico",
"healthCheckMinutes": "Control de salud (min)",
"healthCheckHint": "Intervalo de actualización de token proactivo. 0 = deshabilitado.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "No se pudo probar la conexión",
"failed": "Fallido",
"leaveBlankKeepCurrentApiKey": "Déjelo en blanco para conservar la clave API actual.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Elija la cuenta utilizada menos recientemente",
"costOpt": "Opción de costo",
"costOptDesc": "Prefiere la cuenta más barata disponible",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Límite fijo",
"stickyLimitDesc": "Llamadas por cuenta antes de cambiar",
"modelAliases": "Alias de modelo",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Plan sin formato: {plan}",
"noPlanFromProvider": "Sin plan del proveedor",
"noQuotaData": "Sin datos de cuota",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "No hay datos de cuota disponibles",
"noAccountsForTierFilter": "No se encontraron cuentas para el filtro de niveles",
"tierAll": "Todos",

View File

@@ -604,6 +604,8 @@
"randomDesc": "Yhtenäinen satunnainen valinta, sitten takaisin muihin malleihin",
"leastUsedDesc": "Valitsee mallin, jolla on vähiten pyyntöjä ja tasapainottaa kuormitusta ajan myötä",
"costOptimizedDesc": "Reitit edullisimpaan malliin ensin hinnoittelun perusteella",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Mallit",
"autoBalance": "Automaattinen tasapainotus",
"advancedSettings": "Lisäasetukset",
@@ -1378,6 +1380,8 @@
"email": "Sähköposti",
"healthCheckMinutes": "Terveystarkastus (min)",
"healthCheckHint": "Ennakoiva tunnuksen päivitysväli. 0 = pois käytöstä.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Yhteyden testaus epäonnistui",
"failed": "Epäonnistui",
"leaveBlankKeepCurrentApiKey": "Jätä tyhjäksi, jos haluat säilyttää nykyisen API-avaimen.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Valitse vähiten käytetty tili",
"costOpt": "Kustannusopt",
"costOptDesc": "Valitse halvin saatavilla oleva tili",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Sticky Limit",
"stickyLimitDesc": "Puhelut tilikohtaisesti ennen vaihtamista",
"modelAliases": "Mallin aliakset",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Raakasuunnitelma: {plan}",
"noPlanFromProvider": "Ei suunnitelmaa palveluntarjoajalta",
"noQuotaData": "Ei kiintiötietoja",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Kiintiötietoja ei ole saatavilla",
"noAccountsForTierFilter": "Tasosuodattimelle ei löytynyt tilejä",
"tierAll": "Kaikki",

View File

@@ -604,6 +604,8 @@
"randomDesc": "Sélection aléatoire uniforme, puis retour aux modèles restants",
"leastUsedDesc": "Sélectionne le modèle avec le moins de demandes, en équilibrant la charge au fil du temps",
"costOptimizedDesc": "Itinéraires vers le modèle le moins cher en premier en fonction du prix",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Modèles",
"autoBalance": "Équilibre automatique",
"advancedSettings": "Paramètres avancés",
@@ -1378,6 +1380,8 @@
"email": "Courriel",
"healthCheckMinutes": "Bilan de santé (min)",
"healthCheckHint": "Intervalle dactualisation proactif des jetons. 0 = désactivé.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Échec du test de connexion",
"failed": "Échec",
"leaveBlankKeepCurrentApiKey": "Laissez vide pour conserver la clé API actuelle.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Choisissez le compte le moins récemment utilisé",
"costOpt": "Option de coût",
"costOptDesc": "Préférer le compte disponible le moins cher",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Limite collante",
"stickyLimitDesc": "Appels par compte avant de changer",
"modelAliases": "Alias de modèle",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Plan brut : {plan}",
"noPlanFromProvider": "Aucun plan du fournisseur",
"noQuotaData": "Aucune donnée de quota",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Aucune donnée de quota disponible",
"noAccountsForTierFilter": "Aucun compte trouvé pour le filtre de niveau",
"tierAll": "Tout",

View File

@@ -604,6 +604,8 @@
"randomDesc": "בחירה אקראית אחידה, ואז חזרה לדגמים שנותרו",
"leastUsedDesc": "בוחר את הדגם עם הכי פחות בקשות, מאזן עומס לאורך זמן",
"costOptimizedDesc": "מסלולים לדגם הזול ביותר לפי תמחור",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "דגמים",
"autoBalance": "איזון אוטומטי",
"advancedSettings": "הגדרות מתקדמות",
@@ -1378,6 +1380,8 @@
"email": "דוא\"ל",
"healthCheckMinutes": "בדיקת בריאות (דקה)",
"healthCheckHint": "מרווח רענון אסימון פרואקטיבי. 0 = מושבת.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "בדיקת החיבור נכשלה",
"failed": "נכשל",
"leaveBlankKeepCurrentApiKey": "השאר ריק כדי לשמור את מפתח ה-API הנוכחי.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "בחר חשבון שנעשה בו שימוש לפחות לאחרונה",
"costOpt": "אופטימיזציית עלות",
"costOptDesc": "העדיפו את החשבון הזול ביותר הזמין",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "גבול דביק",
"stickyLimitDesc": "שיחות לכל חשבון לפני המעבר",
"modelAliases": "כינויי דגם",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "תוכנית גולמית: {plan}",
"noPlanFromProvider": "אין תוכנית מספק",
"noQuotaData": "אין נתוני מכסה",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "אין נתוני מכסה זמינים",
"noAccountsForTierFilter": "לא נמצאו חשבונות עבור מסנן שכבות",
"tierAll": "הכל",

View File

@@ -604,6 +604,8 @@
"randomDesc": "Egységes véletlenszerű kiválasztás, majd visszaállás a többi modellhez",
"leastUsedDesc": "A legkevesebb kéréssel rendelkező modellt választja, idővel kiegyensúlyozva a terhelést",
"costOptimizedDesc": "Először a legolcsóbb modellhez vezet az árak alapján",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Modellek",
"autoBalance": "Automatikus egyensúly",
"advancedSettings": "Speciális beállítások",
@@ -1378,6 +1380,8 @@
"email": "E-mail",
"healthCheckMinutes": "állapotfelmérés (perc)",
"healthCheckHint": "Proaktív token frissítési időköz. 0 = letiltva.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Nem sikerült tesztelni a kapcsolatot",
"failed": "Sikertelen",
"leaveBlankKeepCurrentApiKey": "Hagyja üresen az aktuális API-kulcs megtartásához.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Válassza ki a legutóbb használt fiókot",
"costOpt": "Költségopt",
"costOptDesc": "A legolcsóbb elérhető fiók előnyben részesítése",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Ragadós határ",
"stickyLimitDesc": "Hívások fiókonként váltás előtt",
"modelAliases": "Modell álnevek",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Nyers terv: {plan}",
"noPlanFromProvider": "Nincs terv a szolgáltatótól",
"noQuotaData": "Nincsenek kvótaadatok",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Nem állnak rendelkezésre kvótaadatok",
"noAccountsForTierFilter": "Nem található fiók a rétegszűrőhöz",
"tierAll": "Mind",

View File

@@ -604,6 +604,8 @@
"randomDesc": "Pemilihan acak seragam, lalu kembali ke model lainnya",
"leastUsedDesc": "Memilih model dengan permintaan paling sedikit, menyeimbangkan beban dari waktu ke waktu",
"costOptimizedDesc": "Rutekan ke model termurah terlebih dahulu berdasarkan harga",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Model",
"autoBalance": "Keseimbangan otomatis",
"advancedSettings": "Pengaturan Lanjutan",
@@ -1378,6 +1380,8 @@
"email": "Surel",
"healthCheckMinutes": "Pemeriksaan Kesehatan (menit)",
"healthCheckHint": "Interval penyegaran token proaktif. 0 = dinonaktifkan.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Gagal menguji koneksi",
"failed": "Gagal",
"leaveBlankKeepCurrentApiKey": "Biarkan kosong untuk mempertahankan kunci API saat ini.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Pilih akun yang paling jarang digunakan",
"costOpt": "Pilihan Biaya",
"costOptDesc": "Lebih suka akun termurah yang tersedia",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Batas Lengket",
"stickyLimitDesc": "Panggilan per akun sebelum beralih",
"modelAliases": "Alias Model",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Paket mentah: {plan}",
"noPlanFromProvider": "Tidak ada rencana dari penyedia",
"noQuotaData": "Tidak ada data kuota",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Tidak ada data kuota yang tersedia",
"noAccountsForTierFilter": "Tidak ditemukan akun untuk filter tingkat",
"tierAll": "Semua",

View File

@@ -604,6 +604,8 @@
"randomDesc": "समान यादृच्छिक चयन, फिर शेष मॉडलों पर वापस लौटना",
"leastUsedDesc": "समय के साथ लोड को संतुलित करते हुए, सबसे कम अनुरोधों वाला मॉडल चुनता है",
"costOptimizedDesc": "मूल्य निर्धारण के आधार पर सबसे पहले सबसे सस्ते मॉडल पर रूट करें",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "मॉडल",
"autoBalance": "स्वत: संतुलन",
"advancedSettings": "उन्नत सेटिंग्स",
@@ -1378,6 +1380,8 @@
"email": "ईमेल",
"healthCheckMinutes": "स्वास्थ्य जांच (न्यूनतम)",
"healthCheckHint": "प्रोएक्टिव टोकन ताज़ा अंतराल। 0 = अक्षम.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "कनेक्शन का परीक्षण करने में विफल",
"failed": "असफल",
"leaveBlankKeepCurrentApiKey": "वर्तमान एपीआई कुंजी रखने के लिए खाली छोड़ दें।",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "कम से कम हाल ही में उपयोग किया गया खाता चुनें",
"costOpt": "लागत विकल्प",
"costOptDesc": "सबसे सस्ते उपलब्ध खाते को प्राथमिकता दें",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "चिपचिपी सीमा",
"stickyLimitDesc": "स्विच करने से पहले प्रति खाता कॉल",
"modelAliases": "मॉडल उपनाम",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "कच्ची योजना: {plan}",
"noPlanFromProvider": "प्रदाता की ओर से कोई योजना नहीं",
"noQuotaData": "कोई कोटा डेटा नहीं",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "कोई कोटा डेटा उपलब्ध नहीं है",
"noAccountsForTierFilter": "टियर फ़िल्टर के लिए कोई खाता नहीं मिला",
"tierAll": "सब",

View File

@@ -604,6 +604,8 @@
"randomDesc": "Selezione casuale uniforme, quindi fallback sui modelli rimanenti",
"leastUsedDesc": "Sceglie il modello con meno richieste, bilanciando il carico nel tempo",
"costOptimizedDesc": "Percorsi prima verso il modello più economico in base al prezzo",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Modelli",
"autoBalance": "Bilanciamento automatico",
"advancedSettings": "Impostazioni avanzate",
@@ -1378,6 +1380,8 @@
"email": "E-mail",
"healthCheckMinutes": "Controllo dello stato (min)",
"healthCheckHint": "Intervallo di aggiornamento del token proattivo. 0 = disabilitato.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Impossibile testare la connessione",
"failed": "Fallito",
"leaveBlankKeepCurrentApiKey": "Lascia vuoto per mantenere la chiave API corrente.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Scegli l'account utilizzato meno di recente",
"costOpt": "Opzione costo",
"costOptDesc": "Preferisci il conto più economico disponibile",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Limite appiccicoso",
"stickyLimitDesc": "Chiamate per account prima del cambio",
"modelAliases": "Alias del modello",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Piano grezzo: {plan}",
"noPlanFromProvider": "Nessun piano dal fornitore",
"noQuotaData": "Nessun dato sulle quote",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Nessun dato sulle quote disponibile",
"noAccountsForTierFilter": "Nessun account trovato per il filtro del livello",
"tierAll": "Tutto",

View File

@@ -604,6 +604,8 @@
"randomDesc": "均一なランダム選択、その後残りのモデルへのフォールバック",
"leastUsedDesc": "リクエストが最も少ないモデルを選択し、時間の経過とともに負荷のバランスをとります",
"costOptimizedDesc": "価格に基づいて最初に最も安価なモデルにルーティングします",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "モデル",
"autoBalance": "オートバランス",
"advancedSettings": "詳細設定",
@@ -1378,6 +1380,8 @@
"email": "電子メール",
"healthCheckMinutes": "ヘルスチェック (分)",
"healthCheckHint": "プロアクティブなトークンの更新間隔。 0 = 無効。",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "接続のテストに失敗しました",
"failed": "失敗しました",
"leaveBlankKeepCurrentApiKey": "現在の API キーを保持するには、空白のままにします。",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "最も最近使用されていないアカウントを選択する",
"costOpt": "コストオプション",
"costOptDesc": "利用可能な最も安いアカウントを優先する",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "スティッキー制限",
"stickyLimitDesc": "切り替える前のアカウントごとの通話数",
"modelAliases": "モデルのエイリアス",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "未加工のプラン: {plan}",
"noPlanFromProvider": "プロバイダーからのプランなし",
"noQuotaData": "クォータ データがありません",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "利用可能なクォータ データがありません",
"noAccountsForTierFilter": "層フィルターのアカウントが見つかりませんでした",
"tierAll": "すべて",

View File

@@ -604,6 +604,8 @@
"randomDesc": "균일한 무작위 선택 후 나머지 모델로 대체",
"leastUsedDesc": "시간이 지남에 따라 로드 밸런싱을 통해 요청이 가장 적은 모델을 선택합니다.",
"costOptimizedDesc": "가격을 기준으로 가장 저렴한 모델로 먼저 라우팅",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "모델",
"autoBalance": "자동 균형",
"advancedSettings": "고급 설정",
@@ -1378,6 +1380,8 @@
"email": "이메일",
"healthCheckMinutes": "상태 점검(분)",
"healthCheckHint": "사전 토큰 새로 고침 간격. 0 = 비활성화됨.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "연결을 테스트하지 못했습니다.",
"failed": "실패",
"leaveBlankKeepCurrentApiKey": "현재 API 키를 유지하려면 비워 두세요.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "최근에 가장 적게 사용한 계정 선택",
"costOpt": "비용 선택",
"costOptDesc": "가장 저렴한 계정을 선호합니다",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "고정 한도",
"stickyLimitDesc": "전환 전 계정당 통화",
"modelAliases": "모델 별칭",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "기본 계획: {plan}",
"noPlanFromProvider": "공급자의 계획 없음",
"noQuotaData": "할당량 데이터 없음",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "사용 가능한 할당량 데이터가 없습니다.",
"noAccountsForTierFilter": "등급 필터에 대한 계정을 찾을 수 없습니다.",
"tierAll": "모두",

View File

@@ -604,6 +604,8 @@
"randomDesc": "Pemilihan rawak seragam, kemudian sandarkan kepada model yang tinggal",
"leastUsedDesc": "Memilih model dengan permintaan paling sedikit, mengimbangi beban dari semasa ke semasa",
"costOptimizedDesc": "Laluan ke model termurah dahulu berdasarkan harga",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "model",
"autoBalance": "Imbangan automatik",
"advancedSettings": "Tetapan Lanjutan",
@@ -1378,6 +1380,8 @@
"email": "E-mel",
"healthCheckMinutes": "Pemeriksaan Kesihatan (min)",
"healthCheckHint": "Selang penyegaran token proaktif. 0 = kurang upaya.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Gagal menguji sambungan",
"failed": "gagal",
"leaveBlankKeepCurrentApiKey": "Biarkan kosong untuk mengekalkan kunci API semasa.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Pilih akaun yang paling kurang digunakan baru-baru ini",
"costOpt": "Pilihan Kos",
"costOptDesc": "Pilih akaun termurah yang tersedia",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Had Melekit",
"stickyLimitDesc": "Panggilan setiap akaun sebelum bertukar",
"modelAliases": "Alias Model",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Pelan mentah: {plan}",
"noPlanFromProvider": "Tiada pelan daripada pembekal",
"noQuotaData": "Tiada data kuota",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Tiada data kuota tersedia",
"noAccountsForTierFilter": "Tiada akaun ditemui untuk penapis peringkat",
"tierAll": "Semua",

View File

@@ -604,6 +604,8 @@
"randomDesc": "Uniforme willekeurige selectie en vervolgens terugvallen op de resterende modellen",
"leastUsedDesc": "Kiest het model met de minste verzoeken, waarbij de belasting in de loop van de tijd wordt verdeeld",
"costOptimizedDesc": "Routes eerst naar het goedkoopste model op basis van prijzen",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Modellen",
"autoBalance": "Automatische balans",
"advancedSettings": "Geavanceerde instellingen",
@@ -1378,6 +1380,8 @@
"email": "E-mail",
"healthCheckMinutes": "Gezondheidscontrole (min)",
"healthCheckHint": "Proactief tokenvernieuwingsinterval. 0 = uitgeschakeld.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Kan de verbinding niet testen",
"failed": "Mislukt",
"leaveBlankKeepCurrentApiKey": "Laat dit leeg om de huidige API-sleutel te behouden.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Kies het minst recent gebruikte account",
"costOpt": "Kosten opt",
"costOptDesc": "Geef de voorkeur aan het goedkoopste beschikbare account",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Kleverige limiet",
"stickyLimitDesc": "Gesprekken per account voordat u overstapt",
"modelAliases": "Modelaliassen",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Ruw plan: {plan}",
"noPlanFromProvider": "Geen abonnement van aanbieder",
"noQuotaData": "Geen quotagegevens",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Geen quotagegevens beschikbaar",
"noAccountsForTierFilter": "Er zijn geen accounts gevonden voor niveaufilter",
"tierAll": "Allemaal",

View File

@@ -604,6 +604,8 @@
"randomDesc": "Ensartet tilfeldig valg, deretter fallback til gjenværende modeller",
"leastUsedDesc": "Velger modellen med færrest forespørsler, og balanserer belastningen over tid",
"costOptimizedDesc": "Ruter til den billigste modellen først basert på priser",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Modeller",
"autoBalance": "Autobalanse",
"advancedSettings": "Avanserte innstillinger",
@@ -1378,6 +1380,8 @@
"email": "E-post",
"healthCheckMinutes": "Helsesjekk (min)",
"healthCheckHint": "Proaktivt token-oppdateringsintervall. 0 = deaktivert.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Kunne ikke teste tilkoblingen",
"failed": "Mislyktes",
"leaveBlankKeepCurrentApiKey": "La stå tomt for å beholde gjeldende API-nøkkel.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Velg minst nylig brukte konto",
"costOpt": "Kostnad Opt",
"costOptDesc": "Foretrekker den billigste tilgjengelige kontoen",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Sticky Limit",
"stickyLimitDesc": "Anrop per konto før bytte",
"modelAliases": "Modellaliaser",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Rå plan: {plan}",
"noPlanFromProvider": "Ingen plan fra leverandøren",
"noQuotaData": "Ingen kvotedata",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Ingen kvotedata tilgjengelig",
"noAccountsForTierFilter": "Fant ingen kontoer for nivåfilter",
"tierAll": "Alle",

View File

@@ -604,6 +604,8 @@
"randomDesc": "Uniform random selection, pagkatapos ay fallback sa natitirang mga modelo",
"leastUsedDesc": "Pinipili ang modelo na may kaunting mga kahilingan, binabalanse ang pagkarga sa paglipas ng panahon",
"costOptimizedDesc": "Mga ruta muna sa pinakamurang modelo batay sa pagpepresyo",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Mga modelo",
"autoBalance": "Awtomatikong balanse",
"advancedSettings": "Mga Advanced na Setting",
@@ -1378,6 +1380,8 @@
"email": "Email",
"healthCheckMinutes": "Health Check (min)",
"healthCheckHint": "Proactive token refresh interval. 0 = hindi pinagana.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Nabigong subukan ang koneksyon",
"failed": "Nabigo",
"leaveBlankKeepCurrentApiKey": "Iwanang blangko upang mapanatili ang kasalukuyang API key.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Pumili ng hindi bababa sa kamakailang ginamit na account",
"costOpt": "Cost Opt",
"costOptDesc": "Mas gusto ang pinakamurang available na account",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Malagkit na Limitasyon",
"stickyLimitDesc": "Mga tawag sa bawat account bago lumipat",
"modelAliases": "Mga Alyas ng Modelo",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Raw plan: {plan}",
"noPlanFromProvider": "Walang plano mula sa provider",
"noQuotaData": "Walang data ng quota",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Walang available na data ng quota",
"noAccountsForTierFilter": "Walang nahanap na account para sa tier filter",
"tierAll": "Lahat",

View File

@@ -604,6 +604,8 @@
"randomDesc": "Jednolity wybór losowy, a następnie powrót do pozostałych modeli",
"leastUsedDesc": "Wybiera model z najmniejszą liczbą żądań, równoważąc obciążenie w czasie",
"costOptimizedDesc": "Najpierw wybiera najtańszy model na podstawie ceny",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Modele",
"autoBalance": "Automatyczne balansowanie",
"advancedSettings": "Ustawienia zaawansowane",
@@ -1378,6 +1380,8 @@
"email": "E-mail",
"healthCheckMinutes": "Kontrola stanu (min)",
"healthCheckHint": "Proaktywny interwał odświeżania tokenu. 0 = wyłączone.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Nie udało się przetestować połączenia",
"failed": "Nie udało się",
"leaveBlankKeepCurrentApiKey": "Pozostaw puste, aby zachować bieżący klucz API.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Wybierz ostatnio używane konto",
"costOpt": "Opcja kosztowa",
"costOptDesc": "Preferuj najtańsze dostępne konto",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Lepki limit",
"stickyLimitDesc": "Połączenia na konto przed zmianą",
"modelAliases": "Aliasy modeli",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Surowy plan: {plan}",
"noPlanFromProvider": "Brak planu od dostawcy",
"noQuotaData": "Brak danych dotyczących kwot",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Brak dostępnych danych dotyczących kwot",
"noAccountsForTierFilter": "Nie znaleziono kont dla filtra poziomów",
"tierAll": "Wszystko",

View File

@@ -63,6 +63,7 @@
"dashboard": "Painel",
"providers": "Provedores",
"combos": "Combos",
"autoCombo": "Auto Combo",
"usage": "Uso",
"analytics": "Análises",
"costs": "Custos",
@@ -235,6 +236,26 @@
"keyCreatedNote": "Copie e armazene esta chave agora — ela não será mostrada novamente.",
"done": "Pronto",
"savePermissions": "Salvar Permissões",
"autoResolve": "Auto-Resolve",
"autoResolveDesc": "Resolve automaticamente nomes ambíguos de modelo para o provedor nativo desta API key.",
"keyActive": "Chave Ativa",
"keyActiveDesc": "Ativa ou desativa esta API key. Chaves desativadas são bloqueadas com 403.",
"accessSchedule": "Horário de Acesso",
"accessScheduleDesc": "Restrinja o acesso a horários e dias da semana específicos.",
"scheduleFrom": "Das",
"scheduleUntil": "Até",
"scheduleDays": "Dias",
"scheduleTimezone": "Fuso Horário",
"scheduleTimezoneHint": "Use nomes IANA, ex: America/Sao_Paulo",
"scheduleActive": "Agenda",
"disabled": "Desativada",
"daySun": "Dom",
"dayMon": "Seg",
"dayTue": "Ter",
"dayWed": "Qua",
"dayThu": "Qui",
"dayFri": "Sex",
"daySat": "Sáb",
"allowAll": "Permitir Tudo",
"restrict": "Restringir",
"allowAllInfo": "Esta chave pode acessar todos os modelos disponíveis.",
@@ -604,6 +625,8 @@
"randomDesc": "Seleção aleatória uniforme, depois fallback para modelos restantes",
"leastUsedDesc": "Escolhe o modelo com menos requisições, equilibrando carga ao longo do tempo",
"costOptimizedDesc": "Roteia para o modelo mais barato primeiro baseado em preços",
"strictRandom": "Aleatório Estrito",
"strictRandomDesc": "Baralho embaralhado — usa cada modelo uma vez antes de reembaralhar",
"models": "Modelos",
"autoBalance": "Auto-balancear",
"advancedSettings": "Configurações Avançadas",
@@ -652,6 +675,11 @@
"when": "Redução de custo é a prioridade principal.",
"avoid": "A base de preços está ausente ou desatualizada.",
"example": "Jobs em lote ou segundo plano focados em menor custo."
},
"strict-random": {
"when": "Você quer distribuição perfeitamente uniforme — cada modelo é usado uma vez antes de repetir.",
"avoid": "Os modelos têm qualidade ou latência muito diferentes e a ordem importa.",
"example": "Múltiplas contas do mesmo modelo para distribuir uso de forma equilibrada."
}
},
"advancedHelp": {
@@ -705,46 +733,53 @@
"recommendationsApplied": "Recommendations applied to this combo.",
"strategyRecommendations": {
"priority": {
"title": "Fail-safe baseline",
"description": "Use one primary model and keep fallback chain short and reliable.",
"tip1": "Put your most reliable model first.",
"tip2": "Keep 1-2 backup models with similar quality.",
"tip3": "Use safe retries to absorb transient provider failures."
"title": "Fail-safe básico",
"description": "Use um modelo principal e mantenha a cadeia de fallback curta e confiável.",
"tip1": "Coloque o modelo mais confiável em primeiro.",
"tip2": "Mantenha 1-2 modelos de backup com qualidade similar.",
"tip3": "Use retries seguros para absorver falhas transitórias do provedor."
},
"weighted": {
"title": "Controlled traffic split",
"description": "Great for canary rollouts and gradual migration between models.",
"tip1": "Start with conservative split like 90/10.",
"tip2": "Keep the total at 100% and auto-balance after changes.",
"tip3": "Monitor success and latency before increasing canary weight."
"title": "Divisão controlada de tráfego",
"description": "Ótimo para rollouts canário e migração gradual entre modelos.",
"tip1": "Comece com divisão conservadora tipo 90/10.",
"tip2": "Mantenha o total em 100% e rebalanceie após mudanças.",
"tip3": "Monitore sucesso e latência antes de aumentar o peso canário."
},
"round-robin": {
"title": "Predictable load sharing",
"description": "Best when models are equivalent and you need smooth distribution.",
"tip1": "Use at least 2 models.",
"tip2": "Set concurrency limits to avoid burst overload.",
"tip3": "Use queue timeout to fail fast under saturation."
"title": "Distribuição previsível de carga",
"description": "Melhor quando os modelos são equivalentes e você precisa de distribuição uniforme.",
"tip1": "Use pelo menos 2 modelos.",
"tip2": "Configure limites de concorrência para evitar sobrecarga.",
"tip3": "Use timeout de fila para falhar rápido sob saturação."
},
"random": {
"title": "Quick spread with low setup",
"description": "Use when you need simple distribution without strict guarantees.",
"tip1": "Use models with similar latency profiles.",
"tip2": "Keep retries enabled to absorb random misses.",
"tip3": "Prefer this for experimentation, not strict SLAs."
"title": "Distribuição rápida com baixa configuração",
"description": "Use quando precisar de distribuição simples sem garantias rígidas.",
"tip1": "Use modelos com perfis de latência semelhantes.",
"tip2": "Mantenha retries habilitados para absorver falhas aleatórias.",
"tip3": "Prefira para experimentação, não para SLAs rígidos."
},
"least-used": {
"title": "Adaptive balancing",
"description": "Routes to less-used models to reduce hotspots over time.",
"tip1": "Works better under continuous traffic.",
"tip2": "Combine with health checks for safer balancing.",
"tip3": "Track per-model usage to validate distribution gains."
"title": "Balanceamento adaptativo",
"description": "Roteia para modelos menos usados para reduzir hotspots ao longo do tempo.",
"tip1": "Funciona melhor sob tráfego contínuo.",
"tip2": "Combine com health checks para balanceamento mais seguro.",
"tip3": "Acompanhe uso por modelo para validar ganhos na distribuição."
},
"cost-optimized": {
"title": "Budget-first routing",
"description": "Routes to lower-cost models when pricing metadata is available.",
"tip1": "Ensure pricing coverage for all selected models.",
"tip2": "Keep a quality fallback for hard prompts.",
"tip3": "Use for batch/background jobs where cost is the main KPI."
"title": "Roteamento por orçamento",
"description": "Roteia para modelos mais baratos quando metadados de preço estão disponíveis.",
"tip1": "Garanta cobertura de preços para todos os modelos selecionados.",
"tip2": "Mantenha um fallback de qualidade para prompts difíceis.",
"tip3": "Use para jobs em lote/background onde custo é o KPI principal."
},
"strict-random": {
"title": "Distribuição estritamente uniforme",
"description": "Cada modelo é usado exatamente uma vez antes de reembaralhar o baralho.",
"tip1": "Ideal para múltiplas contas do mesmo modelo.",
"tip2": "Garante que nenhuma conta é repetida antes de todas serem usadas.",
"tip3": "Combine com health checks para pular contas indisponíveis sem quebrar o ciclo."
}
},
"templateFreeStack": "Free Stack ($0)",
@@ -1378,6 +1413,8 @@
"email": "Email",
"healthCheckMinutes": "Health Check (min)",
"healthCheckHint": "Intervalo proativo de renovação de token. 0 = desativado.",
"groupLabel": "Ambiente",
"groupPlaceholder": "ex: eKaizen, Pessoal",
"failedTestConnection": "Falha ao testar conexão",
"failed": "Falhou",
"leaveBlankKeepCurrentApiKey": "Deixe em branco para manter a chave de API atual.",
@@ -1548,6 +1585,8 @@
"leastUsedDesc": "Escolher a conta usada menos recentemente",
"costOpt": "Custo Otimizado",
"costOptDesc": "Preferir conta mais barata disponível",
"strictRandom": "Aleatório Estrito",
"strictRandomDesc": "Baralho embaralhado — usa cada conta uma vez antes de reembaralhar",
"stickyLimit": "Limite Fixo",
"stickyLimitDesc": "Chamadas por conta antes de trocar",
"modelAliases": "Aliases de Modelo",
@@ -2072,6 +2111,9 @@
"rawPlanWithValue": "Plano bruto: {plan}",
"noPlanFromProvider": "Sem plano do provedor",
"noQuotaData": "Sem dados de cota",
"ungrouped": "Sem grupo",
"viewFlat": "Lista",
"viewByEnvironment": "Por Ambiente",
"noQuotaDataAvailable": "Nenhum dado de cota disponível",
"noAccountsForTierFilter": "Nenhuma conta encontrada para o filtro de plano",
"tierAll": "Todos",

View File

@@ -604,6 +604,8 @@
"randomDesc": "Seleção aleatória uniforme e, em seguida, retorno aos modelos restantes",
"leastUsedDesc": "Escolhe o modelo com menos solicitações, equilibrando a carga ao longo do tempo",
"costOptimizedDesc": "Rotas para o modelo mais barato primeiro com base no preço",
"strictRandom": "Aleatório Estrito",
"strictRandomDesc": "Baralho embaralhado — usa cada modelo uma vez antes de reembaralhar",
"models": "Modelos",
"autoBalance": "Equilíbrio automático",
"advancedSettings": "Configurações avançadas",
@@ -1390,6 +1392,8 @@
"email": "E-mail",
"healthCheckMinutes": "Verificação de integridade (min)",
"healthCheckHint": "Intervalo de atualização de token proativo. 0 = desabilitado.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Falha ao testar a conexão",
"failed": "Falha",
"leaveBlankKeepCurrentApiKey": "Deixe em branco para manter a chave API atual.",
@@ -1555,6 +1559,8 @@
"leastUsedDesc": "Escolha a conta usada menos recentemente",
"costOpt": "Opção de custo",
"costOptDesc": "Prefira a conta mais barata disponível",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Limite pegajoso",
"stickyLimitDesc": "Chamadas por conta antes de mudar",
"modelAliases": "Aliases de modelo",
@@ -2084,6 +2090,9 @@
"rawPlanWithValue": "Plano bruto: {plan}",
"noPlanFromProvider": "Nenhum plano do provedor",
"noQuotaData": "Sem dados de cota",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Não há dados de cota disponíveis",
"noAccountsForTierFilter": "Nenhuma conta encontrada para filtro de nível",
"tierAll": "Todos",

View File

@@ -604,6 +604,8 @@
"randomDesc": "Selectare aleatorie uniformă, apoi revenire la modelele rămase",
"leastUsedDesc": "Alege modelul cu cele mai puține solicitări, echilibrând sarcina în timp",
"costOptimizedDesc": "Rute către cel mai ieftin model mai întâi pe baza prețului",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Modele",
"autoBalance": "Auto-echilibrare",
"advancedSettings": "Setări avansate",
@@ -1378,6 +1380,8 @@
"email": "E-mail",
"healthCheckMinutes": "Verificare de sănătate (min)",
"healthCheckHint": "Interval proactiv de reîmprospătare a simbolului. 0 = dezactivat.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Nu s-a testat conexiunea",
"failed": "A eșuat",
"leaveBlankKeepCurrentApiKey": "Lăsați necompletat pentru a păstra cheia API curentă.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Alegeți contul cel mai puțin utilizat recent",
"costOpt": "Cost Opt",
"costOptDesc": "Prefer cel mai ieftin cont disponibil",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Limită lipicioasă",
"stickyLimitDesc": "Apeluri pe cont înainte de a comuta",
"modelAliases": "Aliasuri de model",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Plan brut: {plan}",
"noPlanFromProvider": "Niciun plan de la furnizor",
"noQuotaData": "Fără date de cotă",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Nu sunt disponibile date privind cotele",
"noAccountsForTierFilter": "Nu s-au găsit conturi pentru filtrul de nivel",
"tierAll": "Toate",

View File

@@ -604,6 +604,8 @@
"randomDesc": "Равномерный случайный выбор, затем возврат к оставшимся моделям",
"leastUsedDesc": "Выбирает модель с наименьшим количеством запросов, балансируя нагрузку с течением времени.",
"costOptimizedDesc": "Маршруты к самой дешевой модели в первую очередь на основе цены",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Модели",
"autoBalance": "Автобаланс",
"advancedSettings": "Расширенные настройки",
@@ -1378,6 +1380,8 @@
"email": "электронная почта",
"healthCheckMinutes": "Проверка здоровья (мин)",
"healthCheckHint": "Интервал обновления упреждающего токена. 0 = отключено.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Не удалось проверить соединение",
"failed": "Не удалось",
"leaveBlankKeepCurrentApiKey": "Оставьте пустым, чтобы сохранить текущий ключ API.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Выберите наименее использованную учетную запись",
"costOpt": "Опция стоимости",
"costOptDesc": "Предпочитаю самый дешевый доступный аккаунт",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Липкий лимит",
"stickyLimitDesc": "Звонки на аккаунт до переключения",
"modelAliases": "Псевдонимы моделей",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Необработанный план: {plan}",
"noPlanFromProvider": "Нет плана от провайдера",
"noQuotaData": "Нет данных о квотах",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Нет данных о квотах",
"noAccountsForTierFilter": "Аккаунты для фильтра уровня не найдены",
"tierAll": "Все",

View File

@@ -604,6 +604,8 @@
"randomDesc": "Jednotný náhodný výber, potom návrat k zostávajúcim modelom",
"leastUsedDesc": "Vyberie model s najmenším počtom požiadaviek, čím vyrovná zaťaženie v priebehu času",
"costOptimizedDesc": "Najprv sa presmeruje na najlacnejší model na základe ceny",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Modelky",
"autoBalance": "Automatické vyváženie",
"advancedSettings": "Rozšírené nastavenia",
@@ -1378,6 +1380,8 @@
"email": "Email",
"healthCheckMinutes": "Kontrola stavu (min)",
"healthCheckHint": "Interval proaktívneho obnovenia tokenu. 0 = vypnuté.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Nepodarilo sa otestovať pripojenie",
"failed": "Nepodarilo sa",
"leaveBlankKeepCurrentApiKey": "Ak chcete zachovať aktuálny kľúč API, nechajte pole prázdne.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Vyberte najmenej nedávno používaný účet",
"costOpt": "Opt",
"costOptDesc": "Uprednostnite najlacnejší dostupný účet",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Sticky Limit",
"stickyLimitDesc": "Hovory na účet pred prepnutím",
"modelAliases": "Aliasy modelov",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Nespracovaný plán: {plan}",
"noPlanFromProvider": "Žiadny plán od poskytovateľa",
"noQuotaData": "Žiadne údaje o kvóte",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Nie sú k dispozícii žiadne údaje o kvóte",
"noAccountsForTierFilter": "Pre filter úrovne sa nenašli žiadne účty",
"tierAll": "Všetky",

View File

@@ -604,6 +604,8 @@
"randomDesc": "Enhetligt slumpmässigt urval, sedan fallback till återstående modeller",
"leastUsedDesc": "Väljer modellen med minst förfrågningar, balanserar belastningen över tiden",
"costOptimizedDesc": "Rutter till den billigaste modellen först baserat på prissättning",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Modeller",
"autoBalance": "Automatisk balansering",
"advancedSettings": "Avancerade inställningar",
@@ -1378,6 +1380,8 @@
"email": "E-post",
"healthCheckMinutes": "Hälsokontroll (min)",
"healthCheckHint": "Proaktivt uppdateringsintervall för token. 0 = inaktiverad.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Det gick inte att testa anslutningen",
"failed": "Misslyckades",
"leaveBlankKeepCurrentApiKey": "Lämna tomt för att behålla den aktuella API-nyckeln.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Välj minst senast använda konto",
"costOpt": "Kostnad Opt",
"costOptDesc": "Föredrar billigaste tillgängliga konto",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Sticky Limit",
"stickyLimitDesc": "Samtal per konto innan byte",
"modelAliases": "Modellalias",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Rå plan: {plan}",
"noPlanFromProvider": "Ingen plan från leverantören",
"noQuotaData": "Inga kvotdata",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Inga kvotdata tillgängliga",
"noAccountsForTierFilter": "Inga konton hittades för nivåfilter",
"tierAll": "Alla",

View File

@@ -604,6 +604,8 @@
"randomDesc": "การเลือกแบบสุ่มแบบสม่ำเสมอ จากนั้นจึงย้อนกลับไปยังโมเดลที่เหลือ",
"leastUsedDesc": "เลือกโมเดลที่มีคำขอน้อยที่สุด โดยจะปรับสมดุลการโหลดเมื่อเวลาผ่านไป",
"costOptimizedDesc": "กำหนดเส้นทางไปยังรุ่นที่ถูกที่สุดก่อนตามราคา",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "โมเดล",
"autoBalance": "ปรับสมดุลอัตโนมัติ",
"advancedSettings": "การตั้งค่าขั้นสูง",
@@ -1378,6 +1380,8 @@
"email": "อีเมล",
"healthCheckMinutes": "ตรวจสุขภาพ (ขั้นต่ำ)",
"healthCheckHint": "ช่วงเวลาการรีเฟรชโทเค็นเชิงรุก 0 = ปิดการใช้งาน",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "ทดสอบการเชื่อมต่อไม่สำเร็จ",
"failed": "ล้มเหลว",
"leaveBlankKeepCurrentApiKey": "เว้นว่างไว้เพื่อเก็บคีย์ API ปัจจุบันไว้",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "เลือกบัญชีที่ใช้ล่าสุดน้อยที่สุด",
"costOpt": "การเลือกใช้ต้นทุน",
"costOptDesc": "ต้องการบัญชีที่ถูกที่สุด",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "ขีด จำกัด เหนียว",
"stickyLimitDesc": "โทรต่อบัญชีก่อนที่จะเปลี่ยน",
"modelAliases": "นามแฝงของโมเดล",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "แผนดิบ: {plan}",
"noPlanFromProvider": "ไม่มีแผนจากผู้ให้บริการ",
"noQuotaData": "ไม่มีข้อมูลโควต้า",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "ไม่มีข้อมูลโควต้า",
"noAccountsForTierFilter": "ไม่พบบัญชีสำหรับตัวกรองระดับ",
"tierAll": "ทั้งหมด",

View File

@@ -604,6 +604,8 @@
"randomDesc": "Рівномірний випадковий вибір, а потім повернення до інших моделей",
"leastUsedDesc": "Вибирає модель із найменшою кількістю запитів, балансуючи навантаження за часом",
"costOptimizedDesc": "Маршрути до найдешевшої моделі на основі ціни",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Моделі",
"autoBalance": "Автобаланс",
"advancedSettings": "Розширені налаштування",
@@ -1378,6 +1380,8 @@
"email": "Електронна пошта",
"healthCheckMinutes": "Перевірка стану (хв.)",
"healthCheckHint": "Проактивний інтервал оновлення маркера. 0 = вимкнено.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Не вдалося перевірити з’єднання",
"failed": "Не вдалося",
"leaveBlankKeepCurrentApiKey": "Залиште поле порожнім, щоб зберегти поточний ключ API.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Виберіть нещодавно використовуваний обліковий запис",
"costOpt": "Вартість Opt",
"costOptDesc": "Віддайте перевагу найдешевшому доступному обліковому запису",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Sticky Limit",
"stickyLimitDesc": "Дзвінки на обліковий запис перед переходом",
"modelAliases": "Псевдоніми моделі",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Необроблений план: {plan}",
"noPlanFromProvider": "Без плану від провайдера",
"noQuotaData": "Немає даних про квоти",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Немає даних про квоти",
"noAccountsForTierFilter": "Не знайдено облікових записів для фільтра рівня",
"tierAll": "всі",

View File

@@ -604,6 +604,8 @@
"randomDesc": "Lựa chọn ngẫu nhiên thống nhất, sau đó dự phòng cho các mô hình còn lại",
"leastUsedDesc": "Chọn mô hình có ít yêu cầu nhất, cân bằng tải theo thời gian",
"costOptimizedDesc": "Hướng tới mô hình rẻ nhất trước tiên dựa trên giá cả",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Người mẫu",
"autoBalance": "Tự động cân bằng",
"advancedSettings": "Cài đặt nâng cao",
@@ -1378,6 +1380,8 @@
"email": "Email",
"healthCheckMinutes": "Kiểm tra sức khỏe (phút)",
"healthCheckHint": "Khoảng thời gian làm mới mã thông báo chủ động. 0 = bị vô hiệu hóa.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Không thể kiểm tra kết nối",
"failed": "thất bại",
"leaveBlankKeepCurrentApiKey": "Để trống để giữ khóa API hiện tại.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Chọn tài khoản ít được sử dụng gần đây nhất",
"costOpt": "Lựa chọn chi phí",
"costOptDesc": "Ưu tiên tài khoản có sẵn rẻ nhất",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Giới hạn dính",
"stickyLimitDesc": "Cuộc gọi trên mỗi tài khoản trước khi chuyển đổi",
"modelAliases": "Bí danh mẫu",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Gói thô: {plan}",
"noPlanFromProvider": "Không có kế hoạch từ nhà cung cấp",
"noQuotaData": "Không có dữ liệu hạn ngạch",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Không có sẵn dữ liệu hạn ngạch",
"noAccountsForTierFilter": "Không tìm thấy tài khoản nào cho bộ lọc cấp độ",
"tierAll": "Tất cả",

View File

@@ -604,6 +604,8 @@
"randomDesc": "统一随机选择,然后回退到剩余模型",
"leastUsedDesc": "选择请求最少的模型,随着时间的推移平衡负载",
"costOptimizedDesc": "首先根据定价路由至最便宜的型号",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "型号",
"autoBalance": "自动平衡",
"advancedSettings": "高级设置",
@@ -1378,6 +1380,8 @@
"email": "电子邮件",
"healthCheckMinutes": "健康检查(分钟)",
"healthCheckHint": "主动令牌刷新间隔。 0 = 禁用。",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "测试连接失败",
"failed": "失败",
"leaveBlankKeepCurrentApiKey": "留空以保留当前的 API 密钥。",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "选择最近最少使用的帐户",
"costOpt": "成本选择",
"costOptDesc": "更喜欢最便宜的可用帐户",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "粘性限制",
"stickyLimitDesc": "切换前每个账户的通话次数",
"modelAliases": "模型别名",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "原始计划:{plan}",
"noPlanFromProvider": "提供商没有计划",
"noQuotaData": "无配额数据",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "无可用配额数据",
"noAccountsForTierFilter": "未找到适用于层过滤器的帐户",
"tierAll": "全部",

View File

@@ -20,12 +20,24 @@ interface CacheEntry<TValue> {
value: TValue;
}
export interface AccessSchedule {
enabled: boolean;
from: string;
until: string;
days: number[];
tz: string;
}
interface ApiKeyMetadata {
id: string;
name: string;
machineId: string | null;
allowedModels: string[];
allowedConnections: string[];
noLog: boolean;
autoResolve: boolean;
isActive: boolean;
accessSchedule: AccessSchedule | null;
}
interface ApiKeyRow extends JsonRecord {
@@ -36,8 +48,16 @@ interface ApiKeyRow extends JsonRecord {
machineId?: unknown;
allowed_models?: unknown;
allowedModels?: unknown;
allowed_connections?: unknown;
allowedConnections?: unknown;
no_log?: unknown;
noLog?: unknown;
auto_resolve?: unknown;
autoResolve?: unknown;
is_active?: unknown;
isActive?: unknown;
access_schedule?: unknown;
accessSchedule?: unknown;
}
interface StatementLike<TRow = unknown> {
@@ -63,7 +83,11 @@ interface ApiKeysStatements {
interface ApiKeyView extends JsonRecord {
id?: string;
allowedModels: string[];
allowedConnections: string[];
noLog: boolean;
autoResolve: boolean;
isActive: boolean;
accessSchedule: AccessSchedule | null;
}
// LRU cache for API key validation (valid keys only)
@@ -147,6 +171,22 @@ function ensureApiKeysColumns(db: ApiKeysDbLike) {
db.exec("ALTER TABLE api_keys ADD COLUMN no_log INTEGER NOT NULL DEFAULT 0");
console.log("[DB] Added api_keys.no_log column");
}
if (!columnNames.has("allowed_connections")) {
db.exec("ALTER TABLE api_keys ADD COLUMN allowed_connections TEXT");
console.log("[DB] Added api_keys.allowed_connections column");
}
if (!columnNames.has("auto_resolve")) {
db.exec("ALTER TABLE api_keys ADD COLUMN auto_resolve INTEGER NOT NULL DEFAULT 0");
console.log("[DB] Added api_keys.auto_resolve column");
}
if (!columnNames.has("is_active")) {
db.exec("ALTER TABLE api_keys ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1");
console.log("[DB] Added api_keys.is_active column");
}
if (!columnNames.has("access_schedule")) {
db.exec("ALTER TABLE api_keys ADD COLUMN access_schedule TEXT");
console.log("[DB] Added api_keys.access_schedule column");
}
_schemaChecked = true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -172,7 +212,7 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements {
_stmtGetKeyById = db.prepare<ApiKeyRow>("SELECT * FROM api_keys WHERE id = ?");
_stmtValidateKey = db.prepare<JsonRecord>("SELECT 1 FROM api_keys WHERE key = ?");
_stmtGetKeyMetadata = db.prepare<ApiKeyRow>(
"SELECT id, name, machine_id, allowed_models, no_log FROM api_keys WHERE key = ?"
"SELECT id, name, machine_id, allowed_models, allowed_connections, no_log, auto_resolve, is_active, access_schedule FROM api_keys WHERE key = ?"
);
_stmtInsertKey = db.prepare(
"INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
@@ -208,7 +248,11 @@ export async function getApiKeys() {
return rows.map((row) => {
const camelRow = toRecord(rowToCamel(row)) as ApiKeyView;
camelRow.allowedModels = parseAllowedModels(camelRow.allowedModels);
camelRow.allowedConnections = parseAllowedConnections(camelRow.allowedConnections);
camelRow.noLog = parseNoLog(camelRow.noLog);
camelRow.autoResolve = parseAutoResolve(camelRow.autoResolve);
camelRow.isActive = parseIsActive(camelRow.isActive);
camelRow.accessSchedule = parseAccessSchedule(camelRow.accessSchedule);
if (typeof camelRow.id === "string" && camelRow.id.length > 0) {
setNoLog(camelRow.id, camelRow.noLog === true);
}
@@ -223,7 +267,11 @@ export async function getApiKeyById(id: string) {
if (!row) return null;
const camelRow = toRecord(rowToCamel(row)) as ApiKeyView;
camelRow.allowedModels = parseAllowedModels(camelRow.allowedModels);
camelRow.allowedConnections = parseAllowedConnections(camelRow.allowedConnections);
camelRow.noLog = parseNoLog(camelRow.noLog);
camelRow.autoResolve = parseAutoResolve(camelRow.autoResolve);
camelRow.isActive = parseIsActive(camelRow.isActive);
camelRow.accessSchedule = parseAccessSchedule(camelRow.accessSchedule);
if (typeof camelRow.id === "string" && camelRow.id.length > 0) {
setNoLog(camelRow.id, camelRow.noLog === true);
}
@@ -251,6 +299,63 @@ function parseNoLog(value: unknown): boolean {
return value === true || value === 1 || value === "1";
}
function parseAutoResolve(value: unknown): boolean {
return value === true || value === 1 || value === "1";
}
function parseIsActive(value: unknown): boolean {
// DEFAULT 1 — active unless explicitly set to 0
if (value === 0 || value === "0" || value === false) return false;
return true;
}
function parseAccessSchedule(value: unknown): AccessSchedule | null {
if (!value || typeof value !== "string" || value.trim() === "") return null;
try {
const parsed: unknown = JSON.parse(value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
const obj = parsed as Record<string, unknown>;
if (
typeof obj["enabled"] !== "boolean" ||
typeof obj["from"] !== "string" ||
typeof obj["until"] !== "string" ||
!Array.isArray(obj["days"]) ||
typeof obj["tz"] !== "string"
) {
return null;
}
const days = (obj["days"] as unknown[]).filter(
(d): d is number => typeof d === "number" && Number.isInteger(d) && d >= 0 && d <= 6
);
return {
enabled: obj["enabled"],
from: obj["from"],
until: obj["until"],
days,
tz: obj["tz"],
};
} catch {
return null;
}
}
/**
* Helper function to safely parse allowed_connections JSON
*/
function parseAllowedConnections(value: unknown): string[] {
if (!value || typeof value !== "string" || value.trim() === "") {
return [];
}
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed)
? parsed.filter((entry): entry is string => typeof entry === "string")
: [];
} catch {
return [];
}
}
export async function createApiKey(name: string, machineId: string) {
if (!machineId) {
throw new Error("machineId is required");
@@ -268,6 +373,7 @@ export async function createApiKey(name: string, machineId: string) {
key: result.key,
machineId: machineId,
allowedModels: [], // Empty array means all models allowed
allowedConnections: [], // Empty array means all connections allowed
noLog: false,
createdAt: now,
};
@@ -290,7 +396,17 @@ export async function createApiKey(name: string, machineId: string) {
export async function updateApiKeyPermissions(
id: string,
update: string[] | { allowedModels?: string[]; noLog?: boolean }
update:
| string[]
| {
name?: string;
allowedModels?: string[];
allowedConnections?: string[];
noLog?: boolean;
autoResolve?: boolean;
isActive?: boolean;
accessSchedule?: AccessSchedule | null;
}
) {
const db = getDbInstance() as ApiKeysDbLike;
getPreparedStatements(db);
@@ -299,16 +415,43 @@ export async function updateApiKeyPermissions(
Array.isArray(update) || update === undefined
? { allowedModels: update || [] }
: {
name: update.name,
allowedModels: update.allowedModels,
allowedConnections: update.allowedConnections,
noLog: update.noLog,
autoResolve: update.autoResolve,
isActive: update.isActive,
accessSchedule: update.accessSchedule,
};
if (normalized.allowedModels === undefined && normalized.noLog === undefined) {
if (
normalized.name === undefined &&
normalized.allowedModels === undefined &&
normalized.allowedConnections === undefined &&
normalized.noLog === undefined &&
normalized.autoResolve === undefined &&
normalized.isActive === undefined &&
normalized.accessSchedule === undefined
) {
return false;
}
const updates: string[] = [];
const params: { id: string; allowedModels?: string; noLog?: number } = { id };
const params: {
id: string;
name?: string;
allowedModels?: string;
allowedConnections?: string;
noLog?: number;
autoResolve?: number;
isActive?: number;
accessSchedule?: string | null;
} = { id };
if (normalized.name !== undefined) {
updates.push("name = @name");
params.name = normalized.name;
}
if (normalized.allowedModels !== undefined) {
// Empty array means all models are allowed
@@ -316,11 +459,33 @@ export async function updateApiKeyPermissions(
params.allowedModels = JSON.stringify(normalized.allowedModels || []);
}
if (normalized.allowedConnections !== undefined) {
// Empty array means all connections are allowed
updates.push("allowed_connections = @allowedConnections");
params.allowedConnections = JSON.stringify(normalized.allowedConnections || []);
}
if (normalized.noLog !== undefined) {
updates.push("no_log = @noLog");
params.noLog = normalized.noLog ? 1 : 0;
}
if (normalized.autoResolve !== undefined) {
updates.push("auto_resolve = @autoResolve");
params.autoResolve = normalized.autoResolve ? 1 : 0;
}
if (normalized.isActive !== undefined) {
updates.push("is_active = @isActive");
params.isActive = normalized.isActive ? 1 : 0;
}
if (normalized.accessSchedule !== undefined) {
updates.push("access_schedule = @accessSchedule");
params.accessSchedule =
normalized.accessSchedule !== null ? JSON.stringify(normalized.accessSchedule) : null;
}
const result = db.prepare(`UPDATE api_keys SET ${updates.join(", ")} WHERE id = @id`).run(params);
if (result.changes === 0) return false;
@@ -414,7 +579,13 @@ export async function getApiKeyMetadata(
name: metadataName,
machineId: metadataMachineId,
allowedModels: parseAllowedModels(record.allowed_models ?? record.allowedModels),
allowedConnections: parseAllowedConnections(
record.allowed_connections ?? record.allowedConnections
),
noLog: parseNoLog(record.no_log ?? record.noLog),
autoResolve: parseAutoResolve(record.auto_resolve ?? record.autoResolve),
isActive: parseIsActive(record.is_active ?? record.isActive),
accessSchedule: parseAccessSchedule(record.access_schedule ?? record.accessSchedule),
};
if (!metadata.id) {

View File

@@ -185,6 +185,7 @@ export async function createProviderConnection(data: JsonRecord) {
"errorCode",
"consecutiveUseCount",
"rateLimitProtection",
"group",
];
for (const field of optionalFields) {
if (data[field] !== undefined && data[field] !== null) {
@@ -217,7 +218,7 @@ function _insertConnectionRow(db: DbLike, conn: JsonRecord) {
rate_limited_until, health_check_interval, last_health_check_at,
last_tested, api_key, id_token, provider_specific_data,
expires_in, display_name, global_priority, default_model,
token_type, consecutive_use_count, rate_limit_protection, last_used_at, created_at, updated_at
token_type, consecutive_use_count, rate_limit_protection, last_used_at, "group", created_at, updated_at
) VALUES (
@id, @provider, @authType, @name, @email, @priority, @isActive,
@accessToken, @refreshToken, @expiresAt, @tokenExpiresAt,
@@ -226,7 +227,7 @@ function _insertConnectionRow(db: DbLike, conn: JsonRecord) {
@rateLimitedUntil, @healthCheckInterval, @lastHealthCheckAt,
@lastTested, @apiKey, @idToken, @providerSpecificData,
@expiresIn, @displayName, @globalPriority, @defaultModel,
@tokenType, @consecutiveUseCount, @rateLimitProtection, @lastUsedAt, @createdAt, @updatedAt
@tokenType, @consecutiveUseCount, @rateLimitProtection, @lastUsedAt, @group, @createdAt, @updatedAt
)
`
).run({
@@ -268,6 +269,7 @@ function _insertConnectionRow(db: DbLike, conn: JsonRecord) {
rateLimitProtection:
conn.rateLimitProtection === true || conn.rateLimitProtection === 1 ? 1 : 0,
lastUsedAt: conn.lastUsedAt || null,
group: conn.group || null,
createdAt: conn.createdAt,
updatedAt: conn.updatedAt,
});
@@ -292,6 +294,7 @@ function _updateConnectionRow(db: DbLike, id: string, data: JsonRecord) {
consecutive_use_count = @consecutiveUseCount,
rate_limit_protection = @rateLimitProtection,
last_used_at = @lastUsedAt,
"group" = @group,
updated_at = @updatedAt
WHERE id = @id
`
@@ -334,6 +337,7 @@ function _updateConnectionRow(db: DbLike, id: string, data: JsonRecord) {
rateLimitProtection:
data.rateLimitProtection === true || data.rateLimitProtection === 1 ? 1 : 0,
lastUsedAt: data.lastUsedAt || null,
group: data.group || null,
updatedAt: now,
});
}
@@ -407,6 +411,16 @@ export async function cleanupProviderConnections() {
return 0;
}
export async function getDistinctGroups(): Promise<string[]> {
const db = getDbInstance() as unknown as DbLike;
const rows = db
.prepare(
'SELECT DISTINCT "group" FROM provider_connections WHERE "group" IS NOT NULL ORDER BY "group"'
)
.all() as Array<{ group?: string }>;
return rows.map((r) => String(r.group ?? "")).filter(Boolean);
}
// ──────────────── Provider Nodes ────────────────
export async function getProviderNodes(filter: JsonRecord = {}) {

View File

@@ -15,14 +15,92 @@ 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";
interface AccessSchedule {
enabled: boolean;
from: string;
until: string;
days: number[];
tz: string;
}
/** Metadata stored for an API key in the local database. */
export interface ApiKeyMetadata {
id: string;
name?: string;
allowedModels?: string[];
allowedConnections?: string[];
noLog?: boolean;
autoResolve?: boolean;
budget?: number;
usedBudget?: number;
isActive?: boolean;
accessSchedule?: AccessSchedule | null;
}
/**
* Returns true if the current time (in the schedule's timezone) is within
* the configured window.
* Supports overnight ranges (e.g. 22:00 until 06:00).
*/
function isWithinSchedule(schedule: AccessSchedule): boolean {
if (!schedule.enabled) return true;
const now = new Date();
// Convert current UTC time to the configured timezone
let localTimeStr: string;
try {
localTimeStr = new Intl.DateTimeFormat("en-US", {
timeZone: schedule.tz,
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(now);
} catch {
// Invalid timezone — fail open (don't block)
return true;
}
// Intl may return "24:xx" instead of "00:xx" — normalize
const normalizedTime = localTimeStr.replace(/^24:/, "00:");
const [localHour, localMin] = normalizedTime.split(":").map(Number);
const localMinutes = localHour * 60 + localMin;
// Determine current weekday in the configured timezone
let localDayStr: string;
try {
localDayStr = new Intl.DateTimeFormat("en-US", {
timeZone: schedule.tz,
weekday: "short",
}).format(now);
} catch {
return true;
}
const dayMap: Record<string, number> = {
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
};
const localDay = dayMap[localDayStr] ?? now.getDay();
if (!schedule.days.includes(localDay)) return false;
const [fromHour, fromMin] = schedule.from.split(":").map(Number);
const [untilHour, untilMin] = schedule.until.split(":").map(Number);
const fromMinutes = fromHour * 60 + fromMin;
const untilMinutes = untilHour * 60 + untilMin;
// Overnight window (e.g. 22:00 → 06:00)
if (untilMinutes < fromMinutes) {
return localMinutes >= fromMinutes || localMinutes < untilMinutes;
}
return localMinutes >= fromMinutes && localMinutes < untilMinutes;
}
export interface ApiKeyPolicyResult {
@@ -82,7 +160,31 @@ export async function enforceApiKeyPolicy(
return { apiKey, apiKeyInfo: null, rejection: null };
}
// ── Check 1: Model restriction ──
// ── Check 1: is_active — hard block regardless of schedule ──
if (apiKeyInfo.isActive === false) {
return {
apiKey,
apiKeyInfo,
rejection: errorResponse(HTTP_STATUS.FORBIDDEN, "This API key is disabled"),
};
}
// ── Check 2: access_schedule — time-based access window ──
if (apiKeyInfo.accessSchedule && apiKeyInfo.accessSchedule.enabled) {
if (!isWithinSchedule(apiKeyInfo.accessSchedule)) {
const { from, until, tz } = apiKeyInfo.accessSchedule;
return {
apiKey,
apiKeyInfo,
rejection: errorResponse(
HTTP_STATUS.FORBIDDEN,
`Access denied outside allowed hours (${from}${until} ${tz})`
),
};
}
}
// ── Check 3: Model restriction ──
if (modelStr && apiKeyInfo.allowedModels && apiKeyInfo.allowedModels.length > 0) {
const allowed = await isModelAllowedForKey(apiKey, modelStr);
if (!allowed) {
@@ -97,7 +199,7 @@ export async function enforceApiKeyPolicy(
}
}
// ── Check 2: Budget limit ──
// ── Check 4: Budget limit ──
if (apiKeyInfo.id) {
try {
const budgetOk = checkBudget(apiKeyInfo.id);

View File

@@ -0,0 +1,145 @@
/**
* Shared shuffle deck utility — Fisher-Yates shuffle with anti-repeat guarantee.
* Used by both combo model rotation and credential connection selection.
*
* Thread-safe: each deck namespace gets its own promise-based mutex to prevent
* race conditions when concurrent requests hit the same deck simultaneously.
*/
// ─── Types ──────────────────────────────────────────────────────────────────
interface ShuffleDeck {
order: readonly string[];
index: number;
idsKey: string;
}
// ─── State ──────────────────────────────────────────────────────────────────
const decks = new Map<string, ShuffleDeck>();
const mutexes = new Map<string, Promise<void>>();
// ─── Fisher-Yates Shuffle ───────────────────────────────────────────────────
/**
* Fisher-Yates shuffle — returns a new shuffled copy of the array.
* Does NOT mutate the original.
*/
export function fisherYatesShuffle<T>(arr: readonly T[]): T[] {
const result = [...arr];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const tmp = result[i];
result[i] = result[j];
result[j] = tmp;
}
return result;
}
// ─── Deck Operations ────────────────────────────────────────────────────────
/**
* Get next item from a namespaced shuffle deck.
*
* - Namespace isolates decks (e.g. "combo:myCombo" vs "conn:openai").
* - Uses each item exactly once per cycle before reshuffling.
* - Guarantees the last item of a cycle is not the first of the next.
* - Resets deck when the item set changes (detected via sorted key).
* - Serialized per namespace via promise-based mutex (no race conditions).
*/
export async function getNextFromDeck(
namespace: string,
itemIds: readonly string[]
): Promise<string> {
if (itemIds.length === 0) return "";
if (itemIds.length === 1) return itemIds[0];
// Acquire per-namespace mutex
const currentMutex = mutexes.get(namespace) ?? Promise.resolve();
let resolveMutex: (() => void) | undefined;
mutexes.set(
namespace,
new Promise<void>((resolve) => {
resolveMutex = resolve;
})
);
try {
await currentMutex;
const idsKey = [...itemIds].sort().join(",");
const existing = decks.get(namespace);
// If deck exists, same item set, and not exhausted — advance
if (existing && existing.idsKey === idsKey && existing.index < existing.order.length) {
const id = existing.order[existing.index];
decks.set(namespace, { ...existing, index: existing.index + 1 });
return id;
}
// Reshuffle — ensure last of previous cycle is not first of new cycle
const lastUsedId =
existing && existing.idsKey === idsKey && existing.order.length > 0
? existing.order[existing.order.length - 1]
: undefined;
const newOrder = fisherYatesShuffle(itemIds);
if (lastUsedId !== undefined && newOrder[0] === lastUsedId && newOrder.length > 1) {
const swapIdx = 1 + Math.floor(Math.random() * (newOrder.length - 1));
const tmp = newOrder[0];
newOrder[0] = newOrder[swapIdx];
newOrder[swapIdx] = tmp;
}
decks.set(namespace, { order: newOrder, index: 1, idsKey });
return newOrder[0];
} finally {
resolveMutex?.();
}
}
// ─── Sync version (backwards compat for non-concurrent callers) ─────────────
/**
* Synchronous version of getNextFromDeck — NO mutex protection.
* Only safe when the caller already holds a mutex (e.g. auth.ts getProviderCredentials).
*/
export function getNextFromDeckSync(namespace: string, itemIds: readonly string[]): string {
if (itemIds.length === 0) return "";
if (itemIds.length === 1) return itemIds[0];
const idsKey = [...itemIds].sort().join(",");
const existing = decks.get(namespace);
if (existing && existing.idsKey === idsKey && existing.index < existing.order.length) {
const id = existing.order[existing.index];
decks.set(namespace, { ...existing, index: existing.index + 1 });
return id;
}
const lastUsedId =
existing && existing.idsKey === idsKey && existing.order.length > 0
? existing.order[existing.order.length - 1]
: undefined;
const newOrder = fisherYatesShuffle(itemIds);
if (lastUsedId !== undefined && newOrder[0] === lastUsedId && newOrder.length > 1) {
const swapIdx = 1 + Math.floor(Math.random() * (newOrder.length - 1));
const tmp = newOrder[0];
newOrder[0] = newOrder[swapIdx];
newOrder[swapIdx] = tmp;
}
decks.set(namespace, { order: newOrder, index: 1, idsKey });
return newOrder[0];
}
// ─── Test helpers ───────────────────────────────────────────────────────────
/** Reset all decks — for testing only. */
export function _resetAllDecks(): void {
decks.clear();
mutexes.clear();
}

View File

@@ -51,6 +51,7 @@ const comboStrategySchema = z.enum([
"random",
"least-used",
"cost-optimized",
"strict-random",
]);
const comboRuntimeConfigSchema = z
@@ -77,6 +78,7 @@ export const createComboSchema = z.object({
models: z.array(comboModelEntry).optional().default([]),
strategy: comboStrategySchema.optional().default("priority"),
config: comboConfigSchema,
allowedProviders: z.array(z.string().max(200)).optional(),
});
// ──── Auto-Combo Schemas ────
@@ -125,7 +127,15 @@ export const updateSettingsSchema = z.object({
hideHealthCheckLogs: z.boolean().optional(),
// Routing settings (#134)
fallbackStrategy: z
.enum(["fill-first", "round-robin", "p2c", "random", "least-used", "cost-optimized"])
.enum([
"fill-first",
"round-robin",
"p2c",
"random",
"least-used",
"cost-optimized",
"strict-random",
])
.optional(),
wildcardAliases: z.array(z.object({ pattern: z.string(), target: z.string() })).optional(),
stickyRoundRobinLimit: z.number().int().min(0).max(1000).optional(),
@@ -676,6 +686,7 @@ export const updateComboSchema = z
strategy: comboStrategySchema.optional(),
config: comboRuntimeConfigSchema.optional(),
isActive: z.boolean().optional(),
allowedProviders: z.array(z.string().max(200)).optional(),
})
.superRefine((value, ctx) => {
if (
@@ -683,7 +694,8 @@ export const updateComboSchema = z
value.models === undefined &&
value.strategy === undefined &&
value.config === undefined &&
value.isActive === undefined
value.isActive === undefined &&
value.allowedProviders === undefined
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -706,13 +718,34 @@ export const evalRunSuiteSchema = z.object({
outputs: z.record(z.string(), z.string()),
});
const accessScheduleSchema = z.object({
enabled: z.boolean(),
from: z.string().regex(/^\d{2}:\d{2}$/, "Time must be in HH:MM format"),
until: z.string().regex(/^\d{2}:\d{2}$/, "Time must be in HH:MM format"),
days: z.array(z.number().int().min(0).max(6)).min(1, "At least one day is required").max(7),
tz: z.string().min(1).max(100),
});
export const updateKeyPermissionsSchema = z
.object({
name: z.string().trim().min(1).max(200).optional(),
allowedModels: z.array(z.string().trim().min(1)).max(1000).optional(),
allowedConnections: z.array(z.string().uuid()).max(100).optional(),
noLog: z.boolean().optional(),
autoResolve: z.boolean().optional(),
isActive: z.boolean().optional(),
accessSchedule: z.union([accessScheduleSchema, z.null()]).optional(),
})
.superRefine((value, ctx) => {
if (value.allowedModels === undefined && value.noLog === undefined) {
if (
value.name === undefined &&
value.allowedModels === undefined &&
value.allowedConnections === undefined &&
value.noLog === undefined &&
value.autoResolve === undefined &&
value.isActive === undefined &&
value.accessSchedule === undefined
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "No valid fields to update",
@@ -770,6 +803,7 @@ export const updateProviderConnectionSchema = z
rateLimitedUntil: z.union([z.string(), z.null()]).optional(),
lastTested: z.union([z.string(), z.null()]).optional(),
healthCheckInterval: z.coerce.number().int().min(0).optional(),
group: z.union([z.string().max(100), z.null()]).optional(),
// Partial patch of per-connection provider-specific settings (e.g. quota toggles)
providerSpecificData: z.record(z.string(), z.unknown()).optional(),
})

View File

@@ -207,7 +207,11 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
return false;
}
const creds = await getProviderCredentials(provider);
const creds = await getProviderCredentials(
provider,
null,
apiKeyInfo?.allowedConnections ?? null
);
if (!creds || creds.allRateLimited) return false;
return true;
};
@@ -291,7 +295,11 @@ async function handleSingleModelChat(
let lastStatus = null;
while (true) {
const credentials = await getProviderCredentials(provider, excludeConnectionId);
const credentials = await getProviderCredentials(
provider,
excludeConnectionId,
apiKeyInfo?.allowedConnections ?? null
);
if (!credentials || credentials.allRateLimited) {
if (lastStatus === 429 || lastStatus === 503) {

View File

@@ -15,6 +15,7 @@ import {
lockModel,
} from "@omniroute/open-sse/services/accountFallback.ts";
import * as log from "../utils/logger";
import { fisherYatesShuffle, getNextFromDeckSync } from "@/shared/utils/shuffleDeck";
type JsonRecord = Record<string, unknown>;
@@ -87,6 +88,11 @@ let selectionMutex = Promise.resolve();
// unavailable in parallel, which was the root cause of cascading 502 lockouts.
const markMutexes = new Map<string, Promise<void>>();
// Strict-Random shuffle deck moved to src/shared/utils/shuffleDeck.ts
// auth.ts uses getNextFromDeckSync (already inside selectionMutex).
// Re-export for backwards compat with existing test imports.
export { fisherYatesShuffle, getNextFromDeckSync as getNextFromDeck };
/**
* Get provider credentials from localDb
* Filters out unavailable accounts and returns the selected account based on strategy
@@ -324,6 +330,11 @@ export async function getProviderCredentials(
(a, b) => (a.priority || 999) - (b.priority || 999)
);
connection = sorted[0];
} else if (strategy === "strict-random") {
// Strict Random: shuffle deck — uses each account once before reshuffling
const ids = orderedConnections.map((c) => c.id);
const selectedId = getNextFromDeckSync(`conn:${provider}`, ids);
connection = orderedConnections.find((c) => c.id === selectedId) || orderedConnections[0];
} else {
// Default: fill-first (already sorted by priority in getProviderConnections)
connection = orderedConnections[0];

View File

@@ -10,7 +10,8 @@ export interface Settings {
| "p2c"
| "random"
| "least-used"
| "cost-optimized";
| "cost-optimized"
| "strict-random";
stickyRoundRobinLimit: number;
jwtSecret?: string;
}

View File

@@ -0,0 +1,313 @@
/**
* Unit tests for API key policy helpers:
* - parseIsActive (via parseAccessSchedule indirect coverage)
* - isWithinSchedule logic (tested directly via a re-export or by mocking Date)
*
* Because isWithinSchedule is module-private, we test it through observable
* behavior: feeding real Date overrides via globalThis.Date stubbing.
*
* Strategy:
* 1. Extract the schedule-check logic into a standalone helper exported only
* for tests — OR test it end-to-end through enforceApiKeyPolicy.
* 2. Since enforceApiKeyPolicy needs a full DB + HTTP Request, we isolate
* isWithinSchedule by copying its logic into this test file and verifying
* the exact same algorithm.
*/
import test from "node:test";
import assert from "node:assert/strict";
// ─── Replicate the isWithinSchedule logic for pure unit testing ───────────────
//
// This mirrors the implementation in apiKeyPolicy.ts exactly.
// If the production code changes, update this copy too.
/**
* @param {{ enabled: boolean; from: string; until: string; days: number[]; tz: string }} schedule
* @param {Date} now — injectable "current time"
* @returns {boolean}
*/
function isWithinSchedule(schedule, now = new Date()) {
if (!schedule.enabled) return true;
let localTimeStr;
try {
localTimeStr = new Intl.DateTimeFormat("en-US", {
timeZone: schedule.tz,
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(now);
} catch {
return true;
}
const normalizedTime = localTimeStr.replace(/^24:/, "00:");
const [localHour, localMin] = normalizedTime.split(":").map(Number);
const localMinutes = localHour * 60 + localMin;
let localDayStr;
try {
localDayStr = new Intl.DateTimeFormat("en-US", {
timeZone: schedule.tz,
weekday: "short",
}).format(now);
} catch {
return true;
}
const dayMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
const localDay = dayMap[localDayStr] ?? now.getDay();
if (!schedule.days.includes(localDay)) return false;
const [fromHour, fromMin] = schedule.from.split(":").map(Number);
const [untilHour, untilMin] = schedule.until.split(":").map(Number);
const fromMinutes = fromHour * 60 + fromMin;
const untilMinutes = untilHour * 60 + untilMin;
if (untilMinutes < fromMinutes) {
return localMinutes >= fromMinutes || localMinutes < untilMinutes;
}
return localMinutes >= fromMinutes && localMinutes < untilMinutes;
}
// ─── parseIsActive helper (mirrors production code) ──────────────────────────
function parseIsActive(value) {
if (value === 0 || value === "0" || value === false) return false;
return true;
}
// ─── parseAccessSchedule helper (mirrors production code) ────────────────────
function parseAccessSchedule(value) {
if (!value || typeof value !== "string" || value.trim() === "") return null;
try {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
if (
typeof parsed.enabled !== "boolean" ||
typeof parsed.from !== "string" ||
typeof parsed.until !== "string" ||
!Array.isArray(parsed.days) ||
typeof parsed.tz !== "string"
)
return null;
const days = parsed.days.filter(
(d) => typeof d === "number" && Number.isInteger(d) && d >= 0 && d <= 6
);
return { enabled: parsed.enabled, from: parsed.from, until: parsed.until, days, tz: parsed.tz };
} catch {
return null;
}
}
// ─── parseIsActive ────────────────────────────────────────────────────────────
test("parseIsActive: undefined → true (default active)", () => {
assert.equal(parseIsActive(undefined), true);
});
test("parseIsActive: null → true", () => {
assert.equal(parseIsActive(null), true);
});
test("parseIsActive: 1 → true", () => {
assert.equal(parseIsActive(1), true);
});
test("parseIsActive: true → true", () => {
assert.equal(parseIsActive(true), true);
});
test("parseIsActive: 0 → false", () => {
assert.equal(parseIsActive(0), false);
});
test("parseIsActive: false → false", () => {
assert.equal(parseIsActive(false), false);
});
test("parseIsActive: '0' → false", () => {
assert.equal(parseIsActive("0"), false);
});
// ─── parseAccessSchedule ──────────────────────────────────────────────────────
test("parseAccessSchedule: null/empty → null", () => {
assert.equal(parseAccessSchedule(null), null);
assert.equal(parseAccessSchedule(""), null);
assert.equal(parseAccessSchedule(" "), null);
});
test("parseAccessSchedule: valid JSON → object", () => {
const input = JSON.stringify({
enabled: true,
from: "08:00",
until: "18:00",
days: [1, 2, 3, 4, 5],
tz: "America/Sao_Paulo",
});
const result = parseAccessSchedule(input);
assert.deepEqual(result, {
enabled: true,
from: "08:00",
until: "18:00",
days: [1, 2, 3, 4, 5],
tz: "America/Sao_Paulo",
});
});
test("parseAccessSchedule: invalid day values are filtered out", () => {
const input = JSON.stringify({
enabled: true,
from: "08:00",
until: "18:00",
days: [1, 7, -1, 5],
tz: "UTC",
});
const result = parseAccessSchedule(input);
assert.deepEqual(result.days, [1, 5]);
});
test("parseAccessSchedule: missing required field → null", () => {
const input = JSON.stringify({ enabled: true, from: "08:00", until: "18:00", days: [1] });
assert.equal(parseAccessSchedule(input), null); // tz missing
});
test("parseAccessSchedule: invalid JSON → null", () => {
assert.equal(parseAccessSchedule("{broken json}"), null);
});
// ─── isWithinSchedule ────────────────────────────────────────────────────────
// Helper: create a Date at a specific UTC datetime
function utc(y, m, d, h, min) {
return new Date(Date.UTC(y, m - 1, d, h, min));
}
test("isWithinSchedule: enabled=false → always true", () => {
const schedule = { enabled: false, from: "00:00", until: "00:01", days: [1], tz: "UTC" };
// Even a time that would be blocked
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 12, 0)), true);
});
test("isWithinSchedule: time within window → true", () => {
// Monday 2024-03-11, 09:00 UTC (UTC timezone)
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 9, 0)), true);
});
test("isWithinSchedule: time before window → false", () => {
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
// 07:59
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 7, 59)), false);
});
test("isWithinSchedule: time exactly at 'from' → true (inclusive)", () => {
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 8, 0)), true);
});
test("isWithinSchedule: time exactly at 'until' → false (exclusive)", () => {
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 18, 0)), false);
});
test("isWithinSchedule: time after window → false", () => {
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 20, 0)), false);
});
test("isWithinSchedule: wrong weekday → false", () => {
// Monday (day 1) schedule, but 2024-03-12 is Tuesday (day 2)
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 12, 10, 0)), false);
});
test("isWithinSchedule: multiple days — matching day → true", () => {
// Mon-Fri schedule, Wednesday (3)
const schedule = {
enabled: true,
from: "09:00",
until: "17:00",
days: [1, 2, 3, 4, 5],
tz: "UTC",
};
// 2024-03-13 is Wednesday
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 13, 12, 0)), true);
});
test("isWithinSchedule: multiple days — Saturday blocked", () => {
const schedule = {
enabled: true,
from: "09:00",
until: "17:00",
days: [1, 2, 3, 4, 5],
tz: "UTC",
};
// 2024-03-09 is Saturday (day 6)
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 9, 12, 0)), false);
});
// ─── Overnight schedule tests ─────────────────────────────────────────────────
test("isWithinSchedule: overnight window — time after midnight → true", () => {
// 22:00 → 06:00, Monday
const schedule = { enabled: true, from: "22:00", until: "06:00", days: [1], tz: "UTC" };
// 02:30 Monday
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 2, 30)), true);
});
test("isWithinSchedule: overnight window — time before start → false", () => {
const schedule = { enabled: true, from: "22:00", until: "06:00", days: [1], tz: "UTC" };
// 21:59 Monday
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 21, 59)), false);
});
test("isWithinSchedule: overnight window — time after end → false", () => {
const schedule = { enabled: true, from: "22:00", until: "06:00", days: [1], tz: "UTC" };
// 06:01 Monday
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 6, 1)), false);
});
test("isWithinSchedule: overnight window — time exactly at start → true", () => {
const schedule = { enabled: true, from: "22:00", until: "06:00", days: [1], tz: "UTC" };
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 22, 0)), true);
});
test("isWithinSchedule: invalid timezone → fail-open (true)", () => {
const schedule = {
enabled: true,
from: "08:00",
until: "18:00",
days: [1, 2, 3, 4, 5],
tz: "Invalid/Zone",
};
// Should not throw, should return true (fail-open)
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 12, 0)), true);
});
test("isWithinSchedule: America/Sao_Paulo timezone conversion", () => {
// UTC 2024-03-11 15:00 = BRT (UTC-3) 12:00, Monday
const schedule = {
enabled: true,
from: "08:00",
until: "18:00",
days: [1],
tz: "America/Sao_Paulo",
};
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 15, 0)), true);
});
test("isWithinSchedule: America/Sao_Paulo — outside window", () => {
// UTC 2024-03-11 22:00 = BRT 19:00, Monday — after 18:00
const schedule = {
enabled: true,
from: "08:00",
until: "18:00",
days: [1],
tz: "America/Sao_Paulo",
};
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 22, 0)), false);
});

View File

@@ -0,0 +1,167 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// ── Env vars BEFORE dynamic imports ──────────────────────────────────────────
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-strict-random-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "strict-random-test-secret";
const { fisherYatesShuffle, getNextFromDeck } = await import("../../src/sse/services/auth.ts");
test.after(() => {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ─── fisherYatesShuffle ──────────────────────────────────────────────────────
test("fisherYatesShuffle: returns array with same elements", () => {
const input = ["a", "b", "c", "d", "e"];
const result = fisherYatesShuffle(input);
assert.equal(result.length, input.length);
for (const item of input) {
assert.ok(result.includes(item), `Missing item: ${item}`);
}
});
test("fisherYatesShuffle: does not mutate original array", () => {
const input = Object.freeze(["a", "b", "c"]);
const result = fisherYatesShuffle(input);
assert.deepStrictEqual([...input], ["a", "b", "c"]);
assert.equal(result.length, 3);
});
test("fisherYatesShuffle: single element returns same element", () => {
const result = fisherYatesShuffle(["only"]);
assert.deepStrictEqual(result, ["only"]);
});
test("fisherYatesShuffle: empty array returns empty array", () => {
const result = fisherYatesShuffle([]);
assert.deepStrictEqual(result, []);
});
// ─── getNextFromDeck ─────────────────────────────────────────────────────────
test("getNextFromDeck: uses all connections before repeating", () => {
const provider = "test-full-cycle";
const ids = ["c1", "c2", "c3", "c4"];
const seen = new Set();
for (let i = 0; i < ids.length; i++) {
const id = getNextFromDeck(provider, ids);
assert.ok(!seen.has(id), `Duplicate before full cycle: ${id} at step ${i}`);
seen.add(id);
}
assert.equal(seen.size, ids.length, "Should have used every connection exactly once");
});
test("getNextFromDeck: reshuffles after exhausting deck", () => {
const provider = "test-reshuffle";
const ids = ["c1", "c2", "c3"];
// Exhaust first cycle
for (let i = 0; i < ids.length; i++) {
getNextFromDeck(provider, ids);
}
// Next call should start a new cycle (reshuffle)
const firstOfNewCycle = getNextFromDeck(provider, ids);
assert.ok(ids.includes(firstOfNewCycle), "New cycle should return a valid connection");
// Complete the new cycle
const newCycleSeen = new Set([firstOfNewCycle]);
for (let i = 1; i < ids.length; i++) {
const id = getNextFromDeck(provider, ids);
assert.ok(!newCycleSeen.has(id), `Duplicate in new cycle: ${id}`);
newCycleSeen.add(id);
}
assert.equal(newCycleSeen.size, ids.length, "New cycle should use all connections");
});
test("getNextFromDeck: last of previous cycle is not first of next cycle", () => {
const provider = "test-no-repeat-boundary";
const ids = ["c1", "c2", "c3", "c4", "c5"];
// Run multiple full cycles and check the boundary condition
let violations = 0;
const totalCycles = 50;
for (let cycle = 0; cycle < totalCycles; cycle++) {
let lastId = "";
for (let i = 0; i < ids.length; i++) {
lastId = getNextFromDeck(provider, ids);
}
// First of next cycle
const firstOfNext = getNextFromDeck(provider, ids);
if (firstOfNext === lastId) violations++;
// Consume rest of cycle
for (let i = 1; i < ids.length; i++) {
getNextFromDeck(provider, ids);
}
}
assert.equal(
violations,
0,
`Last of cycle matched first of next cycle ${violations}/${totalCycles} times`
);
});
test("getNextFromDeck: connection list change resets deck", () => {
const provider = "test-reset-on-change";
const originalIds = ["c1", "c2", "c3", "c4"];
// Use 2 from original deck
getNextFromDeck(provider, originalIds);
getNextFromDeck(provider, originalIds);
// Now change the connection list (simulates quota exhaustion removing a connection)
const newIds = ["c1", "c2", "c3"]; // c4 removed
const seen = new Set();
for (let i = 0; i < newIds.length; i++) {
const id = getNextFromDeck(provider, newIds);
assert.ok(newIds.includes(id), `Got invalid id ${id} after reset`);
assert.ok(!seen.has(id), `Duplicate after reset: ${id}`);
seen.add(id);
}
assert.equal(seen.size, newIds.length, "Should use all new connections after reset");
});
test("getNextFromDeck: single connection always returns that connection", () => {
const provider = "test-single";
const ids = ["only-one"];
for (let i = 0; i < 10; i++) {
const id = getNextFromDeck(provider, ids);
assert.equal(id, "only-one");
}
});
test("getNextFromDeck: empty array returns empty string", () => {
const provider = "test-empty";
const id = getNextFromDeck(provider, []);
assert.equal(id, "");
});
test("getNextFromDeck: different providers have independent decks", () => {
const idsA = ["a1", "a2", "a3"];
const idsB = ["b1", "b2"];
const firstA = getNextFromDeck("providerA", idsA);
const firstB = getNextFromDeck("providerB", idsB);
assert.ok(idsA.includes(firstA));
assert.ok(idsB.includes(firstB));
// Exhaust providerB deck
getNextFromDeck("providerB", idsB);
// providerA should still have remaining items from its deck
const secondA = getNextFromDeck("providerA", idsA);
assert.ok(idsA.includes(secondA));
assert.notEqual(firstA, secondA, "providerA deck should advance independently");
});

View File

@@ -0,0 +1,149 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
// ── Env vars ANTES dos imports dinâmicos ─────────────────────────
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-t08-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "t08-test-secret-key";
const core = await import("../../src/lib/db/core.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const schemas = await import("../../src/shared/validation/schemas.ts");
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.resolve(__dirname, "../..");
async function resetStorage() {
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(async () => {
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ══════════════════════════════════════════════════════════════════
// Bloco 1 — Schema Zod (updateKeyPermissionsSchema)
// ══════════════════════════════════════════════════════════════════
test("1.1 — allowedConnections com UUID válido é aceito", () => {
const result = schemas.validateBody(schemas.updateKeyPermissionsSchema, {
allowedConnections: ["550e8400-e29b-41d4-a716-446655440000"],
});
assert.equal(result.success, true);
});
test("1.2 — allowedConnections com string não-UUID é rejeitado", () => {
const result = schemas.validateBody(schemas.updateKeyPermissionsSchema, {
allowedConnections: ["nao-e-uuid"],
});
assert.equal(result.success, false);
});
test("1.3 — allowedConnections + noLog combinados são aceitos", () => {
const result = schemas.validateBody(schemas.updateKeyPermissionsSchema, {
allowedConnections: ["550e8400-e29b-41d4-a716-446655440000"],
noLog: true,
});
assert.equal(result.success, true);
});
test("1.4 — payload vazio é rejeitado pelo superRefine", () => {
const result = schemas.validateBody(schemas.updateKeyPermissionsSchema, {});
assert.equal(result.success, false);
});
test("1.5 — allowedConnections array vazio é aceito (não é undefined, superRefine não dispara)", () => {
const result = schemas.validateBody(schemas.updateKeyPermissionsSchema, {
allowedConnections: [],
});
assert.equal(result.success, true);
});
// ══════════════════════════════════════════════════════════════════
// Bloco 2 — DB (apiKeys.ts)
// ══════════════════════════════════════════════════════════════════
test("2.1 — key criada tem allowedConnections: [] por padrão", async () => {
const created = await apiKeysDb.createApiKey("test-key", "machine-t08");
assert.deepEqual(created.allowedConnections, []);
});
test("2.2 — updateApiKeyPermissions persiste array de UUIDs", async () => {
const created = await apiKeysDb.createApiKey("conn-key", "machine-t08");
const uuid1 = "550e8400-e29b-41d4-a716-446655440000";
const uuid2 = "6ba7b810-9dad-11d1-80b4-00c04fd430c8";
const updated = await apiKeysDb.updateApiKeyPermissions(created.id, {
allowedConnections: [uuid1, uuid2],
});
assert.equal(updated, true);
const row = await apiKeysDb.getApiKeyById(created.id);
assert.deepEqual(row?.allowedConnections, [uuid1, uuid2]);
});
test("2.3 — getApiKeyById retorna allowedConnections corretamente", async () => {
const created = await apiKeysDb.createApiKey("by-id-key", "machine-t08");
const uuid = "550e8400-e29b-41d4-a716-446655440000";
await apiKeysDb.updateApiKeyPermissions(created.id, { allowedConnections: [uuid] });
const row = await apiKeysDb.getApiKeyById(created.id);
assert.ok(Array.isArray(row?.allowedConnections));
assert.equal(row?.allowedConnections[0], uuid);
});
test("2.4 — getApiKeyMetadata retorna allowedConnections corretamente", async () => {
const created = await apiKeysDb.createApiKey("meta-key", "machine-t08");
const uuid = "6ba7b810-9dad-11d1-80b4-00c04fd430c8";
await apiKeysDb.updateApiKeyPermissions(created.id, { allowedConnections: [uuid] });
const meta = await apiKeysDb.getApiKeyMetadata(created.key);
assert.ok(Array.isArray(meta?.allowedConnections));
assert.equal(meta?.allowedConnections[0], uuid);
});
test("2.5 — allowedConnections: [] persiste como array vazio (não null)", async () => {
const created = await apiKeysDb.createApiKey("empty-conn-key", "machine-t08");
const uuid = "550e8400-e29b-41d4-a716-446655440000";
// Primeiro adiciona um UUID
await apiKeysDb.updateApiKeyPermissions(created.id, { allowedConnections: [uuid] });
// Reseta para array vazio
await apiKeysDb.updateApiKeyPermissions(created.id, { allowedConnections: [] });
const row = await apiKeysDb.getApiKeyById(created.id);
assert.ok(Array.isArray(row?.allowedConnections));
assert.deepEqual(row?.allowedConnections, []);
});
test("2.6 — cache é invalidado após update (2ª chamada ao metadata reflete novo valor)", async () => {
const created = await apiKeysDb.createApiKey("cache-test-key", "machine-t08");
const uuid = "550e8400-e29b-41d4-a716-446655440000";
// Primeira chamada popula cache (allowedConnections vazio)
const before = await apiKeysDb.getApiKeyMetadata(created.key);
assert.deepEqual(before?.allowedConnections, []);
// Atualiza — deve invalidar cache
await apiKeysDb.updateApiKeyPermissions(created.id, { allowedConnections: [uuid] });
// Segunda chamada deve retornar novo valor (não o cache antigo)
const after = await apiKeysDb.getApiKeyMetadata(created.key);
assert.deepEqual(after?.allowedConnections, [uuid]);
});