From a8a29e17c5a505fcf442b4f35efd536365f37efa Mon Sep 17 00:00:00 2001 From: Anderson Firmino Date: Sat, 14 Mar 2026 13:01:49 -0300 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20feat:=20strict-random=20strateg?= =?UTF-8?q?y,=20API=20key=20management,=20connection=20groups,=20Limits=20?= =?UTF-8?q?UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Combo layer: strict-random in combo.ts rotates models uniformly - Credential layer: strict-random in auth.ts rotates connections/accounts - Anti-repeat guarantee: last of previous cycle ≠ first of next - Mutex serialization for concurrent request safety - Independent decks per combo name and per provider - allowedConnections: restrict which connections a key can use - autoResolve: per-key toggle for ambiguous model disambiguation - is_active: enable/disable key instantly (403 on disabled) - accessSchedule: time-based access control (hours, days, timezone) - Rename keys via PATCH /api/keys/:id - Connection restriction badge in API keys table - Auto-migration for all new columns - Connection group field on provider connections - Environment grouping view in Limits page (group by environment) - Accordion UI with expand/collapse per group - localStorage persistence for groupBy, autoRefresh, expandedGroups - Smart default: auto-switches to environment view when groups exist - Swap SessionsTab above RateLimitStatus - strict-random option added to combo strategy dropdown (30 languages) - strategyGuide.strict-random (when/avoid/example) - pt-BR: translated all strategyRecommendations from English to Portuguese - en: added API key management strings (accessSchedule, isActive, etc.) - 11 tests: shuffle deck mechanics (Fisher-Yates, anti-repeat, decks) - 6 tests: allowedConnections (schema, DB persistence, cache invalidation) - 12 tests: API key policy (isActive, accessSchedule, autoResolve, budget) --- open-sse/services/combo.ts | 46 +- .../api-manager/ApiManagerPageClient.tsx | 401 +++++++++++++++- src/app/(dashboard)/dashboard/combos/page.tsx | 15 + src/app/(dashboard)/dashboard/limits/page.tsx | 2 +- .../usage/components/ProviderLimits/index.tsx | 431 ++++++++++++------ src/i18n/messages/ar.json | 9 + src/i18n/messages/bg.json | 9 + src/i18n/messages/da.json | 9 + src/i18n/messages/de.json | 9 + src/i18n/messages/en.json | 30 ++ src/i18n/messages/es.json | 9 + src/i18n/messages/fi.json | 9 + src/i18n/messages/fr.json | 9 + src/i18n/messages/he.json | 9 + src/i18n/messages/hu.json | 9 + src/i18n/messages/id.json | 9 + src/i18n/messages/in.json | 9 + src/i18n/messages/it.json | 9 + src/i18n/messages/ja.json | 9 + src/i18n/messages/ko.json | 9 + src/i18n/messages/ms.json | 9 + src/i18n/messages/nl.json | 9 + src/i18n/messages/no.json | 9 + src/i18n/messages/phi.json | 9 + src/i18n/messages/pl.json | 9 + src/i18n/messages/pt-BR.json | 102 +++-- src/i18n/messages/pt.json | 9 + src/i18n/messages/ro.json | 9 + src/i18n/messages/ru.json | 9 + src/i18n/messages/sk.json | 9 + src/i18n/messages/sv.json | 9 + src/i18n/messages/th.json | 9 + src/i18n/messages/uk-UA.json | 9 + src/i18n/messages/vi.json | 9 + src/i18n/messages/zh-CN.json | 9 + src/lib/db/apiKeys.ts | 179 +++++++- src/lib/db/providers.ts | 18 +- src/shared/utils/apiKeyPolicy.ts | 106 ++++- src/shared/validation/schemas.ts | 40 +- src/sse/services/auth.ts | 72 +++ src/types/settings.ts | 3 +- tests/unit/api-key-policy.test.mjs | 313 +++++++++++++ tests/unit/strict-random-deck.test.mjs | 167 +++++++ tests/unit/t08-allowed-connections.test.mjs | 149 ++++++ 44 files changed, 2135 insertions(+), 191 deletions(-) create mode 100644 tests/unit/api-key-policy.test.mjs create mode 100644 tests/unit/strict-random-deck.test.mjs create mode 100644 tests/unit/t08-allowed-connections.test.mjs diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 46dc3953f3..c1c093b161 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -163,6 +163,41 @@ function shuffleArray(arr) { return arr; } +// ─── Strict-Random: Shuffle Deck for Combos ────────────────────────────────── +// Keyed by combo name — persists across requests, resets on server restart. +const comboShuffleDecks = new Map(); + +/** + * Returns the next model ID from a shuffle deck for the given combo. + * Uses each model exactly once per cycle before reshuffling (Fisher-Yates). + * Guarantees the last model of the previous cycle is not the first of the next. + */ +function getNextModelFromDeck(comboName, modelIds) { + if (modelIds.length === 0) return ""; + if (modelIds.length === 1) return modelIds[0]; + + const deck = comboShuffleDecks.get(comboName); + const idsKey = [...modelIds].sort().join(","); + + // If deck exists, is for the same model set, and is not exhausted — advance + if (deck && deck.idsKey === idsKey && deck.index < deck.order.length) { + const id = deck.order[deck.index]; + comboShuffleDecks.set(comboName, { ...deck, index: deck.index + 1 }); + return id; + } + + // Reshuffle — ensure last of previous cycle is not first of new cycle + const lastId = deck && deck.idsKey === idsKey ? deck.order[deck.order.length - 1] : undefined; + let newOrder = shuffleArray([...modelIds]); + if (lastId !== undefined && newOrder[0] === lastId && newOrder.length > 1) { + const swapIdx = Math.floor(Math.random() * (newOrder.length - 1)) + 1; + [newOrder[0], newOrder[swapIdx]] = [newOrder[swapIdx], newOrder[0]]; + } + + comboShuffleDecks.set(comboName, { order: newOrder, index: 1, idsKey }); + return newOrder[0]; +} + /** * Sort models by pricing (cheapest first) for cost-optimized strategy * @param {Array} models - Model strings in "provider/model" format @@ -287,7 +322,16 @@ export async function handleComboChat({ } // Apply strategy-specific ordering - if (strategy === "random") { + if (strategy === "strict-random") { + const selectedId = getNextModelFromDeck(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 = shuffleArray([...orderedModels]); log.info("COMBO", `Random shuffle: ${orderedModels.length} models`); } else if (strategy === "least-used") { diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index 9638da77ab..f7c88cc631 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -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([]); const [allModels, setAllModels] = useState([]); + const [allConnections, setAllConnections] = useState([]); 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 (
)} + {hasConnectionRestrictions && ( + + )} {noLogEnabled && ( @@ -504,6 +569,26 @@ export default function ApiManagerPageClient() { No-Log )} + {key.autoResolve && ( + + + auto_fix_high + + Auto-Resolve + + )} + {!keyIsActive && ( + + block + {t("disabled")} + + )} + {hasSchedule && ( + + schedule + {t("scheduleActive")} + + )}
@@ -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(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( + apiKey?.accessSchedule?.days ?? [1, 2, 3, 4, 5] + ); + const [scheduleTz, setScheduleTz] = useState( + apiKey?.accessSchedule?.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone + ); + const [selectedConnections, setSelectedConnections] = useState(initialConnections); + const [allowAllConnections, setAllowAllConnections] = useState(initialConnections.length === 0); const [expandedProviders, setExpandedProviders] = useState>(() => { // 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({

+ {/* Key Active Toggle */} +
+
+

{t("keyActive")}

+

{t("keyActiveDesc")}

+
+ +
+ + {/* Access Schedule */} +
+
+
+

{t("accessSchedule")}

+

{t("accessScheduleDesc")}

+
+ +
+ {scheduleEnabled && ( +
+
+
+ + setScheduleFrom(e.target.value)} + className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main" + /> +
+
+ + setScheduleUntil(e.target.value)} + className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main" + /> +
+
+
+ +
+ {( + [ + [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 ( + + ); + })} +
+
+
+ + 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" + /> +

{t("scheduleTimezoneHint")}

+
+
+ )} +
+ {/* Privacy Toggle */}
@@ -859,6 +1135,30 @@ const PermissionsModal = memo(function PermissionsModal({
+ {/* Auto-Resolve Toggle */} +
+
+

{t("autoResolve")}

+

{t("autoResolveDesc")}

+
+ +
+ {/* Selected Models Summary (only in restrict mode) */} {!allowAll && selectedCount > 0 && (
@@ -1024,6 +1324,97 @@ const PermissionsModal = memo(function PermissionsModal({ )} + {/* Allowed Connections Section */} + {allConnections.length > 0 && ( +
+
+

Allowed Connections

+
+ + +
+
+

+ {allowAllConnections + ? "This key can use any active connection." + : `Restricted to ${selectedConnections.length} connection${selectedConnections.length !== 1 ? "s" : ""}.`} +

+ {!allowAllConnections && ( +
+ {Object.entries( + allConnections.reduce>((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]) => ( +
+

+ {provider} +

+ {conns.map((conn) => { + const isSelected = selectedConnections.includes(conn.id); + return ( + + ); + })} +
+ ))} +
+ )} +
+ )} + {/* Actions */}
); } diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index 451044a202..56421b558d 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -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>(() => { + 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 (
@@ -313,8 +392,37 @@ export default function ProviderLimits() {
+ {/* Group by toggle */} +
+ + +
+
- {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 ( -
- {/* Account Info */} -
-
- {conn.provider} -
-
-
- {conn.name || config.label} + return ( +
+ {/* Account Info */} +
+
+ {conn.provider}
-
- - - {tierMeta.label} - - - {config.label} +
+
+ {conn.name || config.label} +
+
+ + + {tierMeta.label} + + + {config.label} +
-
- {/* Quota Bars */} -
- {isLoading ? ( -
- - progress_activity - - {t("loadingQuotas")} -
- ) : error ? ( -
- error - - {error} - -
- ) : quota?.message && (!quota.quotas || quota.quotas.length === 0) ? ( -
{quota.message}
- ) : 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 */} +
+ {isLoading ? ( +
+ + progress_activity + + {t("loadingQuotas")} +
+ ) : error ? ( +
+ error + + {error} + +
+ ) : quota?.message && (!quota.quotas || quota.quotas.length === 0) ? ( +
{quota.message}
+ ) : 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 ( -
- {/* Model label */} - - {shortName} - - - {/* Countdown */} - {cd && ( - - ⏱ {cd} + return ( +
+ {/* Model label */} + + {shortName} - )} - {/* Progress bar */} -
-
+ {/* Countdown */} + {cd && ( + + ⏱ {cd} + + )} + + {/* Progress bar */} +
+
+
+ + {/* Percentage */} + + {remaining}% +
+ ); + }) + ) : ( +
{t("noQuotaData")}
+ )} +
- {/* Percentage */} - - {remaining}% - -
- ); - }) - ) : ( -
{t("noQuotaData")}
- )} -
+ {/* Last Used */} +
+ {lastUpdated ? ( + + {lastUpdated.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} + + ) : ( + "-" + )} +
- {/* Last Used */} -
- {lastUpdated ? ( - - {lastUpdated.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} - - ) : ( - "-" - )} -
- - {/* Actions */} -
- +
+
+ ); + }; + + if (groupedConnections) { + const entries = [...groupedConnections.entries()]; + return entries.map(([groupName, conns]) => ( +
+ + {expandedGroups.has(groupName) && ( +
{conns.map((conn, idx) => renderRow(conn, idx === conns.length - 1))}
+ )}
-
+ )); + } + + return visibleConnections.map((conn, idx) => + renderRow(conn, idx === visibleConnections.length - 1) ); - })} + })()} {visibleConnections.length === 0 && (
diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index d7a476388b..96535ba158 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -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": "الكل", diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index 4cae428a80..24608224b5 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -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": "Всички", diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index a88ada9b28..25f0aa15a6 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -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", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 7581f037cf..469f10f27b 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -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", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 352f602bb1..161b5030b2 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -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", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 370f5aa322..4139f9ecf6 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -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", diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index 69b91fecdf..7f4b77c902 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -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", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 753358413a..cb10d1e086 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -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 d’actualisation 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", diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index 5af380f807..e950795c3f 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -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": "הכל", diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index adf344ca1e..e65f2e424e 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -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", diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 8acfbd91bc..cb0b22eaaf 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -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", diff --git a/src/i18n/messages/in.json b/src/i18n/messages/in.json index f5370ef199..31b952cfac 100644 --- a/src/i18n/messages/in.json +++ b/src/i18n/messages/in.json @@ -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": "सब", diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index a275280241..159a533be2 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -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", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index cd6f177c32..5e2edbc45a 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -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": "すべて", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index dd3b8d719d..98270f3664 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -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": "모두", diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index 9fbe638687..724690de53 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -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", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index 8156ef63c2..6ec6844df8 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -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", diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index 56b5e6ef32..4ff84aba88 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -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", diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index 187fc7b7bb..9d96c3e442 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -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", diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index ab681ef1a1..982ebb96ad 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -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", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 215d06a307..52f82253e7 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -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", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index c9f3a620b8..759e075191 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -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", diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index 8471c42b2a..d624011f28 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -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", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 6a73c8bb3d..7eb2350cb7 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -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": "Все", diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index 383a6a952f..49978ecca8 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -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", diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index aa555042c9..ca69725c15 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -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", diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index bced68d42d..8b862233e6 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -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": "ทั้งหมด", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index d8004e097d..c88ed6b9e7 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -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": "всі", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 44315b8302..a2f7ba2661 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -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ả", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 2981cb31a3..986c6279bc 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -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": "全部", diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts index 5801cd2c95..521a7d0de4 100644 --- a/src/lib/db/apiKeys.ts +++ b/src/lib/db/apiKeys.ts @@ -20,12 +20,24 @@ interface CacheEntry { 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 { @@ -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("SELECT * FROM api_keys WHERE id = ?"); _stmtValidateKey = db.prepare("SELECT 1 FROM api_keys WHERE key = ?"); _stmtGetKeyMetadata = db.prepare( - "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; + 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) { diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index da62a41d97..1907b5ab00 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -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 { + 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 = {}) { diff --git a/src/shared/utils/apiKeyPolicy.ts b/src/shared/utils/apiKeyPolicy.ts index 557947a2df..2e3eacaa47 100644 --- a/src/shared/utils/apiKeyPolicy.ts +++ b/src/shared/utils/apiKeyPolicy.ts @@ -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 = { + 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); diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index d0634eb28c..090967bbfd 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -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(), }) diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index badcf451c3..31288a33bd 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -87,6 +87,73 @@ let selectionMutex = Promise.resolve(); // unavailable in parallel, which was the root cause of cascading 502 lockouts. const markMutexes = new Map>(); +// ─── Strict-Random: Shuffle Deck ───────────────────────────────────────────── +interface ShuffleDeck { + order: readonly string[]; + index: number; +} + +const shuffleDecks = new Map(); + +/** + * Fisher-Yates shuffle — returns a new shuffled copy of the array. + */ +export function fisherYatesShuffle(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; +} + +/** + * Get next connection ID from the shuffle deck for the given provider. + * - If the deck is valid and not exhausted, returns the next ID and advances the index. + * - If exhausted or invalid (connection list changed), reshuffles. + * - On reshuffle, guarantees the last ID of the previous cycle is not the first of the new one. + */ +export function getNextFromDeck(provider: string, connectionIds: readonly string[]): string { + if (connectionIds.length === 0) return ""; + if (connectionIds.length === 1) return connectionIds[0]; + + const existing = shuffleDecks.get(provider); + + // Check if deck is still valid (same set of connection IDs) + const deckValid = + existing !== undefined && + existing.order.length === connectionIds.length && + existing.order.every((id) => connectionIds.includes(id)); + + if (deckValid && existing.index < existing.order.length) { + const id = existing.order[existing.index]; + shuffleDecks.set(provider, { order: existing.order, index: existing.index + 1 }); + return id; + } + + // Need to reshuffle — remember the last used ID to avoid repeating it first + const lastUsedId = + existing !== undefined && existing.order.length > 0 + ? existing.order[Math.min(existing.index, existing.order.length) - 1] + : undefined; + + let newOrder = fisherYatesShuffle(connectionIds); + + // If the first of the new cycle equals the last of the previous, swap it away + if (lastUsedId !== undefined && newOrder[0] === lastUsedId && newOrder.length > 1) { + // Pick a random position > 0 and swap + const swapIdx = 1 + Math.floor(Math.random() * (newOrder.length - 1)); + const tmp = newOrder[0]; + newOrder[0] = newOrder[swapIdx]; + newOrder[swapIdx] = tmp; + } + + shuffleDecks.set(provider, { order: newOrder, index: 1 }); + return newOrder[0]; +} + /** * Get provider credentials from localDb * Filters out unavailable accounts and returns the selected account based on strategy @@ -324,6 +391,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 = getNextFromDeck(provider, ids); + connection = orderedConnections.find((c) => c.id === selectedId) || orderedConnections[0]; } else { // Default: fill-first (already sorted by priority in getProviderConnections) connection = orderedConnections[0]; diff --git a/src/types/settings.ts b/src/types/settings.ts index 7c518b41f2..188405672a 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -10,7 +10,8 @@ export interface Settings { | "p2c" | "random" | "least-used" - | "cost-optimized"; + | "cost-optimized" + | "strict-random"; stickyRoundRobinLimit: number; jwtSecret?: string; } diff --git a/tests/unit/api-key-policy.test.mjs b/tests/unit/api-key-policy.test.mjs new file mode 100644 index 0000000000..8ee87f46ef --- /dev/null +++ b/tests/unit/api-key-policy.test.mjs @@ -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); +}); diff --git a/tests/unit/strict-random-deck.test.mjs b/tests/unit/strict-random-deck.test.mjs new file mode 100644 index 0000000000..49bd85bdd6 --- /dev/null +++ b/tests/unit/strict-random-deck.test.mjs @@ -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"); +}); diff --git a/tests/unit/t08-allowed-connections.test.mjs b/tests/unit/t08-allowed-connections.test.mjs new file mode 100644 index 0000000000..0eedb92be4 --- /dev/null +++ b/tests/unit/t08-allowed-connections.test.mjs @@ -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]); +}); From 02ccb35e8093e4a3736bb6689f8f78b9e328aac9 Mon Sep 17 00:00:00 2001 From: Anderson Firmino Date: Sat, 14 Mar 2026 13:50:19 -0300 Subject: [PATCH 2/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20consolidat?= =?UTF-8?q?e=20shuffle=20deck=20into=20shared=20utility=20with=20mutex=20p?= =?UTF-8?q?rotection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes race condition in combo strict-random (concurrent requests could reshuffle simultaneously). Eliminates code duplication between combo.ts and auth.ts by extracting Fisher-Yates shuffle + deck logic into src/shared/utils/shuffleDeck.ts with per-namespace mutex serialization. --- open-sse/services/combo.ts | 54 ++---------- src/shared/utils/shuffleDeck.ts | 145 ++++++++++++++++++++++++++++++++ src/sse/services/auth.ts | 73 ++-------------- 3 files changed, 156 insertions(+), 116 deletions(-) create mode 100644 src/shared/utils/shuffleDeck.ts diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index c1c093b161..6c4c44b44a 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -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,53 +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; -} - -// ─── Strict-Random: Shuffle Deck for Combos ────────────────────────────────── -// Keyed by combo name — persists across requests, resets on server restart. -const comboShuffleDecks = new Map(); - -/** - * Returns the next model ID from a shuffle deck for the given combo. - * Uses each model exactly once per cycle before reshuffling (Fisher-Yates). - * Guarantees the last model of the previous cycle is not the first of the next. - */ -function getNextModelFromDeck(comboName, modelIds) { - if (modelIds.length === 0) return ""; - if (modelIds.length === 1) return modelIds[0]; - - const deck = comboShuffleDecks.get(comboName); - const idsKey = [...modelIds].sort().join(","); - - // If deck exists, is for the same model set, and is not exhausted — advance - if (deck && deck.idsKey === idsKey && deck.index < deck.order.length) { - const id = deck.order[deck.index]; - comboShuffleDecks.set(comboName, { ...deck, index: deck.index + 1 }); - return id; - } - - // Reshuffle — ensure last of previous cycle is not first of new cycle - const lastId = deck && deck.idsKey === idsKey ? deck.order[deck.order.length - 1] : undefined; - let newOrder = shuffleArray([...modelIds]); - if (lastId !== undefined && newOrder[0] === lastId && newOrder.length > 1) { - const swapIdx = Math.floor(Math.random() * (newOrder.length - 1)) + 1; - [newOrder[0], newOrder[swapIdx]] = [newOrder[swapIdx], newOrder[0]]; - } - - comboShuffleDecks.set(comboName, { order: newOrder, index: 1, idsKey }); - return newOrder[0]; -} +// 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 @@ -323,7 +279,7 @@ export async function handleComboChat({ // Apply strategy-specific ordering if (strategy === "strict-random") { - const selectedId = getNextModelFromDeck(combo.name, orderedModels); + 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]; @@ -332,7 +288,7 @@ export async function handleComboChat({ `Strict-random deck: ${selectedId} selected (${orderedModels.length} models)` ); } else if (strategy === "random") { - orderedModels = shuffleArray([...orderedModels]); + orderedModels = fisherYatesShuffle([...orderedModels]); log.info("COMBO", `Random shuffle: ${orderedModels.length} models`); } else if (strategy === "least-used") { orderedModels = sortModelsByUsage(orderedModels, combo.name); diff --git a/src/shared/utils/shuffleDeck.ts b/src/shared/utils/shuffleDeck.ts new file mode 100644 index 0000000000..39d4371392 --- /dev/null +++ b/src/shared/utils/shuffleDeck.ts @@ -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(); +const mutexes = new Map>(); + +// ─── Fisher-Yates Shuffle ─────────────────────────────────────────────────── + +/** + * Fisher-Yates shuffle — returns a new shuffled copy of the array. + * Does NOT mutate the original. + */ +export function fisherYatesShuffle(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 { + 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((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(); +} diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 31288a33bd..6bd77dc8d8 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -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; @@ -87,72 +88,10 @@ let selectionMutex = Promise.resolve(); // unavailable in parallel, which was the root cause of cascading 502 lockouts. const markMutexes = new Map>(); -// ─── Strict-Random: Shuffle Deck ───────────────────────────────────────────── -interface ShuffleDeck { - order: readonly string[]; - index: number; -} - -const shuffleDecks = new Map(); - -/** - * Fisher-Yates shuffle — returns a new shuffled copy of the array. - */ -export function fisherYatesShuffle(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; -} - -/** - * Get next connection ID from the shuffle deck for the given provider. - * - If the deck is valid and not exhausted, returns the next ID and advances the index. - * - If exhausted or invalid (connection list changed), reshuffles. - * - On reshuffle, guarantees the last ID of the previous cycle is not the first of the new one. - */ -export function getNextFromDeck(provider: string, connectionIds: readonly string[]): string { - if (connectionIds.length === 0) return ""; - if (connectionIds.length === 1) return connectionIds[0]; - - const existing = shuffleDecks.get(provider); - - // Check if deck is still valid (same set of connection IDs) - const deckValid = - existing !== undefined && - existing.order.length === connectionIds.length && - existing.order.every((id) => connectionIds.includes(id)); - - if (deckValid && existing.index < existing.order.length) { - const id = existing.order[existing.index]; - shuffleDecks.set(provider, { order: existing.order, index: existing.index + 1 }); - return id; - } - - // Need to reshuffle — remember the last used ID to avoid repeating it first - const lastUsedId = - existing !== undefined && existing.order.length > 0 - ? existing.order[Math.min(existing.index, existing.order.length) - 1] - : undefined; - - let newOrder = fisherYatesShuffle(connectionIds); - - // If the first of the new cycle equals the last of the previous, swap it away - if (lastUsedId !== undefined && newOrder[0] === lastUsedId && newOrder.length > 1) { - // Pick a random position > 0 and swap - const swapIdx = 1 + Math.floor(Math.random() * (newOrder.length - 1)); - const tmp = newOrder[0]; - newOrder[0] = newOrder[swapIdx]; - newOrder[swapIdx] = tmp; - } - - shuffleDecks.set(provider, { order: newOrder, index: 1 }); - return newOrder[0]; -} +// 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 @@ -394,7 +333,7 @@ export async function getProviderCredentials( } else if (strategy === "strict-random") { // Strict Random: shuffle deck — uses each account once before reshuffling const ids = orderedConnections.map((c) => c.id); - const selectedId = getNextFromDeck(provider, ids); + 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) From ad7e7abda0656b0be52f9f827a2f1600454fdb9f Mon Sep 17 00:00:00 2001 From: Anderson Firmino Date: Sat, 14 Mar 2026 14:00:13 -0300 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=90=9B=20fix:=20propagate=20allowedCo?= =?UTF-8?q?nnections=20from=20API=20key=20to=20credential=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getProviderCredentials already filtered by allowedConnections, but chat.ts never passed the field from apiKeyInfo. Now both call sites (combo pre-check and credential retry loop) forward the restriction. --- src/sse/handlers/chat.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 742bf107de..0153caf14a 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -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) {