ai_api / src /shared /validation /schemas /settings.ts
Yogesh
initial deploy
cd8bd0a
Raw
History Blame Contribute Delete
10 kB
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();