mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 21:32:20 +03:00
Fila sem teto que segura a request seis minutos até o cliente abortar é pior que 503 imediato: consome slot, mascara a saturação e ainda entrega erro no fim. Um orçamento `maxWaitMs` por conexão compartilhado entre gate, slot padrão do provider e fila do Bottleneck é a forma certa — o teto tem que ser um só, senão cada camada espera o seu. O `max(perConn, upstream)` no `executionMaxWaitMs` é o detalhe que evita a correção matar request em voo, que seria trocar um defeito por outro. Registro a atribuição: você manteve o #12635 aberto para o @Tushar49 e creditou a percepção dele (providers lentos precisam de 2min→10min por conexão) enquanto adiciona o encanamento que faltava. É o jeito certo de construir sobre PR de outra pessoa sem tomar o crédito. Sobre o `npm run lint` desmarcado com a nota do eslint quebrado no ambiente: deixar em branco e explicar vale mais que marcar sem ter rodado. Rodei aqui: limpo. Revalidei sobre o tip: **13/13**, typecheck:core limpo, check-file-size OK. O `file-size-baseline.json` conflitou com os rebaselines desta campanha — resolvido aditivamente, JSON revalidado com `json.load`.
714 lines
29 KiB
TypeScript
714 lines
29 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 {
|
|
MODEL_SUPPORTED_ENDPOINT_VALUES,
|
|
normalizeModelSupportedEndpoints,
|
|
} from "@/shared/constants/modelSupportedEndpoints";
|
|
import { providerAllowsOptionalApiKey } from "@/shared/constants/providers";
|
|
import { HIDEABLE_SIDEBAR_ITEM_IDS } from "@/shared/constants/sidebarVisibility";
|
|
import {
|
|
isForbiddenUpstreamHeaderName,
|
|
isForbiddenCustomHeaderName,
|
|
} from "@/shared/constants/upstreamHeaders";
|
|
import { MAX_TIMER_TIMEOUT_MS } from "@/shared/utils/runtimeTimeouts";
|
|
import { validateProviderSpecificData } from "@/shared/validation/providerSpecificData";
|
|
import {
|
|
isReservedProviderPrefix,
|
|
reservedProviderPrefixMessage,
|
|
} from "@/shared/constants/reservedProviderPrefixes";
|
|
import {
|
|
isValidIanaTimeZone,
|
|
isValidResetHour,
|
|
} from "@omniroute/open-sse/services/dailyQuotaReset.ts";
|
|
import {
|
|
upstreamHeadersRecordSchema,
|
|
modelCompatPerProtocolSchema,
|
|
customHeadersSchema,
|
|
} from "./misc.ts";
|
|
import { isValidProviderIconUrl } from "@/shared/validation/iconUrl";
|
|
|
|
export { validateProviderSpecificData };
|
|
|
|
const dailyQuotaResetTimezoneSchema = z
|
|
.string()
|
|
.trim()
|
|
.optional()
|
|
.or(z.literal(""))
|
|
.refine((value) => !value || isValidIanaTimeZone(value), {
|
|
message: "Unknown IANA timezone",
|
|
});
|
|
|
|
const dailyQuotaResetHourSchema = z
|
|
.number()
|
|
.int()
|
|
.optional()
|
|
.nullable()
|
|
.refine((value) => value == null || isValidResetHour(value), {
|
|
message: "Hour must be 0-23",
|
|
});
|
|
|
|
// ──── Provider Schemas ────
|
|
|
|
// #2166 + data-URL support: shared optional remote icon URL for compatible provider
|
|
// nodes. Empty string is accepted as "no custom icon". Accepts http(s) URLs AND
|
|
// valid `data:image/*;base64,...` data URLs; rejects malformed/unsafe schemes. The
|
|
// validator lives in src/shared/validation/iconUrl.ts so UI and API never diverge.
|
|
const providerNodeIconUrlSchema = z
|
|
.string()
|
|
.trim()
|
|
.refine((value) => isValidProviderIconUrl(value), {
|
|
message: "Icon URL must be a valid http(s) or data:image/*;base64 URL",
|
|
})
|
|
.optional();
|
|
|
|
// #6715: the `apiKey` field is reused as the raw `Cookie:` header value for
|
|
// cookie-based web providers (Gemini Business, Copilot M365, ChatGPT Web (Codex),
|
|
// Claude Web, …). Real multi-cookie session headers (many `__Secure-*` entries,
|
|
// large session tokens) legitimately exceed the old 10,000-char cap, so saving
|
|
// a cookie that the provider's own `validate` check (validateProviderApiKeySchema,
|
|
// uncapped) had already accepted failed with HTTP 400 "Too big …<=10000". Raised
|
|
// to a still-bounded ceiling — well under the 10 MB default request-body limit and
|
|
// the unconstrained SQLite TEXT column — so garbage input is still rejected.
|
|
// Same fix shape as #6562 (priority cap raised to 100_000).
|
|
export const MAX_PROVIDER_CREDENTIAL_LENGTH = 100_000;
|
|
|
|
export const createProviderSchema = z
|
|
.object({
|
|
provider: z.string().min(1).max(100),
|
|
apiKey: z.string().max(MAX_PROVIDER_CREDENTIAL_LENGTH).optional(),
|
|
name: z.string().min(1).max(200),
|
|
priority: z.number().int().min(1).max(100).optional(),
|
|
globalPriority: z.number().int().min(1).max(100).nullable().optional(),
|
|
defaultModel: z.string().max(200).nullable().optional(),
|
|
testStatus: z.string().max(50).optional(),
|
|
providerSpecificData: z
|
|
.record(z.string(), z.unknown())
|
|
.optional()
|
|
.superRefine((data, ctx) => {
|
|
validateProviderSpecificData(data, ctx);
|
|
}),
|
|
})
|
|
.superRefine((data, ctx) => {
|
|
const apiKey = typeof data.apiKey === "string" ? data.apiKey.trim() : "";
|
|
const apiKeyOptional = providerAllowsOptionalApiKey(data.provider);
|
|
if (!apiKeyOptional && apiKey.length === 0) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "API key is required",
|
|
path: ["apiKey"],
|
|
});
|
|
}
|
|
|
|
const cx =
|
|
data.providerSpecificData && typeof data.providerSpecificData === "object"
|
|
? (data.providerSpecificData as Record<string, unknown>).cx
|
|
: undefined;
|
|
if (
|
|
data.provider === "google-pse-search" &&
|
|
(typeof cx !== "string" || cx.trim().length === 0)
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Programmable Search Engine ID (cx) is required",
|
|
path: ["providerSpecificData", "cx"],
|
|
});
|
|
}
|
|
|
|
const gheUrl =
|
|
data.providerSpecificData && typeof data.providerSpecificData === "object"
|
|
? (data.providerSpecificData as Record<string, unknown>).gheUrl
|
|
: undefined;
|
|
if (
|
|
data.provider === "ghe-copilot" &&
|
|
(typeof gheUrl !== "string" || gheUrl.trim().length === 0)
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "GitHub Enterprise URL (gheUrl) is required",
|
|
path: ["providerSpecificData", "gheUrl"],
|
|
});
|
|
}
|
|
});
|
|
|
|
export const bulkCreateProviderSchema = z
|
|
.object({
|
|
provider: z.string().min(1).max(100),
|
|
entries: z
|
|
.array(
|
|
z.object({
|
|
name: z.string().min(1).max(200),
|
|
apiKey: z.string().min(1).max(MAX_PROVIDER_CREDENTIAL_LENGTH),
|
|
// Per-key account id — required for cloudflare-ai (enforced in superRefine below).
|
|
accountId: z.string().min(1).max(200).optional(),
|
|
})
|
|
)
|
|
.min(1, "entries must contain at least 1 item")
|
|
.max(200, "entries must contain at most 200 items"),
|
|
priority: z.number().int().min(1).max(100).optional(),
|
|
globalPriority: z.number().int().min(1).max(100).nullable().optional(),
|
|
providerSpecificData: z
|
|
.record(z.string(), z.unknown())
|
|
.optional()
|
|
.superRefine((data, ctx) => {
|
|
validateProviderSpecificData(data, ctx);
|
|
}),
|
|
validateKeys: z.boolean().optional(),
|
|
})
|
|
.superRefine((data, ctx) => {
|
|
if (data.provider === "google-pse-search") {
|
|
const cx =
|
|
data.providerSpecificData && typeof data.providerSpecificData === "object"
|
|
? (data.providerSpecificData as Record<string, unknown>).cx
|
|
: undefined;
|
|
if (typeof cx !== "string" || cx.trim().length === 0) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Programmable Search Engine ID (cx) is required",
|
|
path: ["providerSpecificData", "cx"],
|
|
});
|
|
}
|
|
}
|
|
if (data.provider === "ghe-copilot") {
|
|
const gheUrl =
|
|
data.providerSpecificData && typeof data.providerSpecificData === "object"
|
|
? (data.providerSpecificData as Record<string, unknown>).gheUrl
|
|
: undefined;
|
|
if (typeof gheUrl !== "string" || gheUrl.trim().length === 0) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "GitHub Enterprise URL (gheUrl) is required",
|
|
path: ["providerSpecificData", "gheUrl"],
|
|
});
|
|
}
|
|
}
|
|
if (data.provider === "cloudflare-ai") {
|
|
// Cloudflare Workers AI builds its per-connection URL from accountId, so every
|
|
// bulk entry must carry its own non-empty account id (name|accountId|apiKey).
|
|
data.entries.forEach((entry, index) => {
|
|
if (typeof entry.accountId !== "string" || entry.accountId.trim().length === 0) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "accountId is required for cloudflare-ai entries",
|
|
path: ["entries", index, "accountId"],
|
|
});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// ──── Heterogeneous Provider Import Schema (#6836) ────
|
|
|
|
// #6836: unlike `bulkCreateProviderSchema` (many keys, ONE provider type per request),
|
|
// this schema backs a file (CSV/JSON) import of a heterogeneous LIST of providers —
|
|
// each entry carries its OWN `provider` id, validated individually here so the route
|
|
// can return per-row partial-failure results (same contract as /api/providers/bulk).
|
|
export const bulkImportProviderSchema = z.object({
|
|
entries: z
|
|
.array(
|
|
z.object({
|
|
// Provider-existence is validated server-side per-row in the import route's
|
|
// importOneEntry (isManagedProviderConnectionId lives in the server-only
|
|
// provider catalog, which must NOT be value-imported into a client-reachable
|
|
// validation schema — it drags the server runtime into the browser/CLI bundle).
|
|
provider: z.string().min(1, "provider is required").max(100),
|
|
name: z.string().min(1, "name is required").max(200),
|
|
apiKey: z.string().min(1, "apiKey is required").max(MAX_PROVIDER_CREDENTIAL_LENGTH),
|
|
baseUrl: z.string().trim().max(2000).optional(),
|
|
priority: z.number().int().min(1).max(100).optional(),
|
|
})
|
|
)
|
|
.min(1, "entries must contain at least 1 item")
|
|
.max(200, "entries must contain at most 200 items"),
|
|
validateKeys: z.boolean().optional(),
|
|
});
|
|
|
|
// ──── Bulk Web-Session Import Schema ────
|
|
|
|
export const bulkWebSessionImportSchema = z.object({
|
|
provider: z.string().min(1).max(100),
|
|
entries: z
|
|
.array(
|
|
z.object({
|
|
name: z.string().min(1).max(200),
|
|
credential: z
|
|
.string()
|
|
.min(1)
|
|
.max(64 * 1024, "Credential must be under 64 KB"),
|
|
})
|
|
)
|
|
.min(1, "entries must contain at least 1 item")
|
|
.max(50, "entries must contain at most 50 items"),
|
|
priority: z.number().int().min(1).max(100).optional(),
|
|
globalPriority: z.number().int().min(1).max(100).nullable().optional(),
|
|
});
|
|
|
|
export const providerModelMutationSchema = z.object({
|
|
provider: z.string().trim().min(1, "provider is required").max(120),
|
|
modelId: z.string().trim().min(1, "modelId is required").max(240),
|
|
modelName: z.string().trim().max(240).optional(),
|
|
source: z.string().trim().max(80).optional(),
|
|
apiFormat: z
|
|
.enum([
|
|
"chat-completions",
|
|
"responses",
|
|
"embeddings",
|
|
"rerank",
|
|
"audio-transcriptions",
|
|
"audio-speech",
|
|
"images-generations",
|
|
"video",
|
|
])
|
|
.default("chat-completions"),
|
|
supportedEndpoints: z
|
|
.array(z.enum(MODEL_SUPPORTED_ENDPOINT_VALUES))
|
|
.transform(normalizeModelSupportedEndpoints)
|
|
.default(["chat"]),
|
|
// #2905: optional per-model wire format override for custom models (e.g. a
|
|
// custom opencode-go model that must use the Anthropic Messages shape).
|
|
targetFormat: z
|
|
.enum(["openai", "openai-responses", "claude", "gemini", "antigravity"])
|
|
.optional(),
|
|
// #1294: optional token limits set in the "add custom model" form. The wire
|
|
// shape uses max_input_tokens / max_output_tokens (mirrors the /v1/models
|
|
// catalog); they persist as inputTokenLimit / outputTokenLimit.
|
|
max_input_tokens: z.number().int().positive().optional(),
|
|
max_output_tokens: z.number().int().positive().optional(),
|
|
// #4125: manual context-window override for a specific provider/model. Persisted
|
|
// via the Feature-5004 `model_context_overrides` table (source="manual") so it wins
|
|
// over the auto-discovery/static-catalog context window in `getModelContextLimit()`
|
|
// — fixes the "provider misreports context length" combo-drop case. `null` clears
|
|
// a previously set override.
|
|
contextWindowOverride: z.number().int().positive().nullable().optional(),
|
|
// #1904: manual vision-capability override for custom OpenAI-compatible models whose
|
|
// upstream discovery metadata does not self-report an image input modality (many
|
|
// self-hosted/local backends). Mirrors the auto-discovery `supportsVision` field so
|
|
// the same flag flows through `getCustomVisionCapabilityFields()` in the /v1/models
|
|
// catalog. `null` clears a manual override back to the id-based heuristic.
|
|
supportsVision: z.boolean().nullable().optional(),
|
|
isFree: z.boolean().nullable().optional(),
|
|
normalizeToolCallId: z.boolean().optional(),
|
|
preserveOpenAIDeveloperRole: z.boolean().nullable().optional(),
|
|
upstreamHeaders: upstreamHeadersRecordSchema.nullable().optional(),
|
|
/** Zod 4: `z.record(z.enum([...]), …)` requires every enum key; use `partialRecord` for sparse patches. */
|
|
compatByProtocol: z
|
|
.partialRecord(z.enum(["openai", "openai-responses", "claude"]), modelCompatPerProtocolSchema)
|
|
.optional(),
|
|
// #9820: optional async video-generation job preset for a custom
|
|
// OpenAI-compatible provider whose /videos surface is a submit→poll API
|
|
// (agnes-video-job, muapi-video-job, sora-job). Persisted on the custom model
|
|
// row; the /v1/videos/generations handler branches on it between the
|
|
// synchronous OpenAI-compatible path and the job/poll path. `"openai-video"`
|
|
// is a legacy no-op value that keeps the sync handler selected.
|
|
generationConfig: z
|
|
.object({
|
|
preset: z.enum(["agnes-video-job", "muapi-video-job", "sora-job", "openai-video"]),
|
|
})
|
|
.optional(),
|
|
});
|
|
|
|
export const updateModelAliasesSchema = z.object({
|
|
aliases: z.record(z.string().trim().min(1), z.string().trim().min(1)),
|
|
});
|
|
|
|
export const addModelAliasSchema = z.object({
|
|
from: z.string().trim().min(1),
|
|
to: z.string().trim().min(1),
|
|
});
|
|
|
|
export const removeModelAliasSchema = z.object({
|
|
from: z.string().trim().min(1),
|
|
});
|
|
|
|
export const createProviderNodeSchema = z
|
|
.object({
|
|
// #6874: name/prefix are required in general, but a `preset` (e.g.
|
|
// "vibeproxy-openai") supplies both — enforced conditionally below
|
|
// instead of unconditionally here.
|
|
name: z.string().trim().optional().or(z.literal("")),
|
|
prefix: z.string().trim().optional().or(z.literal("")),
|
|
apiType: z
|
|
.enum([
|
|
"chat",
|
|
"responses",
|
|
"embeddings",
|
|
"audio-transcriptions",
|
|
"audio-speech",
|
|
"images-generations",
|
|
])
|
|
.optional(),
|
|
baseUrl: z.string().trim().min(1).optional(),
|
|
type: z.enum(["openai-compatible", "anthropic-compatible"]).optional(),
|
|
compatMode: z.enum(["cc"]).optional(),
|
|
// #6874: named presets fill in name/prefix/apiType for well-known
|
|
// OpenAI-compatible local gateways so the operator only has to paste
|
|
// a baseUrl. Currently just VibeProxy (github.com/automazeio/vibeproxy).
|
|
preset: z.enum(["vibeproxy-openai"]).optional(),
|
|
chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
|
|
modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
|
|
// #2166: optional operator-supplied remote icon URL for the provider node. Empty
|
|
// string is accepted so callers can explicitly submit "no custom icon" (falls back
|
|
// to the built-in @lobehub/static resolution). Length/scheme limits live in
|
|
// isValidProviderIconUrl (2000 chars for http(s), 256 KiB for data:image).
|
|
iconUrl: providerNodeIconUrlSchema,
|
|
customHeaders: customHeadersSchema,
|
|
dailyQuotaResetTimezone: dailyQuotaResetTimezoneSchema,
|
|
dailyQuotaResetHour: dailyQuotaResetHourSchema,
|
|
})
|
|
.superRefine((value, ctx) => {
|
|
const nodeType = value.type || "openai-compatible";
|
|
const normalizedPrefix = value.prefix?.trim();
|
|
if (normalizedPrefix && isReservedProviderPrefix(normalizedPrefix)) {
|
|
// Validate caller-supplied prefixes before preset handling. Presets may
|
|
// provide a default, but the route preserves an explicit prefix; an early
|
|
// return here used to let retired identities create unreachable nodes.
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: reservedProviderPrefixMessage(normalizedPrefix),
|
|
path: ["prefix"],
|
|
});
|
|
}
|
|
if (value.preset === "vibeproxy-openai") {
|
|
// Preset supplies name/prefix/apiType — but baseUrl is still mandatory
|
|
// (a local proxy's host/port is operator-specific, unlike the generic
|
|
// openai-compatible fallback-to-api.openai.com default).
|
|
if (!value.baseUrl || !value.baseUrl.trim()) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Base URL is required for the VibeProxy preset",
|
|
path: ["baseUrl"],
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
if (!value.name || !value.name.trim()) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Name is required",
|
|
path: ["name"],
|
|
});
|
|
}
|
|
if (!value.prefix || !value.prefix.trim()) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Prefix is required",
|
|
path: ["prefix"],
|
|
});
|
|
} else if (isReservedProviderPrefix(value.prefix.trim())) {
|
|
// Reserved-prefix guard (tokenrouter bug): the runtime model resolver skips
|
|
// compatible-node lookup for built-in registry ids/aliases, so a node
|
|
// created with such a prefix could never be reached by it and silently
|
|
// routed requests to the built-in provider instead. Reject at the write
|
|
// path. Case-sensitive to match the runtime guard exactly.
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: reservedProviderPrefixMessage(value.prefix.trim()),
|
|
path: ["prefix"],
|
|
});
|
|
}
|
|
if (nodeType === "openai-compatible" && !value.apiType) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Invalid OpenAI compatible API type",
|
|
path: ["apiType"],
|
|
});
|
|
}
|
|
});
|
|
|
|
export const updateProviderNodeSchema = z
|
|
.object({
|
|
name: z.string().trim().min(1, "Name is required"),
|
|
prefix: z.string().trim().min(1, "Prefix is required"),
|
|
apiType: z
|
|
.enum([
|
|
"chat",
|
|
"responses",
|
|
"embeddings",
|
|
"audio-transcriptions",
|
|
"audio-speech",
|
|
"images-generations",
|
|
])
|
|
.optional(),
|
|
baseUrl: z.string().trim().min(1, "Base URL is required"),
|
|
chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
|
|
modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
|
|
// #2166: same optional remote icon URL as createProviderNodeSchema — empty string
|
|
// clears a previously stored custom icon.
|
|
iconUrl: providerNodeIconUrlSchema,
|
|
customHeaders: customHeadersSchema,
|
|
dailyQuotaResetTimezone: dailyQuotaResetTimezoneSchema,
|
|
dailyQuotaResetHour: dailyQuotaResetHourSchema,
|
|
})
|
|
.superRefine((value, ctx) => {
|
|
// Reserved-prefix guard (tokenrouter bug) — same rationale as the guard in
|
|
// createProviderNodeSchema: renaming a node's prefix onto a built-in
|
|
// registry id/alias would make it unreachable via that prefix.
|
|
if (isReservedProviderPrefix(value.prefix)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: reservedProviderPrefixMessage(value.prefix),
|
|
path: ["prefix"],
|
|
});
|
|
}
|
|
});
|
|
|
|
export const providerNodeValidateSchema = z.object({
|
|
baseUrl: z.string().trim().min(1, "Base URL and API key required"),
|
|
apiKey: z.string().trim().optional(),
|
|
type: z.enum(["openai-compatible", "anthropic-compatible"]).optional(),
|
|
compatMode: z.enum(["cc"]).optional(),
|
|
apiType: z
|
|
.enum([
|
|
"chat",
|
|
"responses",
|
|
"embeddings",
|
|
"rerank",
|
|
"audio-transcriptions",
|
|
"audio-speech",
|
|
"images-generations",
|
|
])
|
|
.optional(),
|
|
chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
|
|
modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
|
|
modelId: z.string().trim().max(200).optional().or(z.literal("")),
|
|
});
|
|
|
|
// rate-limit override numeric fields must reject operator intent loss.
|
|
// `z.coerce.number()` silently turns "" into 0 and "60abc" into NaN, which
|
|
// would drop or distort the value instead of rejecting it. Preprocess first so
|
|
// an empty/non-numeric string fails validation (surfaced as a 400), while still
|
|
// coercing legit numeric strings like "60".
|
|
function rateLimitOverrideNumber(max: number) {
|
|
return z.preprocess((raw) => {
|
|
if (typeof raw === "string") {
|
|
if (raw.trim() === "") return NaN;
|
|
const parsed = Number(raw);
|
|
return Number.isNaN(parsed) ? raw : parsed;
|
|
}
|
|
return raw;
|
|
}, z.coerce.number().int().min(0).max(max));
|
|
}
|
|
|
|
export const updateProviderConnectionSchema = z
|
|
.object({
|
|
name: z.string().max(200).optional(),
|
|
// #6562: `priority` is auto-incremented on connection creation
|
|
// (src/lib/db/providers.ts::createProviderConnection — `MAX(priority)+1` per
|
|
// provider, unbounded) and the edit UI always round-trips the connection's
|
|
// current priority unchanged. Providers whose accounts are commonly rotated
|
|
// in bulk (e.g. Codex OAuth import-bulk, up to 50 entries per call, repeatable)
|
|
// routinely exceed 100 connections, so a `max(100)` UI-only ceiling here
|
|
// rejected re-saving an already-valid, already-persisted priority with
|
|
// "Invalid request" on every edit. Bounded well above any realistic
|
|
// connection count (still rejects garbage input) rather than removed.
|
|
priority: z.coerce.number().int().min(1).max(100_000).optional(),
|
|
globalPriority: z.union([z.coerce.number().int().min(1).max(100_000), z.null()]).optional(),
|
|
defaultModel: z.union([z.string().max(200), z.null()]).optional(),
|
|
isActive: z.boolean().optional(),
|
|
apiKey: z.string().max(MAX_PROVIDER_CREDENTIAL_LENGTH).optional(),
|
|
testStatus: z.string().max(50).optional(),
|
|
lastError: z.union([z.string(), z.null()]).optional(),
|
|
lastErrorAt: z.union([z.string(), z.null()]).optional(),
|
|
lastErrorType: z.union([z.string(), z.null()]).optional(),
|
|
lastErrorSource: z.union([z.string(), z.null()]).optional(),
|
|
errorCode: z.union([z.string(), z.null()]).optional(),
|
|
rateLimitedUntil: z.union([z.string(), z.null()]).optional(),
|
|
lastTested: z.union([z.string(), z.null()]).optional(),
|
|
healthCheckInterval: z
|
|
.union([z.null(), z.coerce.number().int().min(0).max(1440)])
|
|
.optional(),
|
|
group: z.union([z.string().max(100), z.null()]).optional(),
|
|
maxConcurrent: z.union([z.null(), z.coerce.number().int().min(0)]).optional(),
|
|
// Per-window quota cutoffs. Map keys are window names (e.g. "window5h",
|
|
// "window7d"); values are 0-100 integers, or null to clear that window's
|
|
// override (the API route merges this into the existing map and prunes
|
|
// null entries before persisting). The whole field set to null clears
|
|
// every override on the connection.
|
|
quotaWindowThresholds: z
|
|
.union([
|
|
z.null(),
|
|
z.record(
|
|
// Window keys mirror the quota names from getUsageForProvider —
|
|
// bound for defense-in-depth so a malicious payload can't ship
|
|
// megabyte-long keys that would bloat the DB row.
|
|
z.string().min(1).max(64),
|
|
z.union([z.null(), z.coerce.number().int().min(0).max(100)])
|
|
),
|
|
])
|
|
.optional(),
|
|
projectId: z.union([z.string(), z.null()]).optional(),
|
|
// Per-connection rate limit overrides — overrides the global RequestQueueSettings
|
|
// for this connection. Set to null to clear all overrides.
|
|
// Per-connection rate limit overrides — overrides the global
|
|
// RequestQueueSettings for this connection. Set to null to clear all
|
|
// overrides. `.strict()` rejects unknown keys (e.g. a typo'd `tmp`) with a
|
|
// 400 instead of silently stripping them: the operator's intent is
|
|
// never dropped without an error. `.nullable()` (rather than a
|
|
// `z.union([z.null(), …])`) keeps the `unrecognized_keys` issue at the top
|
|
// level so the rejected key name survives into the 400 response.
|
|
rateLimitOverrides: z
|
|
.object({
|
|
rpm: rateLimitOverrideNumber(1_000_000).optional(),
|
|
rpd: rateLimitOverrideNumber(10_000_000).optional(),
|
|
tpm: rateLimitOverrideNumber(100_000_000).optional(),
|
|
tpd: rateLimitOverrideNumber(10_000_000_000).optional(),
|
|
minTime: rateLimitOverrideNumber(60_000).optional(),
|
|
maxConcurrent: rateLimitOverrideNumber(10_000).optional(),
|
|
maxWaitMs: rateLimitOverrideNumber(120_000).optional(),
|
|
executionMaxWaitMs: rateLimitOverrideNumber(600_000).optional(),
|
|
})
|
|
.partial()
|
|
.strict()
|
|
.nullable()
|
|
.optional(),
|
|
proxyEnabled: z.boolean().optional(),
|
|
perKeyProxyEnabled: z.boolean().optional(),
|
|
quotaVisible: z.boolean().optional(),
|
|
// Partial patch of per-connection provider-specific settings (e.g. quota toggles)
|
|
providerSpecificData: z
|
|
.record(z.string(), z.unknown())
|
|
.optional()
|
|
.superRefine((data, ctx) => {
|
|
validateProviderSpecificData(data, ctx);
|
|
}),
|
|
})
|
|
.superRefine((value, ctx) => {
|
|
if (Object.keys(value).length === 0) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "No valid fields to update",
|
|
path: [],
|
|
});
|
|
}
|
|
});
|
|
|
|
export const providersBatchTestSchema = z
|
|
.object({
|
|
mode: z.enum([
|
|
"provider",
|
|
"oauth",
|
|
"free",
|
|
"no-auth",
|
|
"apikey",
|
|
"compatible",
|
|
"all",
|
|
"web-cookie",
|
|
"search",
|
|
"audio",
|
|
"local",
|
|
"upstream-proxy",
|
|
"cloud-agent",
|
|
"ide",
|
|
"selected",
|
|
]),
|
|
// Frontend may send null when mode != 'provider' — accept and treat as missing
|
|
providerId: z.string().trim().min(1).nullable().optional(),
|
|
// Explicit connection IDs to test — required when mode=selected
|
|
connectionIds: z.array(z.string().trim().min(1)).max(100).nullable().optional(),
|
|
})
|
|
.superRefine((value, ctx) => {
|
|
// Treat null same as undefined
|
|
const pid = value.providerId ?? null;
|
|
if (value.mode === "provider" && !pid) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "providerId is required when mode=provider",
|
|
path: ["providerId"],
|
|
});
|
|
}
|
|
const ids = value.connectionIds ?? null;
|
|
if (value.mode === "selected" && (!ids || ids.length === 0)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "connectionIds is required when mode=selected",
|
|
path: ["connectionIds"],
|
|
});
|
|
}
|
|
});
|
|
|
|
// PATCH /api/providers — bulk activate/deactivate selected connections
|
|
export const batchUpdateProviderConnectionsSchema = z.object({
|
|
ids: z.array(z.string().trim().min(1)).min(1).max(100),
|
|
isActive: z.boolean(),
|
|
});
|
|
|
|
// PUT /api/providers/[id]/param-filters — upsert provider/model param filter config
|
|
const paramFilterListSchema = z.array(z.string().trim().min(1).max(200)).max(500);
|
|
|
|
const modelParamFilterSchema = z.object({
|
|
block: paramFilterListSchema.optional(),
|
|
allow: paramFilterListSchema.optional(),
|
|
});
|
|
|
|
export const updateParamFilterConfigSchema = z.object({
|
|
block: paramFilterListSchema.optional(),
|
|
allow: paramFilterListSchema.optional(),
|
|
models: z.record(z.string().trim().min(1).max(200), modelParamFilterSchema).optional(),
|
|
autoLearn: z.boolean().optional(),
|
|
});
|
|
|
|
// PUT /api/providers/[id]/interception-rules — upsert provider/model web
|
|
// search/fetch interception rules (#3384/#7339)
|
|
const fetchInterceptionBackendSchema = z.enum(["firecrawl", "jina", "tavily"]);
|
|
|
|
const modelInterceptionRuleSchema = z.object({
|
|
interceptSearch: z.boolean().optional(),
|
|
interceptFetch: z.boolean().optional(),
|
|
fetchBackend: fetchInterceptionBackendSchema.optional(),
|
|
fetchProxyUrl: z.string().trim().url().max(2000).optional(),
|
|
});
|
|
|
|
export const updateInterceptionRulesSchema = z.object({
|
|
interceptSearch: z.boolean().optional(),
|
|
interceptFetch: z.boolean().optional(),
|
|
fetchBackend: fetchInterceptionBackendSchema.optional(),
|
|
fetchProxyUrl: z.string().trim().url().max(2000).optional(),
|
|
models: z.record(z.string().trim().min(1).max(200), modelInterceptionRuleSchema).optional(),
|
|
});
|
|
|
|
// PUT /api/providers/[id]/cc-alias — Claude Code discovery-alias gate override
|
|
// (provider-level or per-model). `value: null` clears the override (inherit).
|
|
const ccAliasSettingValueSchema = z.enum(["on", "off"]).nullable();
|
|
|
|
export const updateCcAliasSettingSchema = z.discriminatedUnion("scope", [
|
|
z.object({
|
|
scope: z.literal("provider"),
|
|
value: ccAliasSettingValueSchema,
|
|
}),
|
|
z.object({
|
|
scope: z.literal("model"),
|
|
modelId: z.string().trim().min(1).max(200),
|
|
value: ccAliasSettingValueSchema,
|
|
}),
|
|
]);
|
|
|
|
export const validateProviderApiKeySchema = z
|
|
.object({
|
|
provider: z.string().trim().min(1, "Provider and API key required"),
|
|
apiKey: z.string().trim().optional(),
|
|
validationModelId: z.string().trim().optional(),
|
|
customUserAgent: z.string().trim().max(500).optional(),
|
|
baseUrl: z.string().trim().url().optional(),
|
|
region: z.string().trim().max(64).optional(),
|
|
accessKeyId: z.string().trim().max(500).optional(),
|
|
sessionToken: z.string().trim().max(5000).optional(),
|
|
cx: z.string().trim().max(500).optional(),
|
|
runtimeKey: z.string().trim().max(65_536).optional(),
|
|
tunnelId: z.string().trim().max(128).optional(),
|
|
connectorName: z.string().trim().max(200).optional(),
|
|
})
|
|
.superRefine((data, ctx) => {
|
|
if (data.provider === "google-pse-search" && !data.cx) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Programmable Search Engine ID (cx) is required",
|
|
path: ["cx"],
|
|
});
|
|
}
|
|
});
|