mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 22:22:57 +03:00
✨ 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:
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user