diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 6e7a861b77..13de1d9547 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -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 = { + "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; diff --git a/open-sse/services/quotaMonitor.ts b/open-sse/services/quotaMonitor.ts new file mode 100644 index 0000000000..9ca39ca711 --- /dev/null +++ b/open-sse/services/quotaMonitor.ts @@ -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 | null; + stopped: boolean; + provider: string; + accountId: string; +} + +const activeMonitors = new Map(); +const alertSuppression = new Map(); +// Registry mirror from quotaPreflight (same Map reference via re-export) +const quotaFetcherRegistry = new Map(); +export function registerMonitorFetcher(provider: string, fetcher: QuotaFetcher): void { + quotaFetcherRegistry.set(provider, fetcher); + registerQuotaFetcher(provider, fetcher); +} + +export function isQuotaMonitorEnabled(connection: Record): boolean { + const psd = connection?.providerSpecificData as Record | 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 +): 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; +} diff --git a/open-sse/services/quotaPreflight.ts b/open-sse/services/quotaPreflight.ts new file mode 100644 index 0000000000..a41f4482dc --- /dev/null +++ b/open-sse/services/quotaPreflight.ts @@ -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; + +const EXHAUSTION_THRESHOLD = 0.95; +const WARN_THRESHOLD = 0.8; + +const quotaFetcherRegistry = new Map(); + +export function registerQuotaFetcher(provider: string, fetcher: QuotaFetcher): void { + quotaFetcherRegistry.set(provider, fetcher); +} + +export function isQuotaPreflightEnabled(connection: Record): boolean { + const psd = connection?.providerSpecificData as Record | undefined; + return psd?.quotaPreflightEnabled === true; +} + +export async function preflightQuota( + provider: string, + connectionId: string, + connection: Record +): Promise { + 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 }; +} diff --git a/src/app/api/models/openrouter-catalog/route.ts b/src/app/api/models/openrouter-catalog/route.ts new file mode 100644 index 0000000000..dee7e8f13e --- /dev/null +++ b/src/app/api/models/openrouter-catalog/route.ts @@ -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, + }, + }); +} diff --git a/src/app/api/providers/[id]/route.ts b/src/app/api/providers/[id]/route.ts index 56d82719b5..8e92b8dbaf 100644 --- a/src/app/api/providers/[id]/route.ts +++ b/src/app/api/providers/[id]/route.ts @@ -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 diff --git a/src/lib/catalog/openrouterCatalog.ts b/src/lib/catalog/openrouterCatalog.ts new file mode 100644 index 0000000000..585f1140dd --- /dev/null +++ b/src/lib/catalog/openrouterCatalog.ts @@ -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 { + 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 }; + } +} diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 9fc6f8968a..ae54f7f52e 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -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) {