mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat: features 07/09/04/06 — codex clamp, openrouter cache, quota preflight & monitor
feat(codex): clamp reasoning effort per model (feature-07) - Add MAX_EFFORT_BY_MODEL table in CodexExecutor - Add clampEffort() applied after effort derivation - Logs debug when clamp is applied feat(catalog): OpenRouter catalog with persistent cache (feature-09) - New src/lib/catalog/openrouterCatalog.ts with TTL 24h + stale-if-error - New GET /api/models/openrouter-catalog endpoint (authenticated) - Reduces redundant OpenRouter API calls from dashboard feat(quota): quota preflight with per-provider toggle (feature-04) - New open-sse/services/quotaPreflight.ts - Toggle: providerSpecificData.quotaPreflightEnabled (default: false) - Extensible via registerQuotaFetcher() pattern - Graceful degradation when no fetcher registered feat(quota): quota session monitor with per-provider toggle (feature-06) - New open-sse/services/quotaMonitor.ts - Toggle: providerSpecificData.quotaMonitorEnabled (default: false) - Adaptive polling: 60s normal / 15s critical - Alert deduplication (5min suppression window) - timer.unref() ensures clean process exit feat(providers): support providerSpecificData patch in PUT /api/providers/[id] - Partial merge of providerSpecificData (preserves existing keys) - Schema updated to accept providerSpecificData in updateProviderConnectionSchema
This commit is contained in:
@@ -2,6 +2,39 @@ import { BaseExecutor } from "./base.ts";
|
||||
import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.ts";
|
||||
import { PROVIDERS } from "../config/constants.ts";
|
||||
|
||||
// Ordered list of effort levels from lowest to highest
|
||||
const EFFORT_ORDER = ["none", "low", "medium", "high", "xhigh"] as const;
|
||||
type EffortLevel = (typeof EFFORT_ORDER)[number];
|
||||
|
||||
/**
|
||||
* Maximum reasoning effort allowed per Codex model.
|
||||
* Models not listed here default to "xhigh" (unrestricted).
|
||||
* Update this table when Codex releases new models with different caps.
|
||||
*/
|
||||
const MAX_EFFORT_BY_MODEL: Record<string, EffortLevel> = {
|
||||
"gpt-5.3-codex": "xhigh",
|
||||
"gpt-5.2-codex": "xhigh",
|
||||
"gpt-5.1-codex-max": "xhigh",
|
||||
"gpt-5-mini": "high",
|
||||
"gpt-5.1-mini": "high",
|
||||
"gpt-4.1-mini": "high",
|
||||
};
|
||||
|
||||
/**
|
||||
* Clamp reasoning effort to the model's maximum allowed level.
|
||||
* Returns the original value if within limits, or the cap if it exceeds it.
|
||||
*/
|
||||
function clampEffort(model: string, requested: string): string {
|
||||
const max: EffortLevel = MAX_EFFORT_BY_MODEL[model] ?? "xhigh";
|
||||
const reqIdx = EFFORT_ORDER.indexOf(requested as EffortLevel);
|
||||
const maxIdx = EFFORT_ORDER.indexOf(max);
|
||||
if (reqIdx > maxIdx) {
|
||||
console.debug(`[Codex] clampEffort: "${requested}" → "${max}" (model: ${model})`);
|
||||
return max;
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex Executor - handles OpenAI Codex API (Responses API format)
|
||||
* Automatically injects default instructions if missing.
|
||||
@@ -47,20 +80,28 @@ export class CodexExecutor extends BaseExecutor {
|
||||
// Extract thinking level from model name suffix
|
||||
// e.g., gpt-5.3-codex-high → high, gpt-5.3-codex → medium (default)
|
||||
const effortLevels = ["none", "low", "medium", "high", "xhigh"];
|
||||
let modelEffort = null;
|
||||
let modelEffort: string | null = null;
|
||||
// Track the clean model name (suffix stripped) for clamp lookup
|
||||
let cleanModel = model;
|
||||
for (const level of effortLevels) {
|
||||
if (model.endsWith(`-${level}`)) {
|
||||
modelEffort = level;
|
||||
// Strip suffix from model name for actual API call
|
||||
body.model = body.model.replace(`-${level}`, "");
|
||||
cleanModel = body.model;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority: explicit reasoning.effort > reasoning_effort param > model suffix > default (medium)
|
||||
if (!body.reasoning) {
|
||||
const effort = body.reasoning_effort || modelEffort || "medium";
|
||||
const rawEffort = body.reasoning_effort || modelEffort || "medium";
|
||||
// Clamp effort to the model's maximum allowed level (feature-07)
|
||||
const effort = clampEffort(cleanModel, rawEffort);
|
||||
body.reasoning = { effort };
|
||||
} else if (body.reasoning.effort) {
|
||||
// Also clamp if reasoning object was provided directly
|
||||
body.reasoning.effort = clampEffort(cleanModel, body.reasoning.effort);
|
||||
}
|
||||
delete body.reasoning_effort;
|
||||
|
||||
|
||||
126
open-sse/services/quotaMonitor.ts
Normal file
126
open-sse/services/quotaMonitor.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* quotaMonitor.ts — Feature 06
|
||||
* Monitoramento de Quota em Sessão Ativa
|
||||
*
|
||||
* Toggle: providerSpecificData.quotaMonitorEnabled (default: false)
|
||||
* Polling adaptativo: NORMAL (60s) → CRITICAL (15s) → EXHAUSTED
|
||||
* timer.unref() garante que o processo pode fechar normalmente.
|
||||
* Alertas deduplicados por sessão (janela de 5min).
|
||||
*/
|
||||
|
||||
import { registerQuotaFetcher, type QuotaFetcher } from "./quotaPreflight.ts";
|
||||
|
||||
export { registerQuotaFetcher };
|
||||
export type { QuotaFetcher };
|
||||
|
||||
const NORMAL_INTERVAL_MS = 60_000;
|
||||
const CRITICAL_INTERVAL_MS = 15_000;
|
||||
const WARN_THRESHOLD = 0.8;
|
||||
const EXHAUSTION_THRESHOLD = 0.95;
|
||||
const ALERT_SUPPRESS_WINDOW_MS = 5 * 60_000;
|
||||
|
||||
interface MonitorState {
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
stopped: boolean;
|
||||
provider: string;
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
const activeMonitors = new Map<string, MonitorState>();
|
||||
const alertSuppression = new Map<string, number>();
|
||||
// Registry mirror from quotaPreflight (same Map reference via re-export)
|
||||
const quotaFetcherRegistry = new Map<string, QuotaFetcher>();
|
||||
export function registerMonitorFetcher(provider: string, fetcher: QuotaFetcher): void {
|
||||
quotaFetcherRegistry.set(provider, fetcher);
|
||||
registerQuotaFetcher(provider, fetcher);
|
||||
}
|
||||
|
||||
export function isQuotaMonitorEnabled(connection: Record<string, unknown>): boolean {
|
||||
const psd = connection?.providerSpecificData as Record<string, unknown> | undefined;
|
||||
return psd?.quotaMonitorEnabled === true;
|
||||
}
|
||||
|
||||
function suppressedAlert(
|
||||
sessionId: string,
|
||||
provider: string,
|
||||
accountId: string,
|
||||
percentUsed: number
|
||||
): void {
|
||||
const key = `${sessionId}:${provider}:${accountId}`;
|
||||
const last = alertSuppression.get(key) ?? 0;
|
||||
if (Date.now() - last < ALERT_SUPPRESS_WINDOW_MS) return;
|
||||
alertSuppression.set(key, Date.now());
|
||||
console.warn(
|
||||
`[QuotaMonitor] session=${sessionId} ${provider}/${accountId}: ${(percentUsed * 100).toFixed(1)}% quota used`
|
||||
);
|
||||
}
|
||||
|
||||
function scheduleNextPoll(sessionId: string, intervalMs: number): void {
|
||||
const state = activeMonitors.get(sessionId);
|
||||
if (!state || state.stopped) return;
|
||||
|
||||
const { provider, accountId } = state;
|
||||
const timer = setTimeout(async () => {
|
||||
const current = activeMonitors.get(sessionId);
|
||||
if (!current || current.stopped) return;
|
||||
|
||||
try {
|
||||
const fetcher = quotaFetcherRegistry.get(provider);
|
||||
if (!fetcher) {
|
||||
scheduleNextPoll(sessionId, NORMAL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
const quota = await fetcher(accountId);
|
||||
const percentUsed = quota?.percentUsed ?? 0;
|
||||
|
||||
if (percentUsed >= EXHAUSTION_THRESHOLD) {
|
||||
suppressedAlert(sessionId, provider, accountId, percentUsed);
|
||||
console.info(
|
||||
`[QuotaMonitor] session=${sessionId}: marking ${accountId} for next-session cooldown`
|
||||
);
|
||||
scheduleNextPoll(sessionId, CRITICAL_INTERVAL_MS);
|
||||
} else if (percentUsed >= WARN_THRESHOLD) {
|
||||
suppressedAlert(sessionId, provider, accountId, percentUsed);
|
||||
scheduleNextPoll(sessionId, CRITICAL_INTERVAL_MS);
|
||||
} else {
|
||||
scheduleNextPoll(sessionId, NORMAL_INTERVAL_MS);
|
||||
}
|
||||
} catch {
|
||||
scheduleNextPoll(sessionId, NORMAL_INTERVAL_MS);
|
||||
}
|
||||
}, intervalMs);
|
||||
|
||||
if (typeof timer.unref === "function") timer.unref();
|
||||
state.timer = timer;
|
||||
}
|
||||
|
||||
export function startQuotaMonitor(
|
||||
sessionId: string,
|
||||
provider: string,
|
||||
accountId: string,
|
||||
connection: Record<string, unknown>
|
||||
): void {
|
||||
if (!isQuotaMonitorEnabled(connection)) return;
|
||||
if (activeMonitors.has(sessionId)) return;
|
||||
|
||||
activeMonitors.set(sessionId, { timer: null, stopped: false, provider, accountId });
|
||||
scheduleNextPoll(sessionId, NORMAL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
export function stopQuotaMonitor(sessionId: string): void {
|
||||
const state = activeMonitors.get(sessionId);
|
||||
if (!state) return;
|
||||
state.stopped = true;
|
||||
if (state.timer) {
|
||||
clearTimeout(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
activeMonitors.delete(sessionId);
|
||||
for (const key of alertSuppression.keys()) {
|
||||
if (key.startsWith(`${sessionId}:`)) alertSuppression.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function getActiveMonitorCount(): number {
|
||||
return activeMonitors.size;
|
||||
}
|
||||
79
open-sse/services/quotaPreflight.ts
Normal file
79
open-sse/services/quotaPreflight.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* quotaPreflight.ts — Feature 04
|
||||
* Quota Preflight & Troca Proativa de Conta
|
||||
*
|
||||
* Toggle: providerSpecificData.quotaPreflightEnabled (default: false)
|
||||
* Providers register quota fetchers via registerQuotaFetcher().
|
||||
* Graceful degradation when no fetcher registered.
|
||||
*/
|
||||
|
||||
export interface PreflightQuotaResult {
|
||||
proceed: boolean;
|
||||
reason?: string;
|
||||
quotaPercent?: number;
|
||||
}
|
||||
|
||||
export interface QuotaInfo {
|
||||
used: number;
|
||||
total: number;
|
||||
percentUsed: number;
|
||||
}
|
||||
|
||||
export type QuotaFetcher = (connectionId: string) => Promise<QuotaInfo | null>;
|
||||
|
||||
const EXHAUSTION_THRESHOLD = 0.95;
|
||||
const WARN_THRESHOLD = 0.8;
|
||||
|
||||
const quotaFetcherRegistry = new Map<string, QuotaFetcher>();
|
||||
|
||||
export function registerQuotaFetcher(provider: string, fetcher: QuotaFetcher): void {
|
||||
quotaFetcherRegistry.set(provider, fetcher);
|
||||
}
|
||||
|
||||
export function isQuotaPreflightEnabled(connection: Record<string, unknown>): boolean {
|
||||
const psd = connection?.providerSpecificData as Record<string, unknown> | undefined;
|
||||
return psd?.quotaPreflightEnabled === true;
|
||||
}
|
||||
|
||||
export async function preflightQuota(
|
||||
provider: string,
|
||||
connectionId: string,
|
||||
connection: Record<string, unknown>
|
||||
): Promise<PreflightQuotaResult> {
|
||||
if (!isQuotaPreflightEnabled(connection)) {
|
||||
return { proceed: true };
|
||||
}
|
||||
|
||||
const fetcher = quotaFetcherRegistry.get(provider);
|
||||
if (!fetcher) {
|
||||
return { proceed: true };
|
||||
}
|
||||
|
||||
let quota: QuotaInfo | null = null;
|
||||
try {
|
||||
quota = await fetcher(connectionId);
|
||||
} catch {
|
||||
return { proceed: true };
|
||||
}
|
||||
|
||||
if (!quota) {
|
||||
return { proceed: true };
|
||||
}
|
||||
|
||||
const { percentUsed } = quota;
|
||||
|
||||
if (percentUsed >= EXHAUSTION_THRESHOLD) {
|
||||
console.info(
|
||||
`[QuotaPreflight] ${provider}/${connectionId}: ${(percentUsed * 100).toFixed(1)}% used — switching`
|
||||
);
|
||||
return { proceed: false, reason: "quota_exhausted", quotaPercent: percentUsed };
|
||||
}
|
||||
|
||||
if (percentUsed >= WARN_THRESHOLD) {
|
||||
console.warn(
|
||||
`[QuotaPreflight] ${provider}/${connectionId}: ${(percentUsed * 100).toFixed(1)}% used — approaching limit`
|
||||
);
|
||||
}
|
||||
|
||||
return { proceed: true, quotaPercent: percentUsed };
|
||||
}
|
||||
48
src/app/api/models/openrouter-catalog/route.ts
Normal file
48
src/app/api/models/openrouter-catalog/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* GET /api/models/openrouter-catalog
|
||||
* Feature 09 — Retorna catálogo OpenRouter com cache persistente.
|
||||
*
|
||||
* Query params:
|
||||
* ?refresh=true — Force-refresh, ignores TTL
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { getOpenRouterCatalog, refreshOpenRouterCatalog } from "@/lib/catalog/openrouterCatalog";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
// Require authentication (dashboard/API key)
|
||||
if (!(await isAuthenticated(req))) {
|
||||
return NextResponse.json(
|
||||
{ error: { message: "Authentication required", type: "invalid_request_error" } },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const forceRefresh = req.nextUrl.searchParams.get("refresh") === "true";
|
||||
|
||||
if (forceRefresh) {
|
||||
const result = await refreshOpenRouterCatalog();
|
||||
return NextResponse.json({
|
||||
object: "list",
|
||||
data: result.data,
|
||||
meta: {
|
||||
source: result.ok ? "fresh" : "error",
|
||||
count: result.data.length,
|
||||
error: result.error ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const result = await getOpenRouterCatalog();
|
||||
return NextResponse.json({
|
||||
object: "list",
|
||||
data: result.data,
|
||||
meta: {
|
||||
source: result.fromCache ? (result.stale ? "stale-cache" : "cache") : "fresh",
|
||||
cachedAt: result.cachedAt ?? undefined,
|
||||
stale: result.stale,
|
||||
count: result.data.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -74,6 +74,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||
rateLimitedUntil,
|
||||
lastTested,
|
||||
healthCheckInterval,
|
||||
providerSpecificData: incomingPsd,
|
||||
} = body;
|
||||
|
||||
const existing = await getProviderConnectionById(id);
|
||||
@@ -98,6 +99,15 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||
if (lastTested !== undefined) updateData.lastTested = lastTested;
|
||||
if (healthCheckInterval !== undefined) updateData.healthCheckInterval = healthCheckInterval;
|
||||
|
||||
// Merge providerSpecificData (partial update — preserve existing keys not sent by caller)
|
||||
if (incomingPsd !== undefined && incomingPsd !== null && typeof incomingPsd === "object") {
|
||||
const existingPsd =
|
||||
existing.providerSpecificData && typeof existing.providerSpecificData === "object"
|
||||
? existing.providerSpecificData
|
||||
: {};
|
||||
updateData.providerSpecificData = { ...existingPsd, ...incomingPsd };
|
||||
}
|
||||
|
||||
const updated = await updateProviderConnection(id, updateData);
|
||||
|
||||
// Hide sensitive fields
|
||||
|
||||
174
src/lib/catalog/openrouterCatalog.ts
Normal file
174
src/lib/catalog/openrouterCatalog.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* openrouterCatalog.ts — Feature 09
|
||||
* Catálogo OpenRouter com cache persistente em arquivo JSON local.
|
||||
*
|
||||
* - TTL configurável via env OPENROUTER_CATALOG_TTL_MS (default: 24h)
|
||||
* - Fallback stale-if-error: retorna último snapshot válido se fetch falhar
|
||||
* - Atualização oportunista em background (não bloqueia o caller)
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/models";
|
||||
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
function getTTL(): number {
|
||||
const env = process.env.OPENROUTER_CATALOG_TTL_MS;
|
||||
return env ? parseInt(env, 10) : DEFAULT_TTL_MS;
|
||||
}
|
||||
|
||||
function getCacheFilePath(): string {
|
||||
const dataDir = process.env.DATA_DIR || path.join(process.cwd(), "data");
|
||||
const cacheDir = path.join(dataDir, "cache");
|
||||
if (!fs.existsSync(cacheDir)) {
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
}
|
||||
return path.join(cacheDir, "openrouter-catalog.json");
|
||||
}
|
||||
|
||||
interface CatalogEntry {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
context_length?: number;
|
||||
pricing?: {
|
||||
prompt?: string;
|
||||
completion?: string;
|
||||
image?: string;
|
||||
request?: string;
|
||||
};
|
||||
top_provider?: {
|
||||
max_completion_tokens?: number;
|
||||
is_moderated?: boolean;
|
||||
};
|
||||
architecture?: {
|
||||
modality?: string;
|
||||
tokenizer?: string;
|
||||
instruct_type?: string;
|
||||
};
|
||||
created?: number;
|
||||
}
|
||||
|
||||
interface CacheFile {
|
||||
fetchedAt: string;
|
||||
data: CatalogEntry[];
|
||||
}
|
||||
|
||||
/** Read cached catalog from disk. Returns null if not found or unparseable. */
|
||||
function readCache(): CacheFile | null {
|
||||
const filePath = getCacheFilePath();
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
const raw = fs.readFileSync(filePath, "utf8");
|
||||
return JSON.parse(raw) as CacheFile;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Write catalog to disk cache. */
|
||||
function writeCache(data: CatalogEntry[]): void {
|
||||
const filePath = getCacheFilePath();
|
||||
const cache: CacheFile = {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
data,
|
||||
};
|
||||
try {
|
||||
fs.writeFileSync(filePath, JSON.stringify(cache, null, 2), "utf8");
|
||||
} catch (err) {
|
||||
console.warn("[OpenRouterCatalog] Failed to write cache:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch fresh catalog from OpenRouter API. */
|
||||
async function fetchFromAPI(): Promise<CatalogEntry[]> {
|
||||
const res = await fetch(OPENROUTER_API_URL, {
|
||||
headers: {
|
||||
"User-Agent": "OmniRoute/2.0",
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`OpenRouter API returned ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as { data?: CatalogEntry[] };
|
||||
const models = Array.isArray(json.data) ? json.data : [];
|
||||
return models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OpenRouter model catalog.
|
||||
*
|
||||
* Returns { data, stale, cachedAt } where:
|
||||
* - data: list of models
|
||||
* - stale: true if data is from a stale cache (fetch failed)
|
||||
* - cachedAt: ISO string of when the data was cached (null if fresh fetch)
|
||||
*/
|
||||
export async function getOpenRouterCatalog(): Promise<{
|
||||
data: CatalogEntry[];
|
||||
stale: boolean;
|
||||
cachedAt: string | null;
|
||||
fromCache: boolean;
|
||||
}> {
|
||||
const ttl = getTTL();
|
||||
const cache = readCache();
|
||||
const now = Date.now();
|
||||
|
||||
// Return cached data if still within TTL
|
||||
if (cache && cache.fetchedAt) {
|
||||
const age = now - new Date(cache.fetchedAt).getTime();
|
||||
if (age < ttl) {
|
||||
return {
|
||||
data: cache.data,
|
||||
stale: false,
|
||||
cachedAt: cache.fetchedAt,
|
||||
fromCache: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Cache expired or missing — attempt fresh fetch
|
||||
try {
|
||||
const data = await fetchFromAPI();
|
||||
writeCache(data);
|
||||
return { data, stale: false, cachedAt: null, fromCache: false };
|
||||
} catch (err) {
|
||||
console.warn("[OpenRouterCatalog] Fetch failed, using stale cache:", err);
|
||||
|
||||
// Stale-if-error: return old cache if available
|
||||
if (cache) {
|
||||
return {
|
||||
data: cache.data,
|
||||
stale: true,
|
||||
cachedAt: cache.fetchedAt,
|
||||
fromCache: true,
|
||||
};
|
||||
}
|
||||
|
||||
// No cache at all — return empty with error signal
|
||||
return { data: [], stale: true, cachedAt: null, fromCache: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-refresh the catalog cache (ignores TTL).
|
||||
* Used by admin endpoints and manual refresh actions.
|
||||
*/
|
||||
export async function refreshOpenRouterCatalog(): Promise<{
|
||||
data: CatalogEntry[];
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const data = await fetchFromAPI();
|
||||
writeCache(data);
|
||||
return { data, ok: true };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
return { data: [], ok: false, error };
|
||||
}
|
||||
}
|
||||
@@ -769,6 +769,8 @@ 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(),
|
||||
// Partial patch of per-connection provider-specific settings (e.g. quota toggles)
|
||||
providerSpecificData: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (Object.keys(value).length === 0) {
|
||||
|
||||
Reference in New Issue
Block a user