mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 22:32:12 +03:00
O gating de quota-share em selectQuotaShareTarget é fail-open: uma conexão at-cap só é despriorizada, nunca bloqueada. Com 1 conexão por conta de assinatura (caso comum), chamadas concorrentes ainda floodam a conta (→ 429 + cooldown) — provado live na .15: 3 chamadas concorrentes com max_concurrent=1 despacharam todas em 94ms. Adiciona um semáforo POR CONEXÃO em torno do dispatch quota-share: chamadas excedentes esperam na fila em vez de floodar (key qsconn:<connectionId>, cap = max_concurrent da conexão). Fail-open em fila saturada/timeout para nunca piorar disponibilidade. Gated por strategy===quota-share + kill-switch resilienceSettings.quotaShareConcurrencyLimit (default on; UI no ResilienceTab). Lógica extraível isolada no leaf puro combo/quotaShareConcurrency.ts (unit-testado: estabilidade da key, no-op sem cap, serialização real, fail-open). Settings + schema + UI espelham comboCooldownWait. Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>
276 lines
9.8 KiB
TypeScript
276 lines
9.8 KiB
TypeScript
import { z } from "zod";
|
|
import {
|
|
ACCOUNT_FALLBACK_STRATEGY_VALUES,
|
|
ROUTING_STRATEGY_VALUES,
|
|
} from "@/shared/constants/routingStrategies";
|
|
import { SUPPORTED_BATCH_ENDPOINTS } from "@/shared/constants/batchEndpoints";
|
|
import { MAX_REQUEST_BODY_LIMIT_MB, MIN_REQUEST_BODY_LIMIT_MB } from "@/shared/constants/bodySize";
|
|
import { COMBO_CONFIG_MODES } from "@/shared/constants/comboConfigMode";
|
|
import { providerAllowsOptionalApiKey } from "@/shared/constants/providers";
|
|
import { HIDEABLE_SIDEBAR_ITEM_IDS } from "@/shared/constants/sidebarVisibility";
|
|
import { HIDEABLE_SIDEBAR_GROUP_IDS } from "@/shared/constants/sidebarGroupVisibility";
|
|
import {
|
|
isForbiddenUpstreamHeaderName,
|
|
isForbiddenCustomHeaderName,
|
|
} from "@/shared/constants/upstreamHeaders";
|
|
import { MAX_TIMER_TIMEOUT_MS } from "@/shared/utils/runtimeTimeouts";
|
|
|
|
// ──── Settings Schemas ────
|
|
// FASE-01: Removed .passthrough() — only explicitly listed fields are accepted
|
|
|
|
export const settingsFallbackStrategySchema = z.enum(ACCOUNT_FALLBACK_STRATEGY_VALUES);
|
|
|
|
// Single source of truth: ../settingsSchemas (the schema the runtime settings route validates
|
|
// against). Re-exported here so this modular barrel stays in exact lockstep — a divergent local
|
|
// copy (introduced by the #3988 lossy modularization) silently dropped 40 fields while gaining a
|
|
// few others. The settings-schema parity test guards this; see QUALITY_GATE_PLAYBOOK Parte 6 (G2).
|
|
export { updateSettingsSchema } from "../settingsSchemas";
|
|
|
|
export const legacyResilienceProfileSchema = z.object({
|
|
transientCooldown: z.number().min(0),
|
|
rateLimitCooldown: z.number().min(0),
|
|
maxBackoffLevel: z.number().int().min(0),
|
|
circuitBreakerThreshold: z.number().int().min(0),
|
|
circuitBreakerReset: z.number().min(0),
|
|
});
|
|
|
|
export const legacyResilienceDefaultsSchema = z
|
|
.object({
|
|
requestsPerMinute: z.number().int().min(1).optional(),
|
|
minTimeBetweenRequests: z.number().int().min(0).optional(),
|
|
concurrentRequests: z.number().int().min(1).optional(),
|
|
})
|
|
.strict();
|
|
|
|
export const requestQueueSettingsSchema = z
|
|
.object({
|
|
autoEnableApiKeyProviders: z.boolean().optional(),
|
|
requestsPerMinute: z.number().int().min(1).optional(),
|
|
minTimeBetweenRequestsMs: z.number().int().min(0).optional(),
|
|
concurrentRequests: z.number().int().min(1).optional(),
|
|
maxWaitMs: z.number().int().min(1).optional(),
|
|
})
|
|
.strict();
|
|
|
|
export const connectionCooldownProfileSchema = z
|
|
.object({
|
|
baseCooldownMs: z.number().int().min(0).optional(),
|
|
useUpstreamRetryHints: z.boolean().optional(),
|
|
// Issue #2100 follow-up: per-profile toggle for upstream 429 hint trust.
|
|
// `null` is an explicit unset sentinel — PATCH handler deletes the key
|
|
// from stored settings so the per-provider default resolves at runtime.
|
|
// `undefined` (key omitted) means "leave existing value unchanged".
|
|
useUpstream429BreakerHints: z.boolean().nullable().optional(),
|
|
maxBackoffSteps: z.number().int().min(0).optional(),
|
|
})
|
|
.strict();
|
|
|
|
export const providerBreakerProfileSchema = z
|
|
.object({
|
|
failureThreshold: z.number().int().min(1).max(1000).optional(),
|
|
degradationThreshold: z.number().int().min(1).max(1000).optional(),
|
|
resetTimeoutMs: z.number().int().min(1000).optional(),
|
|
})
|
|
.strict()
|
|
.superRefine((value, ctx) => {
|
|
if (
|
|
typeof value.failureThreshold === "number" &&
|
|
value.failureThreshold > 1 &&
|
|
typeof value.degradationThreshold === "number" &&
|
|
value.degradationThreshold >= value.failureThreshold
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "degradationThreshold must be lower than failureThreshold",
|
|
path: ["degradationThreshold"],
|
|
});
|
|
}
|
|
});
|
|
|
|
export const waitForCooldownSettingsSchema = z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
maxRetries: z.number().int().min(0).max(10).optional(),
|
|
maxRetryWaitSec: z.number().int().min(0).max(300).optional(),
|
|
})
|
|
.strict();
|
|
|
|
// Quota-share combo cooldown-aware retry (Variante A). Bounds mirror
|
|
// normalizeComboCooldownWaitSettings: a single wait <= 30s, <= 10 attempts.
|
|
export const comboCooldownWaitSettingsSchema = z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
maxWaitMs: z.number().int().min(0).max(30000).optional(),
|
|
maxAttempts: z.number().int().min(0).max(10).optional(),
|
|
budgetMs: z.number().int().min(0).max(300000).optional(),
|
|
})
|
|
.strict();
|
|
|
|
// FASE 2.1: kill-switch for the per-connection quota-share concurrency limit.
|
|
// The cap itself comes from each connection's max_concurrent, so only `enabled`
|
|
// is configurable here.
|
|
export const quotaShareConcurrencyLimitSettingsSchema = z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
})
|
|
.strict();
|
|
|
|
export const providerCooldownSettingsSchema = z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
minRetryCooldownMs: z.number().int().min(0).max(300000).optional(),
|
|
maxRetryCooldownMs: z.number().int().min(0).max(3600000).optional(),
|
|
})
|
|
.strict()
|
|
.superRefine((value, ctx) => {
|
|
if (
|
|
typeof value.minRetryCooldownMs === "number" &&
|
|
typeof value.maxRetryCooldownMs === "number" &&
|
|
value.maxRetryCooldownMs < value.minRetryCooldownMs
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "maxRetryCooldownMs must be greater than or equal to minRetryCooldownMs",
|
|
path: ["maxRetryCooldownMs"],
|
|
});
|
|
}
|
|
});
|
|
|
|
export const updateResilienceSchema = z
|
|
.object({
|
|
requestQueue: requestQueueSettingsSchema.optional(),
|
|
connectionCooldown: z
|
|
.object({
|
|
oauth: connectionCooldownProfileSchema.optional(),
|
|
apikey: connectionCooldownProfileSchema.optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
providerBreaker: z
|
|
.object({
|
|
oauth: providerBreakerProfileSchema.optional(),
|
|
apikey: providerBreakerProfileSchema.optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
waitForCooldown: waitForCooldownSettingsSchema.optional(),
|
|
comboCooldownWait: comboCooldownWaitSettingsSchema.optional(),
|
|
quotaShareConcurrencyLimit: quotaShareConcurrencyLimitSettingsSchema.optional(),
|
|
providerCooldown: providerCooldownSettingsSchema.optional(),
|
|
profiles: z
|
|
.object({
|
|
oauth: legacyResilienceProfileSchema.optional(),
|
|
apikey: legacyResilienceProfileSchema.optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
defaults: legacyResilienceDefaultsSchema.optional(),
|
|
})
|
|
.strict()
|
|
.superRefine((value, ctx) => {
|
|
if (
|
|
!value.requestQueue &&
|
|
!value.connectionCooldown &&
|
|
!value.providerBreaker &&
|
|
!value.waitForCooldown &&
|
|
!value.comboCooldownWait &&
|
|
!value.quotaShareConcurrencyLimit &&
|
|
!value.providerCooldown &&
|
|
!value.profiles &&
|
|
!value.defaults
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Must provide resilience settings to update",
|
|
path: [],
|
|
});
|
|
}
|
|
});
|
|
|
|
export const updateRequireLoginSchema = z
|
|
.object({
|
|
requireLogin: z.boolean().optional(),
|
|
password: z.string().min(4, "Password must be at least 4 characters").optional(),
|
|
})
|
|
.superRefine((value, ctx) => {
|
|
if (value.requireLogin === undefined && !value.password) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "No valid fields to update",
|
|
path: [],
|
|
});
|
|
}
|
|
});
|
|
|
|
export const updateSystemPromptSchema = z
|
|
.object({
|
|
prompt: z.string().max(50000).optional(), // legacy compat
|
|
prefixPrompt: z.string().max(50000).optional(),
|
|
suffixPrompt: z.string().max(50000).optional(),
|
|
enabled: z.boolean().optional(),
|
|
})
|
|
.strict()
|
|
.superRefine((value, ctx) => {
|
|
if (
|
|
value.prompt === undefined &&
|
|
value.prefixPrompt === undefined &&
|
|
value.suffixPrompt === undefined &&
|
|
value.enabled === undefined
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "No valid fields to update",
|
|
path: [],
|
|
});
|
|
}
|
|
});
|
|
|
|
export const updateThinkingBudgetSchema = z
|
|
.object({
|
|
mode: z.enum(["passthrough", "auto", "custom", "adaptive"]).optional(),
|
|
customBudget: z.coerce.number().int().min(0).max(131072).optional(),
|
|
effortLevel: z.enum(["none", "low", "medium", "high", "xhigh", "max"]).optional(),
|
|
baseBudget: z.coerce.number().int().min(0).max(131072).optional(),
|
|
complexityMultiplier: z.coerce.number().min(0).optional(),
|
|
})
|
|
.strict()
|
|
.superRefine((value, ctx) => {
|
|
if (
|
|
value.mode === undefined &&
|
|
value.customBudget === undefined &&
|
|
value.effortLevel === undefined &&
|
|
value.baseBudget === undefined &&
|
|
value.complexityMultiplier === undefined
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "No valid fields to update",
|
|
path: [],
|
|
});
|
|
}
|
|
});
|
|
|
|
export const guideSettingsSaveSchema = z
|
|
.object({
|
|
baseUrl: z.string().trim().min(1).optional(),
|
|
// #3552: the CLI tool cards post `apiKey: null` in cloud mode (the real key is resolved
|
|
// server-side from keyId), and `z.string().optional()` rejected null → 400. Normalize
|
|
// null → undefined so validation passes and the keyId/default path is used.
|
|
apiKey: z.preprocess((v) => (v === null ? undefined : v), z.string().optional()),
|
|
model: z.string().trim().min(1, "Model is required").optional(),
|
|
models: z.array(z.string().trim().min(1, "Models must be non-empty")).min(1).optional(),
|
|
modelLabels: z.record(z.string(), z.string().trim().min(1)).optional(),
|
|
})
|
|
.refine((data) => !!data.model || !!data.models?.length, {
|
|
message: "Model is required",
|
|
path: ["model"],
|
|
});
|
|
|
|
// ─── Auto-disable banned/error accounts ───────────────────────────────────
|
|
export const updateAutoDisableAccountsSchema = z
|
|
.object({
|
|
enabled: z.boolean(),
|
|
threshold: z.number().int().min(1).max(10).optional(),
|
|
})
|
|
.strict();
|