feat: strict-random strategy, API key management, connection groups, Limits UX

- 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)
This commit is contained in:
Anderson Firmino
2026-03-14 13:01:49 -03:00
parent 75a6d850fc
commit a8a29e17c5
44 changed files with 2135 additions and 191 deletions

View File

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

View File

@@ -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(),
})