From 881f59354d3bfc77eba31620d7c86a322a43e007 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 19 Apr 2026 21:07:16 -0300 Subject: [PATCH 001/281] chore: bump version to 3.7.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index bb28534564..9387c47ca5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.6.9", + "version": "3.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.6.9", + "version": "3.7.0", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 6b2cb413e8..fcc76e04a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.6.9", + "version": "3.7.0", "description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.", "type": "module", "bin": { From 77f326e0ddcc9e5733b8264f70fa5546bc220b9a Mon Sep 17 00:00:00 2001 From: Benson K B Date: Mon, 20 Apr 2026 05:38:13 +0530 Subject: [PATCH 002/281] fix(dashboard): correct TOML round-trip corruption in codex config serializer (#1438) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.7.0. Thanks @benzntech for this great contribution! 🎉 We've removed the unrelated sync-fork.yml file and it's now merged into the release branch. --- src/app/api/cli-tools/codex-settings/route.ts | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/app/api/cli-tools/codex-settings/route.ts b/src/app/api/cli-tools/codex-settings/route.ts index deac929dae..5cf1151a1d 100644 --- a/src/app/api/cli-tools/codex-settings/route.ts +++ b/src/app/api/cli-tools/codex-settings/route.ts @@ -38,9 +38,16 @@ const parseToml = (content: string) => { // Key = value const kvMatch = trimmed.match(/^([^=]+)\s*=\s*(.+)$/); if (kvMatch) { - const key = kvMatch[1].trim(); + let key = kvMatch[1].trim(); let value = kvMatch[2].trim(); - // Remove quotes + // Strip quotes from key (TOML quoted keys like "gpt-5.3-codex") + if ( + (key.startsWith('"') && key.endsWith('"')) || + (key.startsWith("'") && key.endsWith("'")) + ) { + key = key.slice(1, -1); + } + // Remove quotes from string values only (not arrays, booleans, numbers) if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) @@ -58,13 +65,23 @@ const parseToml = (content: string) => { return result; }; +// Format a TOML value: arrays and booleans stay unquoted, strings get quoted +const formatTomlValue = (value: unknown): string => { + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number") return String(value); + // Preserve pre-formatted TOML arrays (e.g. ["a", "b"]) + if (typeof value === "string" && value.startsWith("[") && value.endsWith("]")) return value; + if (typeof value === "string") return `"${value}"`; + return `"${value}"`; +}; + // Convert parsed object back to TOML string const toToml = (parsed: Record) => { let lines: string[] = []; // Root level keys Object.entries(parsed._root).forEach(([key, value]) => { - lines.push(`${key} = "${value}"`); + lines.push(`${key} = ${formatTomlValue(value)}`); }); // Sections @@ -73,7 +90,7 @@ const toToml = (parsed: Record) => { lines.push(`[${section}]`); Object.entries(values).forEach(([key, value]) => { const formattedKey = key.includes(".") ? `"${key}"` : key; - lines.push(`${formattedKey} = "${value}"`); + lines.push(`${formattedKey} = ${formatTomlValue(value)}`); }); }); From d6cbdc158079ef547005cfbb4af485d66cab7dc8 Mon Sep 17 00:00:00 2001 From: clousky2020 <33016567+clousky2020@users.noreply.github.com> Date: Mon, 20 Apr 2026 08:13:45 +0800 Subject: [PATCH 003/281] feat: add ModelScope provider with circuit breaker and daily quota lock (#1430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.7.0. Thanks @clousky2020 for this massive and important contribution! 🎉 We've translated the deprecation comments to English for consistency, and it is now officially merged into the release branch. Great work on the ModelScope integration and Circuit Breaker! --- next.config.mjs | 1 + open-sse/config/providerRegistry.ts | 28 +- open-sse/executors/antigravity.ts | 29 +- open-sse/executors/codex.ts | 2 + open-sse/handlers/chatCore.ts | 18 +- open-sse/index.ts | 3 + open-sse/services/accountFallback.ts | 249 +++++++++++++++++- open-sse/services/cloudCodeThinking.ts | 6 + open-sse/services/combo.ts | 60 +++++ open-sse/services/errorClassifier.ts | 5 + .../combos/IntelligentComboPanel.tsx | 3 + .../dashboard/providers/[id]/page.tsx | 28 ++ .../providers/components/ModelStatusBadge.tsx | 162 ++++++++++++ .../components/ModelStatusContext.tsx | 186 +++++++++++++ src/app/(dashboard)/layout.tsx | 7 +- src/i18n/messages/en.json | 11 +- src/i18n/messages/zh-CN.json | 13 +- src/sse/handlers/chat.ts | 6 +- src/sse/handlers/chatHelpers.ts | 7 +- src/sse/services/auth.ts | 26 +- tests/integration/chat-pipeline.test.ts | 12 + tests/unit/account-fallback-service.test.ts | 218 +++++++++++++++ tests/unit/chat-route-coverage.test.ts | 5 + tests/unit/combo-routing-engine.test.ts | 46 ++++ tests/unit/error-classifier.test.ts | 18 ++ 25 files changed, 1129 insertions(+), 20 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/providers/components/ModelStatusBadge.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/components/ModelStatusContext.tsx diff --git a/next.config.mjs b/next.config.mjs index db1017ae84..375b966e96 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -28,6 +28,7 @@ const nextConfig = { "./playwright-report/**/*", "./app.__qa_backup/**/*", "./tests/**/*", + "./logs/**/*", ], }, serverExternalPackages: [ diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index b48b3dc9d9..ede6e0cdff 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -1400,6 +1400,25 @@ export const REGISTRY: Record = { passthroughModels: true, }, + modelscope: { + id: "modelscope", + alias: "ms", + format: "openai", + executor: "default", + baseUrl: "https://api-inference.modelscope.cn/v1/chat/completions", + authType: "apikey", + authHeader: "bearer", + // ModelScope uses per-model quotas. Setting passthroughModels: true ensures 429/404 + // only locks the specific model, not the entire connection. This allows fallback + // to other models on the same API key. + passthroughModels: true, + models: [ + { id: "moonshotai/Kimi-K2.5", name: "Kimi K2.5" }, + { id: "ZhipuAI/GLM-5", name: "GLM-5" }, + { id: "stepfun-ai/Step-3.5-Flash", name: "Step-3.5-Flash" }, + ], + }, + // ── New Free Providers (2026) ───────────────────────────────────────────── longcat: { @@ -2128,8 +2147,14 @@ export function getUnsupportedParams(provider: string, modelId: string): readonl const cached = _unsupportedParamsMap.get(modelId); if (cached) return cached; - // 3. Handle prefixed model IDs (e.g., "openai/o3" → "o3") + // 3. Handle prefixed model IDs (e.g., "openai/o3" → "o3", "moonshotai/Kimi-K2.5" → "moonshotai/Kimi-K2.5") + // ModelScope models have slash in ID, check both full ID and bare ID if (modelId.includes("/")) { + // First check full model ID with provider prefix (e.g., "moonshotai/Kimi-K2.5") + const cachedWithPrefix = _unsupportedParamsMap.get(modelId); + if (cachedWithPrefix) return cachedWithPrefix; + + // Fall back to bare ID (e.g., "Kimi-K2.5") const bareId = modelId.split("/").pop() || ""; const bare = _unsupportedParamsMap.get(bareId); if (bare) return bare; @@ -2154,6 +2179,7 @@ export function getProviderCategory(provider: string): "oauth" | "apikey" { * Derive the latest opus/sonnet/haiku model IDs from the `claude` registry entry. * Picks the first model whose ID matches each family pattern — registry order * determines precedence, so newer models should be listed first. + * @deprecated This function will be removed in v4.0, please use REGISTRY.claude?.models directly */ export function getClaudeCodeDefaultModels(): { opus: string; diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index b8dc191b7b..b987232735 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -735,18 +735,45 @@ export class AntigravityExecutor extends BaseExecutor { // consuming the stream. The client receives the unmodified SSE data. if (response.body) { let sseBuffer = ""; + const decoder = new TextDecoder(); // Singleton for correct streaming decode + const MAX_BUFFER_SIZE = 16 * 1024; // Limit to prevent OOM on large streams + const passThrough = new TransformStream({ transform(chunk, controller) { controller.enqueue(chunk); // Accumulate text to scan for remainingCredits try { - const text = new TextDecoder().decode(chunk, { stream: true }); + const text = decoder.decode(chunk, { stream: true }); sseBuffer += text; + // Limit buffer size to prevent unbounded growth + // Truncate only after a complete newline to avoid splitting SSE lines mid-payload + if (sseBuffer.length > MAX_BUFFER_SIZE) { + const lastNewline = sseBuffer.lastIndexOf( + "\n", + sseBuffer.length - MAX_BUFFER_SIZE + ); + if (lastNewline !== -1) { + sseBuffer = sseBuffer.slice(lastNewline + 1); + } else { + // No newline found in discard region — buffer contains an incomplete SSE line. + // Discard it entirely to avoid returning malformed data; the remainingCredits + // parser won't find valid data in a truncated line anyway. + sseBuffer = ""; + } + } } catch { /* decoding best-effort */ } }, flush() { + // Final decode for any remaining bytes + try { + const text = decoder.decode(); // Flush pending bytes + sseBuffer += text; + } catch { + /* decoding best-effort */ + } + // Parse the accumulated SSE data for remainingCredits try { const lines = sseBuffer.split("\n"); diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 899c4eb00d..fcbcbab1cd 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -271,6 +271,8 @@ function convertSystemToDeveloperRole(body: Record): void { * server-generated prefix (rs_, fc_, resp_, msg_) — so the content is * preserved but the backend won't try to look it up * 4. Always deletes previous_response_id (endpoint doesn't persist responses) + * + * @deprecated This function will be removed in v4.0, Codex executor has updated processing logic */ function stripStoredItemReferences(body: Record): void { // Always strip previous_response_id — the /codex/responses endpoint does not diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 49aefdbc8c..8206492282 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -14,7 +14,12 @@ import { createRequestLogger } from "../utils/requestLogger.ts"; import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts"; import { resolveModelAlias } from "../services/modelDeprecation.ts"; import { getUnsupportedParams } from "../config/providerRegistry.ts"; -import { hasPerModelQuota, lockModelIfPerModelQuota } from "../services/accountFallback.ts"; +import { + hasPerModelQuota, + lockModelIfPerModelQuota, + isDailyQuotaExhausted, + getMsUntilTomorrow, +} from "../services/accountFallback.ts"; import { COOLDOWN_MS } from "../config/constants.ts"; import { buildErrorBody, @@ -2109,17 +2114,22 @@ export async function handleChatCore({ } } else if (errorType === PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED) { // Providers with per-model quotas — lock the model only, not the connection + // Daily quota exhausted: lock until tomorrow; otherwise use default cooldown + const isDailyQuota = isDailyQuotaExhausted(message); + const quotaCooldownMs = isDailyQuota + ? getMsUntilTomorrow() + : retryAfterMs || COOLDOWN_MS.rateLimit; if ( lockModelIfPerModelQuota( provider, connectionId, model, - "quota_exhausted", - retryAfterMs || COOLDOWN_MS.rateLimit + isDailyQuota ? "daily_quota_exhausted" : "quota_exhausted", + quotaCooldownMs ) ) { console.warn( - `[provider] Node ${connectionId} model-only quota exhausted (${statusCode}) for ${model} - ${Math.ceil((retryAfterMs || COOLDOWN_MS.rateLimit) / 1000)}s (connection stays active)` + `[provider] Node ${connectionId} ${isDailyQuota ? "daily " : ""}quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (connection stays active)` ); } else { await updateProviderConnection(connectionId, { diff --git a/open-sse/index.ts b/open-sse/index.ts index fffb5ea17e..2c49410688 100644 --- a/open-sse/index.ts +++ b/open-sse/index.ts @@ -50,6 +50,9 @@ export { isAccountUnavailable, getUnavailableUntil, filterAvailableAccounts, + isProviderInCooldown, + getProviderCooldownRemainingMs, + getProvidersInCooldown, } from "./services/accountFallback.ts"; export { diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 95bdf510df..efadc2c25d 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -24,6 +24,30 @@ type ModelFailureState = { resetAfterMs: number; }; +// Provider-level failure tracking for circuit breaker behavior +type ProviderFailureEntry = { + failureCount: number; + lastFailureAt: number; + resetAfterMs: number; + cooldownUntil: number | null; +}; + +// Error codes that count toward provider-level failure threshold +const PROVIDER_FAILURE_ERROR_CODES = new Set([429, 408, 500, 502, 503, 504]); + +// Configuration for provider-level failure tracking +const PROVIDER_FAILURE_THRESHOLD = 5; +const PROVIDER_FAILURE_WINDOW_MS = 20 * 60 * 1000; // 20 minutes +const PROVIDER_COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes cooling + +// Provider-level failure state map: providerId -> failure entry +const providerFailureState = new Map(); +// Guard against synchronous re-entrant calls within the same event-loop tick. +// NOT a true mutex — Node.js is single-threaded, so different SSE streams +// can interleave across ticks. This Set prevents a single call from recursively +// re-entering recordProviderFailure within the same synchronous call stack. +const providerFailureLocks = new Set(); + // T06 (sub2api PR #1037): Signals that indicate permanent account deactivation. // When a 401 body contains these strings, the account is permanently dead // and should NOT be retried after token refresh. @@ -82,8 +106,12 @@ const CONTEXT_OVERFLOW_PATTERNS = [ const MALFORMED_REQUEST_PATTERNS = [ /\bimproperly formed request\b/i, /\binvalid.*message.*format/i, - /\bmessages must alternate/i, - /\bempty (message|content)/i, + /\bmessages must alternate\b/i, + /\bempty (message|content)\b/i, + // Tool call function name errors + /\bfunction'?s? name (?:can't|can not|is|has) (?:blank|empty|missing)/i, + /function.*name.*(?:blank|empty|missing)/i, + /tool_call.*name.*(?:blank|empty|missing)/i, ]; /** @@ -355,11 +383,21 @@ export function clearModelLock(provider, connectionId, model) { * Compatible and passthrough providers multiplex multiple upstream models behind one * connection, so transient 404/429 responses should stay model-scoped instead of * poisoning the whole connection. + * + * @param provider - Provider ID + * @param _model - Model ID (reserved for future use) + * @param connectionPassthroughModels - Optional per-connection override from providerSpecificData. + * When provided, takes precedence over registry/provider-level logic. */ export function hasPerModelQuota( provider: string | null | undefined, - _model: string | null | undefined = null + _model: string | null | undefined = null, + connectionPassthroughModels?: boolean ): boolean { + // Connection-level override takes precedence (e.g., user-configured ModelScope) + if (typeof connectionPassthroughModels === "boolean") { + return connectionPassthroughModels; + } if (!provider) return false; if (provider === "gemini") return true; if (getPassthroughProviders().has(provider)) return true; @@ -376,18 +414,23 @@ export function lockModelIfPerModelQuota( connectionId: string, model: string | null, reason: string, - cooldownMs: number + cooldownMs: number, + connectionPassthroughModels?: boolean ): boolean { - if (!hasPerModelQuota(provider, model) || !model) return false; + if (!hasPerModelQuota(provider, model, connectionPassthroughModels) || !model) return false; + // Skip model-level lock if the entire provider is in circuit-breaker cooldown. + // The provider cooldown already prevents all requests, so a model lock is redundant. + if (isProviderInCooldown(provider)) return false; lockModel(provider, connectionId, model, reason, cooldownMs); return true; } export function shouldMarkAccountExhaustedFrom429( provider: string | null | undefined, - model: string | null | undefined = null + model: string | null | undefined = null, + connectionPassthroughModels?: boolean ): boolean { - return !hasPerModelQuota(provider, model); + return !hasPerModelQuota(provider, model, connectionPassthroughModels); } /** @@ -442,6 +485,145 @@ export function getAllModelLockouts() { return active; } +// ─── Provider-Level Failure Tracking ───────────────────────────────────────── +// Track failures at provider level: when a provider has too many transient failures +// across all its connections, cooldown the entire provider temporarily. + +/** + * Check if a provider is currently in cooldown due to too many failures + */ +export function isProviderInCooldown(provider: string | null | undefined): boolean { + if (!provider) return false; + const entry = providerFailureState.get(provider); + if (!entry) return false; + + // If in cooldown, check if it has expired + if (entry.cooldownUntil !== null && Date.now() >= entry.cooldownUntil) { + providerFailureState.delete(provider); + return false; + } + + return entry.cooldownUntil !== null; +} + +/** + * Get remaining cooldown time for a provider + */ +export function getProviderCooldownRemainingMs(provider: string | null | undefined): number | null { + if (!provider) return null; + const entry = providerFailureState.get(provider); + if (!entry || entry.cooldownUntil === null) return null; + + const remaining = entry.cooldownUntil - Date.now(); + return remaining > 0 ? remaining : null; +} + +/** + * Record a failure for a provider. When threshold is reached within the window, + * the provider enters cooldown. + */ +export function recordProviderFailure( + provider: string | null | undefined, + log?: { warn?: (...args: unknown[]) => void } +): void { + if (!provider) return; + + // Guard against concurrent re-entrant calls within the same tick + if (providerFailureLocks.has(provider)) return; + providerFailureLocks.add(provider); + + try { + const now = Date.now(); + const entry = providerFailureState.get(provider); + + // Check if we're in cooldown period + if (entry && entry.cooldownUntil !== null && now < entry.cooldownUntil) { + return; // Already in cooldown, don't record + } + + // Check if failure window has expired + if (entry && now - entry.lastFailureAt > entry.resetAfterMs) { + // Window expired, reset count + providerFailureState.set(provider, { + failureCount: 1, + lastFailureAt: now, + resetAfterMs: PROVIDER_FAILURE_WINDOW_MS, + cooldownUntil: null, + }); + return; + } + + // Increment failure count + const newCount = entry ? entry.failureCount + 1 : 1; + + if (newCount >= PROVIDER_FAILURE_THRESHOLD) { + // Threshold reached, enter cooldown + const cooldownUntil = now + PROVIDER_COOLDOWN_MS; + providerFailureState.set(provider, { + failureCount: newCount, + lastFailureAt: now, + resetAfterMs: PROVIDER_FAILURE_WINDOW_MS, + cooldownUntil, + }); + log?.warn?.( + `[ProviderFailure] ${provider}: ${newCount} failures in ${PROVIDER_FAILURE_WINDOW_MS / 1000}s — entering ${PROVIDER_COOLDOWN_MS / 1000}s cooldown` + ); + } else { + // Just increment counter + providerFailureState.set(provider, { + failureCount: newCount, + lastFailureAt: now, + resetAfterMs: PROVIDER_FAILURE_WINDOW_MS, + cooldownUntil: null, + }); + } + } finally { + providerFailureLocks.delete(provider); + } +} + +/** + * Clear provider failure state (e.g., after successful request) + */ +export function clearProviderFailure(provider: string | null | undefined): void { + if (!provider) return; + providerFailureState.delete(provider); +} + +/** + * Get all providers currently in cooldown (for debugging/dashboard) + */ +export function getProvidersInCooldown(): Array<{ + provider: string; + failureCount: number; + cooldownRemainingMs: number | null; + lastFailureAt: number; +}> { + const result = []; + for (const [provider, entry] of providerFailureState) { + if (entry.cooldownUntil === null) continue; + const remaining = entry.cooldownUntil - Date.now(); + if (remaining <= 0) { + providerFailureState.delete(provider); + continue; + } + result.push({ + provider, + failureCount: entry.failureCount, + cooldownRemainingMs: remaining, + lastFailureAt: entry.lastFailureAt, + }); + } + return result; +} + +/** + * Check if a status code should be counted toward provider failure threshold + */ +export function isProviderFailureCode(status: number): boolean { + return PROVIDER_FAILURE_ERROR_CODES.has(status); +} + // ─── Retry-After Parsing ──────────────────────────────────────────────────── /** @@ -625,6 +807,40 @@ export function classifyError(status, errorText) { return RateLimitReason.UNKNOWN; } +// ─── Daily Quota Helpers ──────────────────────────────────────────────────── + +/** + * Calculate milliseconds from now until tomorrow at midnight (00:00:00). + * Used to lock a model until the next day when daily quota is exhausted. + * @returns {number} Milliseconds until tomorrow + */ +export function getMsUntilTomorrow(): number { + const now = new Date(); + const tomorrow = new Date(now); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(0, 0, 0, 0); + const ms = tomorrow.getTime() - now.getTime(); + // Guard against DST edge cases: if ms is negative (shouldn't happen) or + // unreasonably large (>25h due to spring-forward), cap at 24 hours. + return ms > 0 && ms <= 25 * 60 * 60 * 1000 ? ms : 24 * 60 * 60 * 1000; +} + +/** + * Check if error text indicates daily quota exhaustion (as opposed to rate limiting). + * Daily quota errors typically mention "today's quota" or "try again tomorrow". + * @param {string} errorText - Error message text + * @returns {boolean} True if daily quota is exhausted + */ +export function isDailyQuotaExhausted(errorText: string): boolean { + if (!errorText) return false; + const lower = errorText.toLowerCase(); + return ( + lower.includes("today's quota") || + lower.includes("daily quota") || + lower.includes("try again tomorrow") + ); +} + // ─── Configurable Backoff ─────────────────────────────────────────────────── /** @@ -681,6 +897,12 @@ export function checkFallbackError( const errorStr = (errorText || "").toString(); const profile = profileOverride ?? (provider ? getProviderProfile(provider) : null); + // Track provider-level failures for circuit breaker behavior + // Only count transient errors that are likely to recover + if (isProviderFailureCode(status)) { + recordProviderFailure(provider); + } + function parseResetFromHeaders(headers, errorStr = "") { if (!headers) return null; @@ -737,6 +959,19 @@ export function checkFallbackError( }; } + // Daily quota exhausted — lock model until tomorrow + if (isDailyQuotaExhausted(errorStr)) { + const msUntilTomorrow = getMsUntilTomorrow(); + // Cap at 24 hours to handle timezone edge cases + const cooldownMs = Math.min(msUntilTomorrow, 24 * 60 * 60 * 1000); + return { + shouldFallback: true, + cooldownMs, + reason: RateLimitReason.QUOTA_EXHAUSTED, + dailyQuotaExhausted: true, + }; + } + if (lowerError.includes("no credentials")) { return { shouldFallback: true, diff --git a/open-sse/services/cloudCodeThinking.ts b/open-sse/services/cloudCodeThinking.ts index 769c414d7b..9d3b3b944a 100644 --- a/open-sse/services/cloudCodeThinking.ts +++ b/open-sse/services/cloudCodeThinking.ts @@ -23,6 +23,9 @@ function stripGeminiThinkingConfig(value: unknown): unknown { return next; } +/** + * @deprecated This function will be removed in v4.0, reasoning configuration processing has migrated to translateRequest + */ export function shouldStripCloudCodeThinking(provider: string, model: string): boolean { if (!provider || !model) return false; const normalizedModel = normalizeCloudCodeModel(model); @@ -33,6 +36,9 @@ export function shouldStripCloudCodeThinking(provider: string, model: string): b return CLOUD_CODE_REASONING_UNSUPPORTED_PATTERNS.some((pattern) => pattern.test(normalizedModel)); } +/** + * @deprecated This function will be removed in v4.0, reasoning configuration processing has migrated to translateRequest + */ export function stripCloudCodeThinkingConfig( body: Record ): Record { diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index dcbd63a457..796a8a58da 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -59,6 +59,47 @@ const COMBO_BAD_REQUEST_FALLBACK_PATTERNS = [ /unsupported content part type/i, /tool(?:_call|_use)? .* not (?:available|found)/i, /third-party apps/i, + // Context overflow — model-specific, may succeed on a model with larger context window + /context overflow/i, + /context length exceeded/i, + /prompt too large/i, + /token limit/i, + /too many tokens/i, + /exceeds? context/i, + /maximum context/i, + /input too long/i, + /messages? exceed/i, + // Model not supported/found — permanent model-level error, try next combo target + /no provider supported/i, + /model not found/i, + /model not available/i, + /unsupported model/i, + /model.*has no provider/i, + // Function calling format error — model doesn't support this capability + /function\.?arguments.*(must be|should be|必须).*(json|JSON)/i, + /tool.*arguments.*invalid/i, + /function.*parameter.*(invalid|format)/i, + // Input length range error — model-specific context limit + /range of input length/i, + /input length should be/i, + // Transient 400 errors from upstream — should fallback to next combo target + /服务遇到了一点小状况/i, // ModelScope/Qwen transient error + /抱歉.*?敏感内容.*?请检查/i, // ModelScope/Qwen content moderation with context + /内容.*?敏感.*?(?:无法|过滤)/i, // Content sensitivity block + /无法响应.*?请求/i, // "unable to respond to request" + /稍后重试/i, // "retry later" in Chinese + /temporary.*error/i, + /transient.*error/i, + /service.*unavailable/i, + /please.*try.*again/i, + // Rate limit errors — some providers return 400 instead of 429 + /\brate.?-?limit.?(?:exceeded|reached|hit)/i, + /too many requests/i, + /请求过于频繁/i, // Chinese rate limit message + // Tool call function name errors — model-specific, try next combo target + /\bfunction'?s? name (?:can't|can not|is|has) (?:blank|empty|missing)/i, + /function.*name.*(?:blank|empty|missing)/i, + /tool_call.*name.*(?:blank|empty|missing)/i, ]; // Patterns that signal all accounts for a provider are rate-limited / exhausted. @@ -77,6 +118,7 @@ function isAllAccountsRateLimitedResponse( const MAX_COMBO_DEPTH = 3; const MAX_FALLBACK_WAIT_MS = 5000; +const MAX_GLOBAL_ATTEMPTS = 30; function comboModelNotFoundResponse(message: string) { return errorResponse(404, message); @@ -1451,6 +1493,7 @@ export async function handleComboChat({ let earliestRetryAfter = null; let lastStatus = null; const startTime = Date.now(); + globalAttempts = 0; let fallbackCount = 0; let recordedAttempts = 0; @@ -1484,6 +1527,14 @@ export async function handleComboChat({ // Retry loop for transient errors for (let retry = 0; retry <= maxRetries; retry++) { + globalAttempts++; + if (globalAttempts > MAX_GLOBAL_ATTEMPTS) { + log.warn( + "COMBO", + `Maximum combo attempts (${MAX_GLOBAL_ATTEMPTS}) exceeded across all targets and fallbacks. Terminating loop to prevent runaway background requests.` + ); + return errorResponse(503, "Maximum combo retry limit reached"); + } if (retry > 0) { log.info( "COMBO", @@ -1799,6 +1850,7 @@ async function handleRoundRobinCombo({ let lastError = null; let lastStatus = null; let earliestRetryAfter = null; + let globalAttempts = 0; let fallbackCount = 0; let recordedAttempts = 0; @@ -1852,6 +1904,14 @@ async function handleRoundRobinCombo({ // Retry loop within this model try { for (let retry = 0; retry <= maxRetries; retry++) { + globalAttempts++; + if (globalAttempts > MAX_GLOBAL_ATTEMPTS) { + log.warn( + "COMBO-RR", + `Maximum combo attempts (${MAX_GLOBAL_ATTEMPTS}) exceeded. Terminating loop to prevent runaway requests.` + ); + return errorResponse(503, "Maximum combo retry limit reached"); + } if (retry > 0) { log.info( "COMBO-RR", diff --git a/open-sse/services/errorClassifier.ts b/open-sse/services/errorClassifier.ts index 39d513ffbe..4520c2a6e4 100644 --- a/open-sse/services/errorClassifier.ts +++ b/open-sse/services/errorClassifier.ts @@ -1,6 +1,7 @@ import { isAccountDeactivated, isCreditsExhausted, + isDailyQuotaExhausted, isOAuthInvalidToken, } from "./accountFallback.ts"; @@ -100,7 +101,11 @@ export function classifyProviderError(statusCode: number, responseBody: unknown) return PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED; } + // 429: Check if it's a daily quota exhaustion (lock until tomorrow) vs regular rate limit if (statusCode === 429) { + if (isDailyQuotaExhausted(bodyStr)) { + return PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED; + } return PROVIDER_ERROR_TYPES.RATE_LIMITED; } diff --git a/src/app/(dashboard)/dashboard/combos/IntelligentComboPanel.tsx b/src/app/(dashboard)/dashboard/combos/IntelligentComboPanel.tsx index 0a22d20e07..55013eab4d 100644 --- a/src/app/(dashboard)/dashboard/combos/IntelligentComboPanel.tsx +++ b/src/app/(dashboard)/dashboard/combos/IntelligentComboPanel.tsx @@ -11,6 +11,7 @@ import { extractIntelligentHealthState, normalizeIntelligentRoutingConfig, } from "@/lib/combos/intelligentRouting"; +import ModelStatusBadge from "@/app/(dashboard)/dashboard/providers/components/ModelStatusBadge"; function getI18nOrFallback(t: any, key: string, fallback: string) { if (typeof t?.has === "function" && t.has(key)) return t(key); @@ -283,6 +284,8 @@ export default function IntelligentComboPanel({

{entry.model}

+ {/* Model status badge - shows cooldown/error state */} + {percentage}% diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 2b9d0abfc4..9164e05718 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -53,6 +53,7 @@ import { getCodexRequestDefaults as _getCodexRequestDefaults, } from "@/lib/providers/requestDefaults"; import { resolveDashboardProviderInfo } from "../providerPageUtils"; +import ModelStatusBadge from "@/app/(dashboard)/dashboard/providers/components/ModelStatusBadge"; type CompatByProtocolMap = Partial< Record< @@ -326,6 +327,7 @@ function anyUpstreamHeadersBadge( interface ModelRowProps { model: { id: string; name?: string; source?: string; isHidden?: boolean }; fullModel: string; + provider: string; copied?: string; onCopy: (text: string, key: string) => void; t: (key: string, values?: Record) => string; @@ -2460,6 +2462,7 @@ export default function ProviderDetailPage() { key={model.id} model={model} fullModel={`${providerDisplayAlias}/${model.id}`} + provider={providerId} copied={copied} onCopy={copy} t={t} @@ -3329,6 +3332,7 @@ export default function ProviderDetailPage() { function ModelRow({ model, fullModel, + provider, copied, onCopy, t, @@ -3358,6 +3362,7 @@ function ModelRow({ {fullModel} + - - {/* Expanded popover */} - {expanded && ( -
-
-
- - {t("modelStatus")} -
- -
- -
- {isHealthy ? ( -

{t("allModelsNormal")}

- ) : ( -
- {Object.entries(byProvider).map(([provider, provModels]) => ( -
-

- {provider} -

-
- {provModels.map((m) => { - const status = - STATUS_CONFIG[m.status as keyof typeof STATUS_CONFIG] || - STATUS_CONFIG.unknown; - const isClearing = clearing === `${m.provider}:${m.model}`; - return ( -
-
- - - {m.model} - -
- {m.status === "cooldown" && ( - - )} -
- ); - })} -
-
- ))} -
- )} -
-
- )} - - ); -} diff --git a/src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityPanel.tsx b/src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityPanel.tsx deleted file mode 100644 index 2ede152ba5..0000000000 --- a/src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityPanel.tsx +++ /dev/null @@ -1,203 +0,0 @@ -"use client"; - -/** - * ModelAvailabilityPanel — Batch B - * - * Shows real-time model availability and cooldown status. - * Fetched from /api/models/availability. - */ - -import { useState, useEffect, useCallback } from "react"; -import { useTranslations } from "next-intl"; -import { Card, Button } from "@/shared/components"; -import { useNotificationStore } from "@/store/notificationStore"; - -export default function ModelAvailabilityPanel() { - const t = useTranslations("providers"); - const tc = useTranslations("common"); - - const STATUS_CONFIG = { - available: { icon: "check_circle", color: "#22c55e", label: t("available") }, - cooldown: { icon: "schedule", color: "#f59e0b", label: t("cooldown") }, - unavailable: { icon: "error", color: "#ef4444", label: t("unavailable") }, - unknown: { icon: "help", color: "#6b7280", label: t("unknown") }, - }; - - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [clearing, setClearing] = useState(null); - const notify = useNotificationStore(); - - const fetchStatus = useCallback(async () => { - try { - const res = await fetch("/api/models/availability"); - if (res.ok) { - const json = await res.json(); - setData(json); - } - } catch { - // silent fail — will retry - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - fetchStatus(); - const interval = setInterval(fetchStatus, 30000); - return () => clearInterval(interval); - }, [fetchStatus]); - - const handleClearCooldown = async (provider: string, model: string) => { - setClearing(`${provider}:${model}`); - try { - const res = await fetch("/api/models/availability", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "clearCooldown", provider, model }), - }); - if (res.ok) { - notify.success(t("cooldownCleared", { model })); - await fetchStatus(); - } else { - notify.error(t("failedClearCooldown")); - } - } catch { - notify.error(t("failedClearCooldown")); - } finally { - setClearing(null); - } - }; - - if (loading) { - return ( - -
- - {t("loadingAvailability")} -
-
- ); - } - - const models = data?.models || []; - const unavailableCount = - data?.unavailableCount || models.filter((m: any) => m.status !== "available").length; - - if (models.length === 0 || unavailableCount === 0) { - return ( - -
-
- -
-
-

{t("modelAvailability")}

-

{t("allModelsOperational")}

-
-
-
- ); - } - - // Group by provider - const byProvider: Record = {}; - models.forEach((m: any) => { - if (m.status === "available") return; - const key = m.provider || "unknown"; - if (!byProvider[key]) byProvider[key] = []; - byProvider[key].push(m); - }); - - return ( - -
-
-
- -
-
-

{t("modelAvailability")}

-

- {t("modelsWithIssues", { count: unavailableCount })} -

-
-
- -
- -
- {Object.entries(byProvider).map(([provider, provModels]) => ( -
-

{provider}

-
- {provModels.map((m) => { - const status = - STATUS_CONFIG[m.status as keyof typeof STATUS_CONFIG] || STATUS_CONFIG.unknown; - const isClearing = clearing === `${m.provider}:${m.model}`; - return ( -
-
- - {m.model} - - {status.label} - - {m.cooldownUntil && ( - - {t("until", { time: new Date(m.cooldownUntil).toLocaleTimeString() })} - - )} -
- {m.status === "cooldown" && ( - - )} -
- ); - })} -
-
- ))} -
-
- ); -} diff --git a/src/app/(dashboard)/dashboard/providers/components/ModelStatusBadge.tsx b/src/app/(dashboard)/dashboard/providers/components/ModelStatusBadge.tsx deleted file mode 100644 index 82538a6d8d..0000000000 --- a/src/app/(dashboard)/dashboard/providers/components/ModelStatusBadge.tsx +++ /dev/null @@ -1,162 +0,0 @@ -"use client"; - -/** - * ModelStatusBadge — compact single-model status indicator - * - * Shows a small badge with status icon (cooldown/unavailable/error) - * with a tooltip containing additional details like remaining cooldown time - * or last error message. - * Only renders for non-available models to keep the UI clean. - * - * Uses shared polling via ModelStatusContext to avoid redundant API calls. - */ - -import { useState, useEffect, useRef } from "react"; -import { useTranslations } from "next-intl"; -import Tooltip from "@/shared/components/Tooltip"; -import { useModelStatus } from "./ModelStatusContext"; - -interface ModelStatusBadgeProps { - provider: string; - model: string; - size?: "sm" | "md"; - className?: string; -} - -function formatRemainingTime(ms: number): string { - if (ms <= 0) return ""; - const seconds = Math.ceil(ms / 1000); - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - if (minutes < 60) { - return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; - } - const hours = Math.floor(minutes / 60); - const remainingMinutes = minutes % 60; - return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; -} - -export default function ModelStatusBadge({ - provider, - model, - size = "sm", - className = "", -}: ModelStatusBadgeProps) { - const t = useTranslations("providers"); - const status = useModelStatus(provider, model); - - // Store the latest remaining time calculated by the interval - const [displayRemainingMs, setDisplayRemainingMs] = useState(null); - - // Use ref to track server-provided initial cooldown duration for countdown calculation - const initialCooldownMsRef = useRef(null); - // Track when we started counting down - const countdownStartRef = useRef(null); - - // Set up countdown timer when cooldown begins - useEffect(() => { - if (status?.status !== "cooldown" || !status.remainingMs) { - // Reset refs when not in cooldown (no setState here) - initialCooldownMsRef.current = null; - countdownStartRef.current = null; - return; - } - - // Initialize timing refs (no setState here) - initialCooldownMsRef.current = status.remainingMs; - countdownStartRef.current = Date.now(); - - // Update countdown every second via interval callback (setState allowed here) - const interval = setInterval(() => { - if (!initialCooldownMsRef.current || !countdownStartRef.current) { - return; - } - - const elapsed = Date.now() - countdownStartRef.current; - const newRemaining = Math.max(0, initialCooldownMsRef.current - elapsed); - - setDisplayRemainingMs(newRemaining); - - // Stop updating when countdown reaches zero - if (newRemaining === 0) { - clearInterval(interval); - } - }, 1000); - - return () => clearInterval(interval); - }, [status?.status, status?.remainingMs]); - - // Derive the displayed remaining time: use countdown value if active, else use status value - const remainingMs = - status?.status === "cooldown" ? (displayRemainingMs ?? status.remainingMs ?? null) : null; - - // Don't render badge for available models (keep UI clean) - if (!status || status.status === "available" || status.status === "unknown") { - return null; - } - - const getStatusColor = () => { - switch (status.status) { - case "cooldown": - return "#f59e0b"; - case "unavailable": - case "error": - return "#ef4444"; - default: - return "#6b7280"; - } - }; - - const getStatusIcon = () => { - switch (status.status) { - case "cooldown": - return "schedule"; - case "unavailable": - case "error": - return "error"; - default: - return "help"; - } - }; - - const getTooltipText = () => { - switch (status.status) { - case "cooldown": { - const remaining = remainingMs !== null ? formatRemainingTime(remainingMs) : ""; - const reason = status.reason ? ` (${status.reason})` : ""; - const remainingText = remaining ? ` - ${remaining}` : ""; - return `${t("cooldown")}${reason}${remainingText}`; - } - case "unavailable": - return `${t("unavailable")}${status.reason ? `: ${status.reason}` : ""}`; - case "error": - return `${t("error")}${status.lastError ? `: ${status.lastError}` : ""}`; - default: - return ""; - } - }; - - const color = getStatusColor(); - const sizeClasses = size === "sm" ? "px-1.5 py-0.5" : "px-2 py-1"; - const iconSize = size === "sm" ? "text-[12px]" : "text-[14px]"; - - return ( - - - - {status.status === "cooldown" && remainingMs !== null && ( - {formatRemainingTime(remainingMs)} - )} - - - ); -} diff --git a/src/app/(dashboard)/dashboard/providers/components/ModelStatusContext.tsx b/src/app/(dashboard)/dashboard/providers/components/ModelStatusContext.tsx deleted file mode 100644 index 3605a9defd..0000000000 --- a/src/app/(dashboard)/dashboard/providers/components/ModelStatusContext.tsx +++ /dev/null @@ -1,186 +0,0 @@ -"use client"; - -/** - * ModelStatusContext — shared polling for model availability - * - * Prevents redundant API calls by having all ModelStatusBadge components - * share a single polling interval. Only one request is made every 15 seconds - * regardless of how many badges are on the page. - */ - -import React, { createContext, useContext, useEffect, useRef, useCallback, useMemo } from "react"; - -export interface ModelStatus { - status: "available" | "cooldown" | "unavailable" | "error" | "unknown"; - reason?: string; - remainingMs?: number; - lastError?: string; -} - -interface ModelStatusContextValue { - getStatus: (provider: string, model: string) => ModelStatus | null; - registerModel: (key: string, provider: string, model: string) => void; - unregisterModel: (key: string) => void; -} - -const ModelStatusContext = createContext(null); - -// Global map of model key -> status -let modelStatusMap = new Map(); -// Global set of registered model keys -let registeredModels = new Set(); -// Polling interval ref (singleton) -let pollIntervalRef: NodeJS.Timeout | null = null; - -function getModelKey(provider: string, model: string): string { - return `${provider}/${model}`; -} - -async function fetchModelStatus(): Promise { - try { - const res = await fetch("/api/models/availability"); - if (!res.ok) return; - - const json = await res.json(); - const models = json?.models || []; - - // Update all registered models with fresh data - const now = Date.now(); - registeredModels.forEach((key) => { - const [provider, model] = key.split("/"); - // Use exact matching first to avoid gpt-4 matching gpt-4-turbo incorrectly - const modelEntry = - models.find((m: any) => m.provider === provider && m.model === model) || - models.find( - // Fallback to prefix matching only for models that contain the registered key - // This handles cases like "gpt-4o" matching badge for "gpt-4" - (m: any) => - m.provider === provider && - m.model && - model && - (m.model.startsWith(model + "-") || model.startsWith(m.model + "-")) - ); - - if (modelEntry) { - const newStatus: ModelStatus = { - status: modelEntry.status || "unknown", - reason: modelEntry.reason, - remainingMs: modelEntry.remainingMs, - lastError: modelEntry.lastError, - }; - - // For cooldown status, calculate remaining time based on server-provided value - if (modelEntry.status === "cooldown" && modelEntry.remainingMs) { - newStatus.remainingMs = modelEntry.remainingMs; - } - - modelStatusMap.set(key, newStatus); - } else { - modelStatusMap.set(key, { status: "available" }); - } - }); - - // Trigger re-render by dispatching custom event - window.dispatchEvent(new CustomEvent("model-status-update")); - } catch { - // Best-effort polling - } -} - -function ensurePolling(): void { - if (pollIntervalRef) return; - - // Initial fetch - fetchModelStatus(); - - // Poll every 15 seconds - pollIntervalRef = setInterval(fetchModelStatus, 15000); -} - -function stopPolling(): void { - if (pollIntervalRef) { - clearInterval(pollIntervalRef); - pollIntervalRef = null; - } -} - -export function ModelStatusProvider({ children }: { children: React.ReactNode }) { - const [, forceUpdate] = React.useState(0); - - // Listen for status updates from the global poller - useEffect(() => { - const handleUpdate = () => forceUpdate((n) => n + 1); - window.addEventListener("model-status-update", handleUpdate); - return () => window.removeEventListener("model-status-update", handleUpdate); - }, []); - - // Cleanup on unmount — stop polling only when no models remain registered - useEffect(() => { - return () => { - if (registeredModels.size === 0) { - stopPolling(); - } - }; - }, []); - - const getStatus = useCallback((provider: string, model: string): ModelStatus | null => { - const key = getModelKey(provider, model); - return modelStatusMap.get(key) || null; - }, []); - - const registerModel = useCallback((key: string, provider: string, model: string): void => { - const wasEmpty = registeredModels.size === 0; - registeredModels.add(key); - - // Start polling when first model registers - if (wasEmpty) { - ensurePolling(); - } - - // Immediately fetch if no data yet - if (!modelStatusMap.has(key)) { - fetchModelStatus(); - } - }, []); - - const unregisterModel = useCallback((key: string): void => { - registeredModels.delete(key); - modelStatusMap.delete(key); - - // Stop polling when last model unregisters - if (registeredModels.size === 0) { - stopPolling(); - } - }, []); - - const value = useMemo( - () => ({ - getStatus, - registerModel, - unregisterModel, - }), - [getStatus, registerModel, unregisterModel] - ); - - return {children}; -} - -export function useModelStatus(provider: string, model: string): ModelStatus | null { - const context = useContext(ModelStatusContext); - - if (!context) { - throw new Error("useModelStatus must be used within a ModelStatusProvider"); - } - - const key = useMemo(() => getModelKey(provider, model), [provider, model]); - - // Register/unregister on mount/unmount - useEffect(() => { - context.registerModel(key, provider, model); - return () => context.unregisterModel(key); - }, [context, key, provider, model]); - - return context.getStatus(provider, model); -} - -export default ModelStatusContext; diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index de86e770cb..e509322fa6 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -26,7 +26,6 @@ import { getErrorCode, getRelativeTime } from "@/shared/utils"; import { pickDisplayValue } from "@/shared/utils/maskEmail"; import useEmailPrivacyStore from "@/store/emailPrivacyStore"; import { useNotificationStore } from "@/store/notificationStore"; -import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge"; import { useTranslations } from "next-intl"; import { buildMergedOAuthProviderEntries, @@ -496,7 +495,6 @@ export default function ProvidersPage() {
- = { "context-relay": "Context Relay", }; +const LEGACY_COMBO_RESILIENCE_KEYS = new Set([ + "timeoutMs", + "healthCheckEnabled", + "healthCheckTimeoutMs", +]); + function translateOrFallback( t: ReturnType, key: string, @@ -18,14 +24,31 @@ function translateOrFallback( return typeof t.has === "function" && t.has(key) ? t(key) : fallback; } +function sanitizeComboRuntimeConfig(config?: Record | null) { + if (!config || typeof config !== "object") return {}; + return Object.fromEntries( + Object.entries(config).filter( + ([key, value]) => + value !== undefined && value !== null && !LEGACY_COMBO_RESILIENCE_KEYS.has(key) + ) + ); +} + +function sanitizeProviderOverrides(overrides?: Record | null) { + if (!overrides || typeof overrides !== "object") return {}; + return Object.fromEntries( + Object.entries(overrides).map(([providerId, config]) => [ + providerId, + sanitizeComboRuntimeConfig(config), + ]) + ); +} + export default function ComboDefaultsTab() { const [comboDefaults, setComboDefaults] = useState({ strategy: "priority", maxRetries: 1, retryDelayMs: 2000, - timeoutMs: 120000, - healthCheckEnabled: true, - healthCheckTimeoutMs: 3000, maxComboDepth: 3, trackMetrics: true, handoffThreshold: 0.85, @@ -54,7 +77,6 @@ export default function ComboDefaultsTab() { const numericSettings = [ { key: "maxRetries", label: t("maxRetriesLabel"), min: 0, max: 5 }, { key: "retryDelayMs", label: t("retryDelayLabel"), min: 500, max: 10000, step: 500 }, - { key: "timeoutMs", label: t("timeoutLabel"), min: 5000, step: 5000 }, { key: "maxComboDepth", label: t("maxNestingDepth"), min: 1, max: 10 }, ]; @@ -66,7 +88,7 @@ export default function ComboDefaultsTab() { .then(([comboData, settingsData]) => { setComboDefaults((prev) => ({ ...prev, - ...(comboData.comboDefaults || {}), + ...sanitizeComboRuntimeConfig(comboData.comboDefaults), strategy: settingsData.fallbackStrategy ?? comboData.comboDefaults?.strategy ?? prev.strategy, stickyRoundRobinLimit: @@ -74,7 +96,9 @@ export default function ComboDefaultsTab() { comboData.comboDefaults?.stickyRoundRobinLimit ?? prev.stickyRoundRobinLimit, })); - if (comboData.providerOverrides) setProviderOverrides(comboData.providerOverrides); + if (comboData.providerOverrides) { + setProviderOverrides(sanitizeProviderOverrides(comboData.providerOverrides)); + } }) .catch((err) => console.error("Failed to fetch combo defaults:", err)); }, []); @@ -116,7 +140,10 @@ export default function ComboDefaultsTab() { const comboDefaultsRes = await fetch("/api/settings/combo-defaults", { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ comboDefaults: comboDefaultsPayload, providerOverrides }), + body: JSON.stringify({ + comboDefaults: sanitizeComboRuntimeConfig(comboDefaultsPayload), + providerOverrides: sanitizeProviderOverrides(providerOverrides), + }), }); if (!comboDefaultsRes.ok) { @@ -136,7 +163,7 @@ export default function ComboDefaultsTab() { const addProviderOverride = () => { const name = newOverrideProvider.trim().toLowerCase(); if (!name || providerOverrides[name]) return; - setProviderOverrides((prev) => ({ ...prev, [name]: { maxRetries: 1, timeoutMs: 120000 } })); + setProviderOverrides((prev) => ({ ...prev, [name]: { maxRetries: 1 } })); setNewOverrideProvider(""); }; @@ -381,21 +408,6 @@ export default function ComboDefaultsTab() { {/* Toggles */}
-
-
-

{t("healthCheck")}

-

{t("healthCheckDesc")}

-
- - setComboDefaults((prev) => ({ - ...prev, - healthCheckEnabled: !prev.healthCheckEnabled, - })) - } - /> -

{t("trackMetrics")}

@@ -436,24 +448,6 @@ export default function ComboDefaultsTab() { aria-label={t("providerMaxRetriesAria", { provider })} /> {t("retries")} - - setProviderOverrides((prev) => ({ - ...prev, - [provider]: { - ...prev[provider], - timeoutMs: parseInt(e.target.value) || 120000, - }, - })) - } - className="text-xs w-24" - aria-label={t("providerTimeoutAria", { provider })} - /> - {t("ms")}
@@ -106,7 +98,7 @@ export default function PoliciesPanel() { gpp_maybe
-

{t("policiesCircuitBreakers")}

+

{t("policiesLocked")}

{t("activeIssuesDetected")}

@@ -115,50 +107,6 @@ export default function PoliciesPanel() {
- {/* Circuit Breakers */} - {circuitBreakers.filter((cb) => cb.state !== "closed").length > 0 && ( -
-

{t("circuitBreakers")}

-
- {circuitBreakers - .filter((cb) => cb.state !== "closed") - .map((cb, i) => { - const status = CB_STATUS[cb.state] || CB_STATUS.open; - return ( -
-
- - {status.icon} - - - {cb.name || cb.provider || "Unknown"} - - - {status.label} - - {cb.failures > 0 && ( - {cb.failures} failures - )} -
-
- ); - })} -
-
- )} - {/* Locked Identifiers */} {lockedIds.length > 0 && (
diff --git a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx index e286af443b..f3ca6f7105 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx @@ -1,549 +1,316 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; -import { Card, Button } from "@/shared/components"; +import { type ReactNode, useEffect, useState } from "react"; +import { Button, Card } from "@/shared/components"; import { useNotificationStore } from "@/store/notificationStore"; -import { useLocale, useTranslations } from "next-intl"; -import AutoDisableCard from "./AutoDisableCard"; +import { useTranslations } from "next-intl"; -// ─── State colors and labels ────────────────────────────────────────────── -const STATE_STYLES = { - CLOSED: { - bg: "bg-emerald-500/15", - text: "text-emerald-400", - border: "border-emerald-500/30", - icon: "check_circle", - }, - OPEN: { - bg: "bg-red-500/15", - text: "text-red-400", - border: "border-red-500/30", - icon: "error", - }, - HALF_OPEN: { - bg: "bg-amber-500/15", - text: "text-amber-400", - border: "border-amber-500/30", - icon: "warning", - }, +type RequestQueueSettings = { + autoEnableApiKeyProviders: boolean; + requestsPerMinute: number; + minTimeBetweenRequestsMs: number; + concurrentRequests: number; + maxWaitMs: number; }; -const CB_STATUS = { - closed: { icon: "check_circle", color: "#22c55e" }, - "half-open": { icon: "pending", color: "#f59e0b" }, - open: { icon: "error", color: "#ef4444" }, +type ConnectionCooldownProfileSettings = { + baseCooldownMs: number; + useUpstreamRetryHints: boolean; + maxBackoffSteps: number; }; -function getBreakerStateLabel(state, t) { - const normalized = String(state || "closed") - .toLowerCase() - .replaceAll("_", "-"); - if (normalized === "open") return t("breakerStateOpen"); - if (normalized === "half-open") return t("breakerStateHalfOpen"); - return t("breakerStateClosed"); +type ProviderBreakerProfileSettings = { + failureThreshold: number; + resetTimeoutMs: number; +}; + +type WaitForCooldownSettings = { + enabled: boolean; + maxRetries: number; + maxRetryWaitSec: number; +}; + +type ResilienceResponse = { + requestQueue: RequestQueueSettings; + connectionCooldown: { + oauth: ConnectionCooldownProfileSettings; + apikey: ConnectionCooldownProfileSettings; + }; + providerBreaker: { + oauth: ProviderBreakerProfileSettings; + apikey: ProviderBreakerProfileSettings; + }; + waitForCooldown: WaitForCooldownSettings; +}; + +function formatMs(value: number | null | undefined) { + if (typeof value !== "number") return "—"; + return `${value}ms`; } -function formatMs(ms) { - if (!ms || ms <= 0) return "—"; - if (ms < 1000) return `${ms}ms`; - if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; - return `${(ms / 60000).toFixed(1)}m`; +function SectionDescription({ + scope, + trigger, + effect, +}: { + scope: string; + trigger: string; + effect: string; +}) { + return ( +
+
+ Scope: {scope} +
+
+ Trigger: {trigger} +
+
+ Effect: {effect} +
+
+ ); } -function getErrorMessage(err, fallback) { - return err instanceof Error && err.message ? err.message : fallback; +function NumberField({ + label, + value, + suffix, + min = 0, + onChange, +}: { + label: string; + value: number; + suffix?: string; + min?: number; + onChange: (value: number) => void; +}) { + return ( + + ); } -// ─── Provider Profiles Card ────────────────────────────────────────────── -function ProviderProfilesCard({ profiles, onSave, saving }) { - const [editMode, setEditMode] = useState(false); - const [draft, setDraft] = useState(profiles); - const t = useTranslations("settings"); +function BooleanField({ + label, + description, + checked, + onChange, +}: { + label: string; + description: string; + checked: boolean; + onChange: (value: boolean) => void; +}) { + return ( + + ); +} + +function ProfileColumn({ + title, + icon, + children, +}: { + title: string; + icon: string; + children: ReactNode; +}) { + return ( +
+
+ {icon} +

{title}

+
+
{children}
+
+ ); +} + +function ActionRow({ + editing, + saving, + onEdit, + onCancel, + onSave, +}: { + editing: boolean; + saving: boolean; + onEdit: () => void; + onCancel: () => void; + onSave: () => void; +}) { const tc = useTranslations("common"); - - useEffect(() => { - setDraft(profiles); - }, [profiles]); - - const formatMsRaw = (value) => (value == null ? "—" : `${value}${t("ms")}`); - const fields = [ - { key: "transientCooldown", label: t("transientCooldown"), format: formatMsRaw }, - { key: "rateLimitCooldown", label: t("rateLimitCooldown"), format: formatMsRaw }, - { key: "maxBackoffLevel", label: t("maxBackoffLevel") }, - { - key: "circuitBreakerThreshold", - label: t("cbThreshold"), - format: (value) => (value == null ? "—" : t("failures", { count: value })), - }, - { key: "circuitBreakerReset", label: t("cbResetTime"), format: formatMsRaw }, - ]; - - const handleSave = () => { - // Only send 'oauth' and 'apikey' — the API schema rejects any other keys (e.g. 'local') - const { oauth, apikey } = draft ?? {}; - onSave({ ...(oauth ? { oauth } : {}), ...(apikey ? { apikey } : {}) }); - setEditMode(false); - }; - - return ( - -
-
-
- -

{t("providerProfiles")}

-
- {editMode ? ( -
- - -
- ) : ( - - )} -
- -

{t("providerProfilesDesc")}

- -
- {["oauth", "apikey"].map((type) => ( -
-

- - {type === "oauth" ? t("oauthProviders") : t("apiKeyProviders")} -

-
- {fields.map(({ key, label, format }) => ( -
- {label} - {editMode ? ( - - setDraft({ - ...draft, - [type]: { ...draft[type], [key]: Number(e.target.value) }, - }) - } - className="w-24 px-2 py-1 text-xs rounded bg-white/10 border border-white/20 text-right" - /> - ) : ( - - {format - ? format(profiles?.[type]?.[key]) - : (profiles?.[type]?.[key] ?? "—")} - - )} -
- ))} -
-
- ))} -
-
-
- ); -} - -// ─── Editable Rate Limit Card ───────────────────────────────────────────── -function RateLimitCard({ rateLimitStatus, defaults, onSaveDefaults, saving }) { - const [editMode, setEditMode] = useState(false); - const [draft, setDraft] = useState(defaults || {}); - const t = useTranslations("settings"); - const tc = useTranslations("common"); - - // Sync draft when defaults change from parent (standard prop-to-state sync) - /* eslint-disable react-hooks/set-state-in-effect */ - useEffect(() => { - if (defaults) setDraft(defaults); - }, [defaults]); - /* eslint-enable react-hooks/set-state-in-effect */ - - const handleSave = () => { - onSaveDefaults(draft); - setEditMode(false); - }; - - return ( - -
-
-
- -

{t("rateLimiting")}

-
- {editMode ? ( -
- - -
- ) : ( - - )} -
- -

{t("rateLimitingDesc")}

- -
-

- {t("defaultSafetyNet")} -

-
- {[ - { key: "requestsPerMinute", label: t("rpm") }, - { key: "minTimeBetweenRequests", label: t("minGap"), format: formatMs }, - { key: "concurrentRequests", label: t("maxConcurrent") }, - ].map(({ key, label, format }) => ( -
- {editMode ? ( - - setDraft((prev) => ({ ...prev, [key]: parseInt(e.target.value) || 0 })) - } - className="w-full px-2 py-1 text-lg font-bold rounded bg-white/10 border border-white/20" - /> - ) : ( -
- {format ? format(defaults?.[key]) : (defaults?.[key] ?? "—")} -
- )} -
{label}
-
- ))} -
-
- - {rateLimitStatus && rateLimitStatus.length > 0 ? ( -
-

- {t("activeLimiters")} -

- {rateLimitStatus.map((rl, i) => ( -
- {rl.provider || rl.key} -
- {rl.reservoir != null && ( - - {t("reservoir")}: {rl.reservoir} - - )} - {rl.running != null && ( - - {t("running")}: {rl.running} - - )} - {rl.queued != null && ( - - {t("queued")}: {rl.queued} - - )} -
-
- ))} -
- ) : ( -

{t("noActiveLimiters")}

- )} -
-
- ); -} - -// ─── Circuit Breaker Card ──────────────────────────────────────────────── -function CircuitBreakerCard({ breakers, onReset, loading }) { - const activeBreakers = breakers.filter((b) => b.state !== "CLOSED"); - const totalBreakers = breakers.length; - const t = useTranslations("settings"); - - return ( - -
-
-
- -

{t("circuitBreakers")}

-
-
- - {activeBreakers.length > 0 - ? t("tripped", { count: activeBreakers.length }) - : t("healthy", { count: totalBreakers })} - - {activeBreakers.length > 0 && ( - - )} -
-
- - {breakers.length === 0 ? ( -

{t("noCircuitBreakers")}

- ) : ( -
- {breakers.map((b) => { - const style = STATE_STYLES[b.state] || STATE_STYLES.CLOSED; - return ( -
-
- - {b.name.replace("combo:", "")} -
-
- {b.failureCount > 0 && ( - - {t("failures", { count: b.failureCount })} - - )} - - {getBreakerStateLabel(b.state, t)} - -
-
- ); - })} -
- )} -
-
- ); -} - -// ─── Policies Panel (from Security tab) ────────────────────────────────── -function PoliciesCard() { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [unlocking, setUnlocking] = useState(null); - const notify = useNotificationStore(); - const locale = useLocale(); - const t = useTranslations("settings"); - - const fetchPolicies = useCallback(async () => { - try { - const res = await fetch("/api/policies"); - if (res.ok) { - const json = await res.json(); - setData(json); - } - } catch { - // silent - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - fetchPolicies(); - const interval = setInterval(fetchPolicies, 15000); - return () => clearInterval(interval); - }, [fetchPolicies]); - - const handleUnlock = async (identifier) => { - setUnlocking(identifier); - try { - const res = await fetch("/api/policies", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "unlock", identifier }), - }); - if (res.ok) { - notify.success(t("unlockedIdentifier", { identifier })); - await fetchPolicies(); - } else { - notify.error(t("failedUnlock")); - } - } catch { - notify.error(t("failedUnlock")); - } finally { - setUnlocking(null); - } - }; - - const circuitBreakers = data?.circuitBreakers || []; - const lockedIds = data?.lockedIdentifiers || []; - const hasIssues = circuitBreakers.some((cb) => cb.state !== "closed") || lockedIds.length > 0; - - if (loading) { + if (editing) { return ( - -
- policy - {t("loadingPolicies")} -
-
+
+ + +
); } return ( - -
-
-
- -

{t("policiesLocked")}

-
- {hasIssues && ( - - )} -
+ + ); +} - {!hasIssues ? ( -
-
- verified_user -
-
-

{t("allOperational")}

-
+function RequestQueueCard({ + value, + onSave, + saving, +}: { + value: RequestQueueSettings; + onSave: (next: RequestQueueSettings) => Promise; + saving: boolean; +}) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(value); + + useEffect(() => { + setDraft(value); + }, [value]); + + return ( + +
+
+
+ speed +

Request Queue & Pacing

+ +
+ setEditing(true)} + onCancel={() => { + setDraft(value); + setEditing(false); + }} + onSave={async () => { + await onSave(draft); + setEditing(false); + }} + /> +
+ +

+ This layer only controls queueing and pacing. It does not write cooldowns or open breakers. +

+ +
+ {editing ? ( + <> + + setDraft((prev) => ({ ...prev, autoEnableApiKeyProviders })) + } + /> + setDraft((prev) => ({ ...prev, requestsPerMinute }))} + /> + + setDraft((prev) => ({ ...prev, minTimeBetweenRequestsMs })) + } + /> + + setDraft((prev) => ({ ...prev, concurrentRequests })) + } + /> + setDraft((prev) => ({ ...prev, maxWaitMs }))} + /> + ) : ( <> - {/* Circuit Breakers */} - {circuitBreakers.filter((cb) => cb.state !== "closed").length > 0 && ( -
-

{t("circuitBreakers")}

-
- {circuitBreakers - .filter((cb) => cb.state !== "closed") - .map((cb, i) => { - const status = CB_STATUS[cb.state] || CB_STATUS.open; - return ( -
-
- - {status.icon} - - - {cb.name || cb.provider || t("unknown")} - - - {getBreakerStateLabel(cb.state, t)} - - {cb.failures > 0 && ( - - {t("failures", { count: cb.failures })} - - )} -
-
- ); - })} -
+
+
Auto-enable for API key providers
+
+ {value.autoEnableApiKeyProviders ? "Enabled" : "Disabled"}
- )} - - {/* Locked Identifiers */} - {lockedIds.length > 0 && ( -
-

{t("lockedIdentifiers")}

-
- {lockedIds.map((id, i) => { - const identifier = typeof id === "string" ? id : id.identifier || id.id; - return ( -
-
- - lock - - {identifier} - {typeof id === "object" && id.lockedAt && ( - - {t("sinceDate", { - date: new Date(id.lockedAt).toLocaleString(locale), - })} - - )} -
- -
- ); - })} -
+
+
+
Requests per minute
+
+ {value.requestsPerMinute}
- )} +
+
+
Min time between requests
+
+ {formatMs(value.minTimeBetweenRequestsMs)} +
+
+
+
Concurrent requests
+
+ {value.concurrentRequests} +
+
+
+
Max queue wait
+
+ {formatMs(value.maxWaitMs)} +
+
)}
@@ -551,131 +318,435 @@ function PoliciesCard() { ); } -// ─── Main Resilience Tab ───────────────────────────────────────────────── -export default function ResilienceTab() { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - const t = useTranslations("settings"); - - const loadData = useCallback(async () => { - try { - setLoading(true); - const res = await fetch("/api/resilience"); - if (!res.ok) throw new Error(t("failedLoadWithStatus", { status: res.status })); - const json = await res.json(); - setData(json); - setError(null); - } catch (err) { - setError(getErrorMessage(err, t("failedLoadResilience"))); - } finally { - setLoading(false); - } - }, [t]); +function ConnectionCooldownCard({ + value, + onSave, + saving, +}: { + value: ResilienceResponse["connectionCooldown"]; + onSave: (next: ResilienceResponse["connectionCooldown"]) => Promise; + saving: boolean; +}) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(value); useEffect(() => { - loadData(); - // Auto-refresh every 10s - const interval = setInterval(loadData, 10000); - return () => clearInterval(interval); - }, [loadData]); + setDraft(value); + }, [value]); - const handleResetBreakers = async () => { - try { - setLoading(true); - const res = await fetch("/api/resilience/reset", { method: "POST" }); - if (!res.ok) throw new Error(t("resetFailed")); - await loadData(); - } catch (err) { - setError(getErrorMessage(err, t("resetFailed"))); - } finally { - setLoading(false); - } + const renderProfile = (key: "oauth" | "apikey", title: string, icon: string) => { + const current = editing ? draft[key] : value[key]; + return ( + + {editing ? ( + <> + + setDraft((prev) => ({ ...prev, [key]: { ...prev[key], baseCooldownMs } })) + } + /> + + setDraft((prev) => ({ + ...prev, + [key]: { ...prev[key], useUpstreamRetryHints }, + })) + } + /> + + setDraft((prev) => ({ ...prev, [key]: { ...prev[key], maxBackoffSteps } })) + } + /> + + ) : ( + <> +
+ Base cooldown + {formatMs(current.baseCooldownMs)} +
+
+ Use upstream retry hints + + {current.useUpstreamRetryHints ? "Yes" : "No"} + +
+
+ Max backoff steps + {current.maxBackoffSteps} +
+ + )} +
+ ); }; - const handleSaveProfiles = async (profiles) => { - try { - setSaving(true); - const res = await fetch("/api/resilience", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ profiles }), - }); - if (!res.ok) throw new Error(t("saveFailed")); - await loadData(); - } catch (err) { - setError(getErrorMessage(err, t("saveFailed"))); - } finally { - setSaving(false); - } + return ( + +
+
+
+ timer_off +

Connection Cooldown

+
+ +
+ setEditing(true)} + onCancel={() => { + setDraft(value); + setEditing(false); + }} + onSave={async () => { + await onSave(draft); + setEditing(false); + }} + /> +
+ +

+ Base cooldown covers retryable connection failures. When upstream retry hints are enabled, + explicit provider wait windows override the local base cooldown. +

+ +
+ {renderProfile("oauth", "OAuth Providers", "lock")} + {renderProfile("apikey", "API Key Providers", "key")} +
+
+ ); +} + +function ProviderBreakerCard({ + value, + onSave, + saving, +}: { + value: ResilienceResponse["providerBreaker"]; + onSave: (next: ResilienceResponse["providerBreaker"]) => Promise; + saving: boolean; +}) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(value); + + useEffect(() => { + setDraft(value); + }, [value]); + + const renderProfile = (key: "oauth" | "apikey", title: string, icon: string) => { + const current = editing ? draft[key] : value[key]; + return ( + + {editing ? ( + <> + + setDraft((prev) => ({ ...prev, [key]: { ...prev[key], failureThreshold } })) + } + /> + + setDraft((prev) => ({ ...prev, [key]: { ...prev[key], resetTimeoutMs } })) + } + /> + + ) : ( + <> +
+ Failure threshold + {current.failureThreshold} +
+
+ Reset timeout + {formatMs(current.resetTimeoutMs)} +
+ + )} +
+ ); }; - const handleSaveDefaults = async (defaults) => { + return ( + +
+
+
+ + electrical_services + +

Provider Circuit Breaker

+
+ +
+ setEditing(true)} + onCancel={() => { + setDraft(value); + setEditing(false); + }} + onSave={async () => { + await onSave(draft); + setEditing(false); + }} + /> +
+ +

+ Breaker runtime state is shown only on the Health page. Connection-scoped 429 rate limits + stay in Connection Cooldown and do not trip the provider breaker. +

+ +
+ {renderProfile("oauth", "OAuth Providers", "lock")} + {renderProfile("apikey", "API Key Providers", "key")} +
+
+ ); +} + +function WaitForCooldownCard({ + value, + onSave, + saving, +}: { + value: WaitForCooldownSettings; + onSave: (next: WaitForCooldownSettings) => Promise; + saving: boolean; +}) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(value); + + useEffect(() => { + setDraft(value); + }, [value]); + + return ( + +
+
+
+ hourglass_top +

Wait For Cooldown

+
+ +
+ setEditing(true)} + onCancel={() => { + setDraft(value); + setEditing(false); + }} + onSave={async () => { + await onSave(draft); + setEditing(false); + }} + /> +
+ +

+ This only affects the current request. It does not write connection or provider state. +

+ +
+ {editing ? ( + <> + setDraft((prev) => ({ ...prev, enabled }))} + /> + setDraft((prev) => ({ ...prev, maxRetries }))} + /> + setDraft((prev) => ({ ...prev, maxRetryWaitSec }))} + /> + + ) : ( + <> +
+
Enable server-side waiting
+
+ {value.enabled ? "Enabled" : "Disabled"} +
+
+
+
Max retries
+
{value.maxRetries}
+
+
+
Max retry wait
+
+ {value.maxRetryWaitSec}s +
+
+ + )} +
+
+ ); +} + +export default function ResilienceTab() { + const notify = useNotificationStore(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [savingSection, setSavingSection] = useState(null); + + useEffect(() => { + let mounted = true; + + const load = async () => { + try { + const response = await fetch("/api/resilience"); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const json = await response.json(); + if (!mounted) return; + setData({ + requestQueue: json.requestQueue, + connectionCooldown: json.connectionCooldown, + providerBreaker: json.providerBreaker, + waitForCooldown: json.waitForCooldown, + }); + } catch (error) { + notify.error(error instanceof Error ? error.message : "Failed to load resilience settings"); + } finally { + if (mounted) setLoading(false); + } + }; + + void load(); + return () => { + mounted = false; + }; + }, [notify]); + + const savePatch = async (section: string, payload: Record) => { + setSavingSection(section); try { - setSaving(true); - const res = await fetch("/api/resilience", { + const response = await fetch("/api/resilience", { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ defaults }), + body: JSON.stringify(payload), }); - if (!res.ok) throw new Error(t("saveFailed")); - await loadData(); - } catch (err) { - setError(getErrorMessage(err, t("saveFailed"))); + const json = await response.json(); + if (!response.ok) { + throw new Error(json?.error?.message || json?.error || `HTTP ${response.status}`); + } + setData({ + requestQueue: json.requestQueue, + connectionCooldown: json.connectionCooldown, + providerBreaker: json.providerBreaker, + waitForCooldown: json.waitForCooldown, + }); + notify.success("Resilience settings updated."); + } catch (error) { + notify.error(error instanceof Error ? error.message : "Failed to save resilience settings"); + throw error; } finally { - setSaving(false); + setSavingSection(null); } }; if (loading && !data) { return ( -
- hourglass_empty - {t("loadingResilience")} -
+ +
+ progress_activity + Loading resilience settings... +
+
); } - if (error && !data) { + if (!data) { return ( -
- error - {error} -
- +

Unable to load resilience settings.

); } return ( -
- {/* 1. Provider Profiles (resilience settings by auth type) */} - + +
+ info +
+

Resilience Structure

+

+ This page only configures behavior. Live breaker state is shown on the Health page. + Combo-specific retry and round-robin slot control remain on combo settings. +

+
+
+
+ + savePatch("requestQueue", { requestQueue })} /> - {/* 1.5 Auto Disable Banned Accounts */} - - {/* 2. Rate Limiting (editable defaults + active limiters) */} - savePatch("connectionCooldown", { connectionCooldown })} /> - {/* 3. Circuit Breakers (combo pipeline) */} - savePatch("providerBreaker", { providerBreaker })} + /> + savePatch("waitForCooldown", { waitForCooldown })} /> - {/* 4. Policies & Locked Identifiers (from previous Security tab) */} -
); } diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 8237e8eb34..e640e17628 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -1,10 +1,5 @@ import { DashboardLayout } from "@/shared/components"; -import { ModelStatusProvider } from "./dashboard/providers/components/ModelStatusContext"; export default function DashboardRootLayout({ children }) { - return ( - - {children} - - ); + return {children}; } diff --git a/src/app/api/models/availability/route.ts b/src/app/api/models/availability/route.ts deleted file mode 100644 index 9ef7418c54..0000000000 --- a/src/app/api/models/availability/route.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { NextResponse } from "next/server"; -import { - getAvailabilityReport, - clearModelUnavailability, - getUnavailableCount, -} from "@/domain/modelAvailability"; -import { clearModelAvailabilitySchema } from "@/shared/validation/schemas"; -import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; - -export async function GET() { - try { - const report = getAvailabilityReport(); - const count = getUnavailableCount(); - return NextResponse.json({ unavailableCount: count, models: report }); - } catch (error) { - console.error("Error getting model availability:", error); - return NextResponse.json({ error: "Failed to get model availability" }, { status: 500 }); - } -} - -export async function POST(request) { - let rawBody; - try { - rawBody = await request.json(); - } catch { - return NextResponse.json( - { - error: { - message: "Invalid request", - details: [{ field: "body", message: "Invalid JSON body" }], - }, - }, - { status: 400 } - ); - } - - try { - const validation = validateBody(clearModelAvailabilitySchema, rawBody); - if (isValidationFailure(validation)) { - return NextResponse.json({ error: validation.error }, { status: 400 }); - } - const { provider, model } = validation.data; - - const removed = clearModelUnavailability(provider, model); - return NextResponse.json({ success: true, removed }); - } catch (error) { - console.error("Error clearing model availability:", error); - return NextResponse.json({ error: "Failed to clear model availability" }, { status: 500 }); - } -} diff --git a/src/app/api/policies/route.ts b/src/app/api/policies/route.ts index dc74eb76f6..47f0e3dfa5 100644 --- a/src/app/api/policies/route.ts +++ b/src/app/api/policies/route.ts @@ -1,14 +1,12 @@ import { NextResponse } from "next/server"; -import { getAllCircuitBreakerStatuses } from "@/shared/utils/circuitBreaker"; import { getLockedIdentifiers, forceUnlock } from "@/domain/lockoutPolicy"; import { policyActionSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; export async function GET() { try { - const circuitBreakers = getAllCircuitBreakerStatuses(); const lockedIdentifiers = getLockedIdentifiers(); - return NextResponse.json({ circuitBreakers, lockedIdentifiers }); + return NextResponse.json({ lockedIdentifiers }); } catch (error) { console.error("Error loading policies:", error); return NextResponse.json({ error: "Failed to load policies" }, { status: 500 }); diff --git a/src/app/api/resilience/reset/route.ts b/src/app/api/resilience/reset/route.ts index 3ee4e94aea..8c21960732 100644 --- a/src/app/api/resilience/reset/route.ts +++ b/src/app/api/resilience/reset/route.ts @@ -1,11 +1,10 @@ import { NextResponse } from "next/server"; /** - * POST /api/resilience/reset — Reset all circuit breakers and clear cooldowns + * POST /api/resilience/reset — Reset all provider circuit breakers */ export async function POST() { try { - // Reset all circuit breakers const { getAllCircuitBreakerStatuses, getCircuitBreaker } = await import("@/shared/utils/circuitBreaker"); diff --git a/src/app/api/resilience/route.ts b/src/app/api/resilience/route.ts index d1bea74e33..9436aae3cf 100644 --- a/src/app/api/resilience/route.ts +++ b/src/app/api/resilience/route.ts @@ -1,5 +1,12 @@ import { NextResponse } from "next/server"; import { getSettings, updateSettings } from "@/lib/localDb"; +import { + buildLegacyResilienceCompat, + mergeResilienceSettings, + resolveResilienceSettings, + type ResilienceSettings, + type ResilienceSettingsPatch, +} from "@/lib/resilience/settings"; import { updateResilienceSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; @@ -13,41 +20,130 @@ function getErrorMessage(error: unknown, fallback: string): string { return error instanceof Error && error.message ? error.message : fallback; } +function normalizeLegacyPatch(body: JsonRecord): ResilienceSettingsPatch { + const profiles = asRecord(body.profiles); + const defaults = asRecord(body.defaults); + const oauth = asRecord(profiles.oauth); + const apikey = asRecord(profiles.apikey); + + const patch: ResilienceSettingsPatch = {}; + + if (Object.keys(defaults).length > 0) { + patch.requestQueue = { + ...(typeof defaults.requestsPerMinute === "number" + ? { requestsPerMinute: defaults.requestsPerMinute } + : {}), + ...(typeof defaults.minTimeBetweenRequests === "number" + ? { minTimeBetweenRequestsMs: defaults.minTimeBetweenRequests } + : {}), + ...(typeof defaults.concurrentRequests === "number" + ? { concurrentRequests: defaults.concurrentRequests } + : {}), + }; + } + + if (Object.keys(oauth).length > 0 || Object.keys(apikey).length > 0) { + const buildLegacyCooldownPatch = (profile: JsonRecord) => { + const cooldownCandidates = [ + typeof profile.transientCooldown === "number" ? profile.transientCooldown : null, + typeof profile.rateLimitCooldown === "number" && profile.rateLimitCooldown > 0 + ? profile.rateLimitCooldown + : null, + ].filter((value): value is number => typeof value === "number"); + + return { + ...(cooldownCandidates.length > 0 + ? { baseCooldownMs: Math.max(...cooldownCandidates) } + : {}), + ...(typeof profile.rateLimitCooldown === "number" + ? { useUpstreamRetryHints: profile.rateLimitCooldown === 0 } + : {}), + ...(typeof profile.maxBackoffLevel === "number" + ? { maxBackoffSteps: profile.maxBackoffLevel } + : {}), + }; + }; + + patch.connectionCooldown = { + ...(Object.keys(oauth).length > 0 + ? { + oauth: buildLegacyCooldownPatch(oauth), + } + : {}), + ...(Object.keys(apikey).length > 0 + ? { + apikey: buildLegacyCooldownPatch(apikey), + } + : {}), + }; + + patch.providerBreaker = { + ...(Object.keys(oauth).length > 0 + ? { + oauth: { + ...(typeof oauth.circuitBreakerThreshold === "number" + ? { failureThreshold: oauth.circuitBreakerThreshold } + : {}), + ...(typeof oauth.circuitBreakerReset === "number" + ? { resetTimeoutMs: oauth.circuitBreakerReset } + : {}), + }, + } + : {}), + ...(Object.keys(apikey).length > 0 + ? { + apikey: { + ...(typeof apikey.circuitBreakerThreshold === "number" + ? { failureThreshold: apikey.circuitBreakerThreshold } + : {}), + ...(typeof apikey.circuitBreakerReset === "number" + ? { resetTimeoutMs: apikey.circuitBreakerReset } + : {}), + }, + } + : {}), + }; + } + + return patch; +} + +async function syncRuntimeSettings(resilienceSettings: ResilienceSettings) { + const { applyRequestQueueSettings } = + await import("@omniroute/open-sse/services/rateLimitManager"); + applyRequestQueueSettings(resilienceSettings.requestQueue); +} + /** - * GET /api/resilience — Get current resilience configuration and status + * GET /api/resilience — Get current resilience configuration */ export async function GET() { try { - // Dynamic imports for open-sse modules - const { getAllCircuitBreakerStatuses } = await import("@/shared/utils/circuitBreaker"); - const { getAllRateLimitStatus } = await import("@omniroute/open-sse/services/rateLimitManager"); - const { PROVIDER_PROFILES, DEFAULT_API_LIMITS } = - await import("@omniroute/open-sse/config/constants"); - const settings = await getSettings(); - const circuitBreakers = getAllCircuitBreakerStatuses(); - const rateLimitStatus = getAllRateLimitStatus(); + const resilience = resolveResilienceSettings(settings); return NextResponse.json({ - profiles: settings.providerProfiles || PROVIDER_PROFILES, - defaults: { - ...DEFAULT_API_LIMITS, - ...asRecord(settings.rateLimitDefaults), + requestQueue: resilience.requestQueue, + connectionCooldown: resilience.connectionCooldown, + providerBreaker: resilience.providerBreaker, + waitForCooldown: { + enabled: resilience.waitForCooldown.enabled, + maxRetries: resilience.waitForCooldown.maxRetries, + maxRetryWaitSec: resilience.waitForCooldown.maxRetryWaitSec, }, - circuitBreakers, - rateLimitStatus, + legacy: buildLegacyResilienceCompat(resilience), }); } catch (err: unknown) { console.error("[API] GET /api/resilience error:", err); return NextResponse.json( - { error: getErrorMessage(err, "Failed to load resilience status") }, + { error: getErrorMessage(err, "Failed to load resilience settings") }, { status: 500 } ); } } /** - * PATCH /api/resilience — Update provider resilience profiles and/or rate limit defaults + * PATCH /api/resilience — Update resilience configuration */ export async function PATCH(request) { let rawBody; @@ -70,18 +166,47 @@ export async function PATCH(request) { if (isValidationFailure(validation)) { return NextResponse.json({ error: validation.error }, { status: 400 }); } - const { profiles, defaults } = validation.data; - const updates: Record = {}; - if (profiles) updates.providerProfiles = profiles; - if (defaults) updates.rateLimitDefaults = defaults; + const body = validation.data as JsonRecord; + const currentSettings = await getSettings(); + const currentResilience = resolveResilienceSettings(currentSettings); + const nextResilience = mergeResilienceSettings(currentResilience, { + ...(body.requestQueue + ? { requestQueue: body.requestQueue as ResilienceSettingsPatch["requestQueue"] } + : {}), + ...(body.connectionCooldown + ? { + connectionCooldown: + body.connectionCooldown as ResilienceSettingsPatch["connectionCooldown"], + } + : {}), + ...(body.providerBreaker + ? { providerBreaker: body.providerBreaker as ResilienceSettingsPatch["providerBreaker"] } + : {}), + ...(body.waitForCooldown + ? { waitForCooldown: body.waitForCooldown as ResilienceSettingsPatch["waitForCooldown"] } + : {}), + ...normalizeLegacyPatch(body), + }); - await updateSettings(updates); + await updateSettings({ + resilienceSettings: nextResilience, + requestRetry: nextResilience.waitForCooldown.maxRetries, + maxRetryIntervalSec: nextResilience.waitForCooldown.maxRetryWaitSec, + }); + await syncRuntimeSettings(nextResilience); return NextResponse.json({ ok: true, - ...(profiles ? { profiles } : {}), - ...(defaults ? { defaults } : {}), + requestQueue: nextResilience.requestQueue, + connectionCooldown: nextResilience.connectionCooldown, + providerBreaker: nextResilience.providerBreaker, + waitForCooldown: { + enabled: nextResilience.waitForCooldown.enabled, + maxRetries: nextResilience.waitForCooldown.maxRetries, + maxRetryWaitSec: nextResilience.waitForCooldown.maxRetryWaitSec, + }, + legacy: buildLegacyResilienceCompat(nextResilience), }); } catch (err: unknown) { console.error("[API] PATCH /api/resilience error:", err); diff --git a/src/app/api/settings/combo-defaults/route.ts b/src/app/api/settings/combo-defaults/route.ts index fb2df51cda..49c01285b4 100644 --- a/src/app/api/settings/combo-defaults/route.ts +++ b/src/app/api/settings/combo-defaults/route.ts @@ -3,6 +3,32 @@ import { getSettings, updateSettings } from "@/lib/localDb"; import { updateComboDefaultsSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +const LEGACY_COMBO_RESILIENCE_KEYS = new Set([ + "timeoutMs", + "healthCheckEnabled", + "healthCheckTimeoutMs", +]); + +function sanitizeComboRuntimeConfig(config?: Record | null) { + if (!config || typeof config !== "object") return {}; + return Object.fromEntries( + Object.entries(config).filter( + ([key, value]) => + value !== undefined && value !== null && !LEGACY_COMBO_RESILIENCE_KEYS.has(key) + ) + ); +} + +function sanitizeProviderOverrides(overrides?: Record | null) { + if (!overrides || typeof overrides !== "object") return {}; + return Object.fromEntries( + Object.entries(overrides).map(([providerId, config]) => [ + providerId, + sanitizeComboRuntimeConfig(config), + ]) + ); +} + /** * GET /api/settings/combo-defaults * Returns the current combo global defaults and provider overrides @@ -10,21 +36,23 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; export async function GET() { try { const settings: any = await getSettings(); + const comboDefaults = sanitizeComboRuntimeConfig(settings.comboDefaults); + const providerOverrides = sanitizeProviderOverrides(settings.providerOverrides); return NextResponse.json({ - comboDefaults: settings.comboDefaults || { - strategy: "priority", - maxRetries: 1, - retryDelayMs: 2000, - timeoutMs: 120000, - healthCheckEnabled: true, - healthCheckTimeoutMs: 3000, - handoffThreshold: 0.85, - handoffModel: "", - maxMessagesForSummary: 30, - maxComboDepth: 3, - trackMetrics: true, - }, - providerOverrides: settings.providerOverrides || {}, + comboDefaults: + Object.keys(comboDefaults).length > 0 + ? comboDefaults + : { + strategy: "priority", + maxRetries: 1, + retryDelayMs: 2000, + handoffThreshold: 0.85, + handoffModel: "", + maxMessagesForSummary: 30, + maxComboDepth: 3, + trackMetrics: true, + }, + providerOverrides, }); } catch (error) { console.log("Error fetching combo defaults:", error); @@ -63,16 +91,16 @@ export async function PATCH(request) { const updates: Record = {}; if (body.comboDefaults) { - updates.comboDefaults = body.comboDefaults; + updates.comboDefaults = sanitizeComboRuntimeConfig(body.comboDefaults); } if (body.providerOverrides) { - updates.providerOverrides = body.providerOverrides; + updates.providerOverrides = sanitizeProviderOverrides(body.providerOverrides); } const settings: any = await updateSettings(updates); return NextResponse.json({ - comboDefaults: settings.comboDefaults || {}, - providerOverrides: settings.providerOverrides || {}, + comboDefaults: sanitizeComboRuntimeConfig(settings.comboDefaults), + providerOverrides: sanitizeProviderOverrides(settings.providerOverrides), }); } catch (error) { console.log("Error updating combo defaults:", error); diff --git a/src/domain/modelAvailability.ts b/src/domain/modelAvailability.ts deleted file mode 100644 index 9510c0ea73..0000000000 --- a/src/domain/modelAvailability.ts +++ /dev/null @@ -1,319 +0,0 @@ -/** - * Model Availability — Domain Layer (T-19) - * - * Tracks model availability per provider with TTL-based cooldowns. - * When a model becomes unavailable (rate-limited, erroring), it is - * marked with a cooldown period. The availability report powers - * the dashboard health view. - * - * @module domain/modelAvailability - */ - -/** - * @typedef {Object} UnavailableEntry - * @property {string} provider - * @property {string} model - * @property {number} unavailableSince - timestamp - * @property {number} cooldownMs - * @property {string} [reason] - */ - -/** @type {Map} */ -const unavailable = new Map(); - -/** - * @typedef {Object} FailureState - * @property {number} failureCount - * @property {number} lastFailureAt - * @property {number} resetAfterMs - */ - -/** - * @typedef {Object} ProviderProfile - * @property {number} [transientCooldown] - * @property {number} [rateLimitCooldown] - * @property {number} [maxBackoffLevel] - * @property {number} [circuitBreakerThreshold] - * @property {number} [circuitBreakerReset] - */ - -/** @type {Map} */ -const failureState = new Map(); - -const FAILURE_WINDOW_MS = 30 * 60 * 1000; - -const PROBLEMATIC_STATUS_COOLDOWNS = { - 429: 5 * 60 * 1000, - 408: 60 * 1000, - 500: 2 * 60 * 1000, - 502: 2 * 60 * 1000, - 503: 2 * 60 * 1000, - 504: 2 * 60 * 1000, -}; - -const MIN_PROBLEMATIC_COOLDOWN_MS = 60 * 1000; -const MAX_PROBLEMATIC_COOLDOWN_MS = 30 * 60 * 1000; - -function toPositiveNumber(value) { - return Number.isFinite(value) && Number(value) > 0 ? Number(value) : null; -} - -function toNonNegativeNumber(value) { - return Number.isFinite(value) && Number(value) >= 0 ? Number(value) : null; -} - -/** - * The first layer already reacts immediately to authoritative model/account failures. - * Global provider/model quarantine is the escalation layer, so its failure window and - * threshold are only customized when a runtime provider profile is supplied. - * - * @param {ProviderProfile | null | undefined} profile - * @returns {number} - */ -function getFailureWindowMs(profile) { - return toPositiveNumber(profile?.circuitBreakerReset) ?? FAILURE_WINDOW_MS; -} - -/** - * Without a runtime profile we preserve legacy behavior: quarantine on the first failure. - * - * @param {ProviderProfile | null | undefined} profile - * @returns {number} - */ -function getFailureThreshold(profile) { - return toPositiveNumber(profile?.circuitBreakerThreshold) ?? 1; -} - -function getLegacyStatusCooldown(status) { - return status && Object.prototype.hasOwnProperty.call(PROBLEMATIC_STATUS_COOLDOWNS, status) - ? PROBLEMATIC_STATUS_COOLDOWNS[status] - : 0; -} - -/** - * @param {number | null} status - * @param {ProviderProfile | null | undefined} profile - * @returns {number} - */ -function getProfileStatusCooldown(status, profile) { - if (!profile) return 0; - if (status === 429) { - return toPositiveNumber(profile.rateLimitCooldown) ?? 0; - } - return toPositiveNumber(profile.transientCooldown) ?? 0; -} - -/** - * @param {number} baseCooldownMs - * @param {number} failureCount - * @param {ProviderProfile | null | undefined} profile - * @returns {number} - */ -function getScaledCooldown(baseCooldownMs, failureCount, profile) { - const safeBase = toPositiveNumber(baseCooldownMs) ?? 1000; - if (!profile) { - return Math.min( - Math.max(safeBase, MIN_PROBLEMATIC_COOLDOWN_MS) * Math.pow(2, Math.max(0, failureCount - 1)), - MAX_PROBLEMATIC_COOLDOWN_MS - ); - } - - const maxBackoffLevel = Math.max( - 0, - Math.trunc(toNonNegativeNumber(profile.maxBackoffLevel) ?? 0) - ); - const exponent = Math.min(Math.max(0, failureCount - 1), maxBackoffLevel); - return safeBase * Math.pow(2, exponent); -} - -/** - * Build a composite key for provider+model. - * @param {string} provider - * @param {string} model - * @returns {string} - */ -function makeKey(provider, model) { - return `${provider}::${model}`; -} - -/** - * Check if a model is currently available. - * - * @param {string} provider - Provider ID (e.g. "openai", "anthropic") - * @param {string} model - Model ID (e.g. "gpt-4o", "claude-sonnet-4-20250514") - * @returns {boolean} true if model is available (not in cooldown) - */ -export function isModelAvailable(provider, model) { - const key = makeKey(provider, model); - const entry = unavailable.get(key); - if (!entry) return true; - - // Check if cooldown has expired - if (Date.now() - entry.unavailableSince >= entry.cooldownMs) { - unavailable.delete(key); - return true; - } - - return false; -} - -/** - * Get remaining cooldown information for a model, if it is currently unavailable. - * - * @param {string} provider - * @param {string} model - * @returns {{ provider: string, model: string, reason: string, remainingMs: number, unavailableSince: string } | null} - */ -export function getModelCooldownInfo(provider, model) { - const key = makeKey(provider, model); - const entry = unavailable.get(key); - if (!entry) return null; - - const elapsed = Date.now() - entry.unavailableSince; - if (elapsed >= entry.cooldownMs) { - unavailable.delete(key); - return null; - } - - return { - provider: entry.provider, - model: entry.model, - reason: entry.reason || "unknown", - remainingMs: entry.cooldownMs - elapsed, - unavailableSince: new Date(entry.unavailableSince).toISOString(), - }; -} - -/** - * Mark a model as temporarily unavailable. - * - * @param {string} provider - * @param {string} model - * @param {number} [cooldownMs=60000] - Cooldown in milliseconds (default 60s) - * @param {string} [reason] - Optional reason for unavailability - */ -export function setModelUnavailable(provider, model, cooldownMs = 60000, reason) { - const key = makeKey(provider, model); - const now = Date.now(); - const safeCooldownMs = Number.isFinite(cooldownMs) && cooldownMs > 0 ? cooldownMs : 60000; - const existing = unavailable.get(key); - const existingRemainingMs = - existing && Date.now() - existing.unavailableSince < existing.cooldownMs - ? existing.cooldownMs - (Date.now() - existing.unavailableSince) - : 0; - const effectiveCooldownMs = Math.max(safeCooldownMs, existingRemainingMs); - - unavailable.set(key, { - provider, - model, - unavailableSince: now, - cooldownMs: effectiveCooldownMs, - reason: reason || "unknown", - }); -} - -/** - * Marca provider/model como problemático com cooldown adaptativo. - * Mantém retrocompatibilidade: não altera o comportamento de setModelUnavailable, - * apenas oferece uma estratégia mais agressiva para falhas recorrentes. - * - * @param {string} provider - * @param {string} model - * @param {{ status?: number, baseCooldownMs?: number, reason?: string, profile?: ProviderProfile | null }} [options] - * @returns {{ cooldownMs: number, failureCount: number, quarantined: boolean, threshold: number, resetAfterMs: number }} - */ -export function markModelAsProblematic(provider, model, options = {}) { - const key = makeKey(provider, model); - const now = Date.now(); - const status = Number.isFinite(options.status) ? Number(options.status) : null; - const profile = options.profile || null; - const explicitBaseCooldownMs = - Number.isFinite(options.baseCooldownMs) && Number(options.baseCooldownMs) > 0 - ? Number(options.baseCooldownMs) - : 0; - const statusBaseCooldown = profile - ? getProfileStatusCooldown(status, profile) - : getLegacyStatusCooldown(status); - const baseCooldownMs = Math.max(explicitBaseCooldownMs, statusBaseCooldown); - - const prev = failureState.get(key); - const resetAfterMs = getFailureWindowMs(profile); - const withinFailureWindow = prev && now - prev.lastFailureAt <= prev.resetAfterMs; - const failureCount = withinFailureWindow ? prev.failureCount + 1 : 1; - failureState.set(key, { failureCount, lastFailureAt: now, resetAfterMs }); - - const threshold = getFailureThreshold(profile); - const cooldownMs = getScaledCooldown(baseCooldownMs, failureCount, profile); - const quarantined = failureCount >= threshold; - - if (quarantined) { - setModelUnavailable(provider, model, cooldownMs, options.reason || "problematic_model"); - } - - return { - cooldownMs, - failureCount, - quarantined, - threshold, - resetAfterMs, - }; -} - -/** - * Clear unavailability for a model (e.g. after manual reset). - * - * @param {string} provider - * @param {string} model - * @returns {boolean} true if entry existed and was removed - */ -export function clearModelUnavailability(provider, model) { - const key = makeKey(provider, model); - failureState.delete(key); - return unavailable.delete(key); -} - -/** - * Get a report of all currently unavailable models. - * - * @returns {Array<{ provider: string, model: string, reason: string, remainingMs: number, unavailableSince: string }>} - */ -export function getAvailabilityReport() { - const now = Date.now(); - const report = []; - - for (const [key, entry] of unavailable.entries()) { - const elapsed = now - entry.unavailableSince; - if (elapsed >= entry.cooldownMs) { - unavailable.delete(key); - continue; - } - - report.push({ - provider: entry.provider, - model: entry.model, - reason: entry.reason || "unknown", - remainingMs: entry.cooldownMs - elapsed, - unavailableSince: new Date(entry.unavailableSince).toISOString(), - }); - } - - return report; -} - -/** - * Get total count of unavailable models. - * @returns {number} - */ -export function getUnavailableCount() { - // Prune expired entries first - getAvailabilityReport(); - return unavailable.size; -} - -/** - * Reset all availability states (for testing or admin). - */ -export function resetAllAvailability() { - unavailable.clear(); - failureState.clear(); -} diff --git a/src/lib/combos/intelligentRouting.ts b/src/lib/combos/intelligentRouting.ts index 1f2a285a6a..7f42f5a0cb 100644 --- a/src/lib/combos/intelligentRouting.ts +++ b/src/lib/combos/intelligentRouting.ts @@ -31,13 +31,6 @@ export type IntelligentProviderScore = { factors: IntelligentRoutingWeights; }; -export type IntelligentExclusionEntry = { - provider: string; - excludedAt: string; - cooldownMs: number; - reason: string; -}; - export const DEFAULT_INTELLIGENT_WEIGHTS: IntelligentRoutingWeights = { quota: 0.2, health: 0.25, @@ -161,50 +154,3 @@ export function buildIntelligentProviderScores(combo: { factors: weights, })); } - -export function extractIntelligentHealthState(health: unknown): { - incidentMode: boolean; - exclusions: IntelligentExclusionEntry[]; -} { - const healthRecord = isRecord(health) ? health : {}; - const providerHealth = isRecord(healthRecord.providerHealth) ? healthRecord.providerHealth : {}; - const providerBreakers = Object.entries(providerHealth).map(([provider, status]) => { - const statusRecord = isRecord(status) ? status : {}; - return { - provider, - state: typeof statusRecord.state === "string" ? statusRecord.state : "CLOSED", - lastFailure: typeof statusRecord.lastFailure === "string" ? statusRecord.lastFailure : null, - }; - }); - const breakersFromArray = Array.isArray(healthRecord.circuitBreakers) - ? healthRecord.circuitBreakers - .map((entry) => { - const breaker = isRecord(entry) ? entry : {}; - const provider = - typeof breaker.provider === "string" - ? breaker.provider - : typeof breaker.name === "string" - ? breaker.name - : "unknown"; - return { - provider, - state: typeof breaker.state === "string" ? breaker.state : "CLOSED", - lastFailure: typeof breaker.lastFailure === "string" ? breaker.lastFailure : null, - }; - }) - .filter((entry) => typeof entry.provider === "string") - : []; - - const breakers = breakersFromArray.length > 0 ? breakersFromArray : providerBreakers; - const openBreakers = breakers.filter((breaker) => breaker.state === "OPEN"); - - return { - incidentMode: openBreakers.length / Math.max(breakers.length, 1) > 0.5, - exclusions: openBreakers.map((breaker) => ({ - provider: breaker.provider, - excludedAt: breaker.lastFailure || new Date().toISOString(), - cooldownMs: 5 * 60 * 1000, - reason: "Circuit breaker OPEN", - })), - }; -} diff --git a/src/lib/dataPaths.js b/src/lib/dataPaths.js deleted file mode 100644 index 3595429936..0000000000 --- a/src/lib/dataPaths.js +++ /dev/null @@ -1,70 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.APP_NAME = void 0; -exports.getLegacyDotDataDir = getLegacyDotDataDir; -exports.getDefaultDataDir = getDefaultDataDir; -exports.resolveDataDir = resolveDataDir; -exports.isSamePath = isSamePath; -const path_1 = __importDefault(require("path")); -const os_1 = __importDefault(require("os")); -exports.APP_NAME = "omniroute"; -function fallbackHomeDir() { - const envHome = process.env.HOME || process.env.USERPROFILE; - if (typeof envHome === "string" && envHome.trim().length > 0) { - return path_1.default.resolve(envHome); - } - return os_1.default.tmpdir(); -} -function safeHomeDir() { - try { - return os_1.default.homedir(); - } - catch { - return fallbackHomeDir(); - } -} -function normalizeConfiguredPath(dir) { - if (typeof dir !== "string") - return null; - const trimmed = dir.trim(); - if (!trimmed) - return null; - return path_1.default.resolve(trimmed); -} -function getLegacyDotDataDir() { - return path_1.default.join(safeHomeDir(), `.${exports.APP_NAME}`); -} -function getDefaultDataDir() { - const homeDir = safeHomeDir(); - if (process.platform === "win32") { - const appData = process.env.APPDATA || path_1.default.join(homeDir, "AppData", "Roaming"); - return path_1.default.join(appData, exports.APP_NAME); - } - // Support XDG on Linux/macOS when explicitly configured. - const xdgConfigHome = normalizeConfiguredPath(process.env.XDG_CONFIG_HOME); - if (xdgConfigHome) { - return path_1.default.join(xdgConfigHome, exports.APP_NAME); - } - return getLegacyDotDataDir(); -} -function resolveDataDir({ isCloud = false } = {}) { - if (isCloud) - return "/tmp"; - const configured = normalizeConfiguredPath(process.env.DATA_DIR); - if (configured) - return configured; - return getDefaultDataDir(); -} -function isSamePath(a, b) { - if (!a || !b) - return false; - const normalizedA = path_1.default.resolve(a); - const normalizedB = path_1.default.resolve(b); - if (process.platform === "win32") { - return normalizedA.toLowerCase() === normalizedB.toLowerCase(); - } - return normalizedA === normalizedB; -} diff --git a/src/lib/monitoring/observability.ts b/src/lib/monitoring/observability.ts index 8feb1cdcc7..0088fd5192 100644 --- a/src/lib/monitoring/observability.ts +++ b/src/lib/monitoring/observability.ts @@ -4,7 +4,8 @@ interface CircuitBreakerStatus { name: string; state: string; failureCount?: number; - lastFailureTime?: string | null; + lastFailureTime?: number | string | null; + retryAfterMs?: number; } interface SessionSnapshot { @@ -155,13 +156,31 @@ export function buildHealthPayload({ platform: process.platform, }; + const providerBreakers = circuitBreakers + .filter((cb) => !cb.name.startsWith("test-") && !cb.name.startsWith("test_")) + .map((cb) => { + const lastFailure = + typeof cb.lastFailureTime === "number" && Number.isFinite(cb.lastFailureTime) + ? new Date(cb.lastFailureTime).toISOString() + : typeof cb.lastFailureTime === "string" + ? cb.lastFailureTime + : null; + return { + provider: cb.name, + state: cb.state, + failureCount: cb.failureCount || 0, + lastFailure, + retryAfterMs: cb.retryAfterMs || 0, + }; + }); + const providerHealth: Record = {}; - for (const cb of circuitBreakers) { - if (cb.name.startsWith("test-") || cb.name.startsWith("test_")) continue; - providerHealth[cb.name] = { - state: cb.state, - failures: cb.failureCount || 0, - lastFailure: cb.lastFailureTime || null, + for (const breaker of providerBreakers) { + providerHealth[breaker.provider] = { + state: breaker.state, + failures: breaker.failureCount, + lastFailure: breaker.lastFailure, + retryAfterMs: breaker.retryAfterMs, }; } @@ -197,6 +216,7 @@ export function buildHealthPayload({ ...breakerCounts, total: breakerCounts.open + breakerCounts.halfOpen + breakerCounts.closed, }, + providerBreakers, providerHealth, providerSummary: { catalogCount, diff --git a/src/lib/resilience/settings.ts b/src/lib/resilience/settings.ts new file mode 100644 index 0000000000..f6f65e8faf --- /dev/null +++ b/src/lib/resilience/settings.ts @@ -0,0 +1,419 @@ +import { DEFAULT_API_LIMITS, PROVIDER_PROFILES } from "@omniroute/open-sse/config/constants"; + +type JsonRecord = Record; +type AuthCategory = "oauth" | "apikey"; + +export interface RequestQueueSettings { + autoEnableApiKeyProviders: boolean; + requestsPerMinute: number; + minTimeBetweenRequestsMs: number; + concurrentRequests: number; + maxWaitMs: number; +} + +export interface ConnectionCooldownProfileSettings { + baseCooldownMs: number; + useUpstreamRetryHints: boolean; + maxBackoffSteps: number; +} + +export interface ProviderBreakerProfileSettings { + failureThreshold: number; + resetTimeoutMs: number; +} + +export interface WaitForCooldownSettings { + enabled: boolean; + maxRetries: number; + maxRetryWaitSec: number; + maxRetryWaitMs: number; +} + +export interface ResilienceSettings { + requestQueue: RequestQueueSettings; + connectionCooldown: Record; + providerBreaker: Record; + waitForCooldown: WaitForCooldownSettings; +} + +export interface ResilienceSettingsPatch { + requestQueue?: Partial; + connectionCooldown?: Partial>>; + providerBreaker?: Partial>>; + waitForCooldown?: Partial; +} + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function toInteger( + value: unknown, + fallback: number, + options: { min?: number; max?: number } = {} +): number { + const min = options.min ?? 0; + const max = options.max ?? Number.MAX_SAFE_INTEGER; + const parsed = + typeof value === "number" + ? value + : typeof value === "string" && value.trim().length > 0 + ? Number(value) + : Number.NaN; + + if (!Number.isFinite(parsed)) { + return fallback; + } + + return Math.min(max, Math.max(min, Math.trunc(parsed))); +} + +function toBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +export const DEFAULT_REQUEST_QUEUE_MAX_WAIT_MS = (() => { + const parsed = Number(process.env.RATE_LIMIT_MAX_WAIT_MS || "120000"); + return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : 120000; +})(); + +export const DEFAULT_RESILIENCE_SETTINGS: ResilienceSettings = { + requestQueue: { + autoEnableApiKeyProviders: true, + requestsPerMinute: DEFAULT_API_LIMITS.requestsPerMinute, + minTimeBetweenRequestsMs: DEFAULT_API_LIMITS.minTimeBetweenRequests, + concurrentRequests: DEFAULT_API_LIMITS.concurrentRequests, + maxWaitMs: DEFAULT_REQUEST_QUEUE_MAX_WAIT_MS, + }, + connectionCooldown: { + oauth: { + baseCooldownMs: PROVIDER_PROFILES.oauth.transientCooldown, + useUpstreamRetryHints: PROVIDER_PROFILES.oauth.rateLimitCooldown === 0, + maxBackoffSteps: PROVIDER_PROFILES.oauth.maxBackoffLevel, + }, + apikey: { + baseCooldownMs: PROVIDER_PROFILES.apikey.transientCooldown, + useUpstreamRetryHints: PROVIDER_PROFILES.apikey.rateLimitCooldown === 0, + maxBackoffSteps: PROVIDER_PROFILES.apikey.maxBackoffLevel, + }, + }, + providerBreaker: { + oauth: { + failureThreshold: PROVIDER_PROFILES.oauth.circuitBreakerThreshold, + resetTimeoutMs: PROVIDER_PROFILES.oauth.circuitBreakerReset, + }, + apikey: { + failureThreshold: PROVIDER_PROFILES.apikey.circuitBreakerThreshold, + resetTimeoutMs: PROVIDER_PROFILES.apikey.circuitBreakerReset, + }, + }, + waitForCooldown: { + enabled: true, + maxRetries: 3, + maxRetryWaitSec: 30, + maxRetryWaitMs: 30000, + }, +}; + +function normalizeRequestQueueSettings( + next: unknown, + fallback: RequestQueueSettings +): RequestQueueSettings { + const record = asRecord(next); + const requestsPerMinute = toInteger(record.requestsPerMinute, fallback.requestsPerMinute, { + min: 1, + max: 1_000_000, + }); + const minTimeBetweenRequestsMs = toInteger( + record.minTimeBetweenRequestsMs, + fallback.minTimeBetweenRequestsMs, + { min: 0, max: 60 * 60 * 1000 } + ); + const concurrentRequests = toInteger(record.concurrentRequests, fallback.concurrentRequests, { + min: 1, + max: 10_000, + }); + const maxWaitMs = toInteger(record.maxWaitMs, fallback.maxWaitMs, { + min: 1, + max: 24 * 60 * 60 * 1000, + }); + + return { + autoEnableApiKeyProviders: toBoolean( + record.autoEnableApiKeyProviders, + fallback.autoEnableApiKeyProviders + ), + requestsPerMinute, + minTimeBetweenRequestsMs, + concurrentRequests, + maxWaitMs, + }; +} + +function normalizeConnectionCooldownProfile( + next: unknown, + fallback: ConnectionCooldownProfileSettings +): ConnectionCooldownProfileSettings { + const record = asRecord(next); + return { + baseCooldownMs: toInteger(record.baseCooldownMs, fallback.baseCooldownMs, { + min: 0, + max: 24 * 60 * 60 * 1000, + }), + useUpstreamRetryHints: toBoolean(record.useUpstreamRetryHints, fallback.useUpstreamRetryHints), + maxBackoffSteps: toInteger(record.maxBackoffSteps, fallback.maxBackoffSteps, { + min: 0, + max: 32, + }), + }; +} + +function normalizeLegacyConnectionCooldownProfile( + next: unknown, + fallback: ConnectionCooldownProfileSettings +): ConnectionCooldownProfileSettings { + const record = asRecord(next); + const transientCooldown = toInteger(record.transientCooldown, fallback.baseCooldownMs, { + min: 0, + max: 24 * 60 * 60 * 1000, + }); + const legacyRateLimitCooldown = toInteger(record.rateLimitCooldown, transientCooldown, { + min: 0, + max: 24 * 60 * 60 * 1000, + }); + const useUpstreamRetryHints = + typeof record.rateLimitCooldown === "number" + ? record.rateLimitCooldown === 0 + : fallback.useUpstreamRetryHints; + + return { + baseCooldownMs: useUpstreamRetryHints + ? transientCooldown + : Math.max(transientCooldown, legacyRateLimitCooldown), + useUpstreamRetryHints, + maxBackoffSteps: toInteger(record.maxBackoffLevel, fallback.maxBackoffSteps, { + min: 0, + max: 32, + }), + }; +} + +function normalizeProviderBreakerProfile( + next: unknown, + fallback: ProviderBreakerProfileSettings +): ProviderBreakerProfileSettings { + const record = asRecord(next); + return { + failureThreshold: toInteger(record.failureThreshold, fallback.failureThreshold, { + min: 1, + max: 1000, + }), + resetTimeoutMs: toInteger(record.resetTimeoutMs, fallback.resetTimeoutMs, { + min: 1000, + max: 24 * 60 * 60 * 1000, + }), + }; +} + +function normalizeWaitForCooldownSettings( + next: unknown, + fallback: WaitForCooldownSettings +): WaitForCooldownSettings { + const record = asRecord(next); + const maxRetryWaitSec = toInteger(record.maxRetryWaitSec, fallback.maxRetryWaitSec, { + min: 0, + max: 300, + }); + const maxRetries = toInteger(record.maxRetries, fallback.maxRetries, { min: 0, max: 10 }); + const enabled = + toBoolean(record.enabled, fallback.enabled) && maxRetries > 0 && maxRetryWaitSec > 0; + + return { + enabled, + maxRetries, + maxRetryWaitSec, + maxRetryWaitMs: maxRetryWaitSec * 1000, + }; +} + +function buildLegacyFallback(settings: JsonRecord): ResilienceSettings { + const profiles = asRecord(settings.providerProfiles); + const defaults = asRecord(settings.rateLimitDefaults); + + const oauthLegacy = asRecord(profiles.oauth); + const apikeyLegacy = asRecord(profiles.apikey); + + const waitMaxRetrySec = toInteger( + settings.maxRetryIntervalSec, + DEFAULT_RESILIENCE_SETTINGS.waitForCooldown.maxRetryWaitSec, + { min: 0, max: 300 } + ); + const waitMaxRetries = toInteger( + settings.requestRetry, + DEFAULT_RESILIENCE_SETTINGS.waitForCooldown.maxRetries, + { min: 0, max: 10 } + ); + + return { + requestQueue: { + autoEnableApiKeyProviders: DEFAULT_RESILIENCE_SETTINGS.requestQueue.autoEnableApiKeyProviders, + requestsPerMinute: toInteger( + defaults.requestsPerMinute, + DEFAULT_RESILIENCE_SETTINGS.requestQueue.requestsPerMinute, + { min: 1, max: 1_000_000 } + ), + minTimeBetweenRequestsMs: toInteger( + defaults.minTimeBetweenRequests, + DEFAULT_RESILIENCE_SETTINGS.requestQueue.minTimeBetweenRequestsMs, + { min: 0, max: 60 * 60 * 1000 } + ), + concurrentRequests: toInteger( + defaults.concurrentRequests, + DEFAULT_RESILIENCE_SETTINGS.requestQueue.concurrentRequests, + { min: 1, max: 10_000 } + ), + maxWaitMs: DEFAULT_RESILIENCE_SETTINGS.requestQueue.maxWaitMs, + }, + connectionCooldown: { + oauth: normalizeLegacyConnectionCooldownProfile( + oauthLegacy, + DEFAULT_RESILIENCE_SETTINGS.connectionCooldown.oauth + ), + apikey: normalizeLegacyConnectionCooldownProfile( + apikeyLegacy, + DEFAULT_RESILIENCE_SETTINGS.connectionCooldown.apikey + ), + }, + providerBreaker: { + oauth: { + failureThreshold: toInteger( + oauthLegacy.circuitBreakerThreshold, + DEFAULT_RESILIENCE_SETTINGS.providerBreaker.oauth.failureThreshold, + { min: 1, max: 1000 } + ), + resetTimeoutMs: toInteger( + oauthLegacy.circuitBreakerReset, + DEFAULT_RESILIENCE_SETTINGS.providerBreaker.oauth.resetTimeoutMs, + { min: 1000, max: 24 * 60 * 60 * 1000 } + ), + }, + apikey: { + failureThreshold: toInteger( + apikeyLegacy.circuitBreakerThreshold, + DEFAULT_RESILIENCE_SETTINGS.providerBreaker.apikey.failureThreshold, + { min: 1, max: 1000 } + ), + resetTimeoutMs: toInteger( + apikeyLegacy.circuitBreakerReset, + DEFAULT_RESILIENCE_SETTINGS.providerBreaker.apikey.resetTimeoutMs, + { min: 1000, max: 24 * 60 * 60 * 1000 } + ), + }, + }, + waitForCooldown: { + enabled: waitMaxRetries > 0 && waitMaxRetrySec > 0, + maxRetries: waitMaxRetries, + maxRetryWaitSec: waitMaxRetrySec, + maxRetryWaitMs: waitMaxRetrySec * 1000, + }, + }; +} + +export function resolveResilienceSettings( + settings: Record | null | undefined +): ResilienceSettings { + const record = asRecord(settings); + const current = asRecord(record.resilienceSettings); + const fallback = buildLegacyFallback(record); + + return { + requestQueue: normalizeRequestQueueSettings(current.requestQueue, fallback.requestQueue), + connectionCooldown: { + oauth: normalizeConnectionCooldownProfile( + asRecord(current.connectionCooldown).oauth, + fallback.connectionCooldown.oauth + ), + apikey: normalizeConnectionCooldownProfile( + asRecord(current.connectionCooldown).apikey, + fallback.connectionCooldown.apikey + ), + }, + providerBreaker: { + oauth: normalizeProviderBreakerProfile( + asRecord(current.providerBreaker).oauth, + fallback.providerBreaker.oauth + ), + apikey: normalizeProviderBreakerProfile( + asRecord(current.providerBreaker).apikey, + fallback.providerBreaker.apikey + ), + }, + waitForCooldown: normalizeWaitForCooldownSettings( + current.waitForCooldown, + fallback.waitForCooldown + ), + }; +} + +export function mergeResilienceSettings( + current: ResilienceSettings, + updates: ResilienceSettingsPatch +): ResilienceSettings { + return { + requestQueue: normalizeRequestQueueSettings(updates.requestQueue, current.requestQueue), + connectionCooldown: { + oauth: normalizeConnectionCooldownProfile( + updates.connectionCooldown?.oauth, + current.connectionCooldown.oauth + ), + apikey: normalizeConnectionCooldownProfile( + updates.connectionCooldown?.apikey, + current.connectionCooldown.apikey + ), + }, + providerBreaker: { + oauth: normalizeProviderBreakerProfile( + updates.providerBreaker?.oauth, + current.providerBreaker.oauth + ), + apikey: normalizeProviderBreakerProfile( + updates.providerBreaker?.apikey, + current.providerBreaker.apikey + ), + }, + waitForCooldown: normalizeWaitForCooldownSettings( + updates.waitForCooldown, + current.waitForCooldown + ), + }; +} + +export function buildLegacyResilienceCompat(settings: ResilienceSettings) { + return { + profiles: { + oauth: { + transientCooldown: settings.connectionCooldown.oauth.baseCooldownMs, + rateLimitCooldown: settings.connectionCooldown.oauth.useUpstreamRetryHints + ? 0 + : settings.connectionCooldown.oauth.baseCooldownMs, + maxBackoffLevel: settings.connectionCooldown.oauth.maxBackoffSteps, + circuitBreakerThreshold: settings.providerBreaker.oauth.failureThreshold, + circuitBreakerReset: settings.providerBreaker.oauth.resetTimeoutMs, + }, + apikey: { + transientCooldown: settings.connectionCooldown.apikey.baseCooldownMs, + rateLimitCooldown: settings.connectionCooldown.apikey.useUpstreamRetryHints + ? 0 + : settings.connectionCooldown.apikey.baseCooldownMs, + maxBackoffLevel: settings.connectionCooldown.apikey.maxBackoffSteps, + circuitBreakerThreshold: settings.providerBreaker.apikey.failureThreshold, + circuitBreakerReset: settings.providerBreaker.apikey.resetTimeoutMs, + }, + }, + defaults: { + requestsPerMinute: settings.requestQueue.requestsPerMinute, + minTimeBetweenRequests: settings.requestQueue.minTimeBetweenRequestsMs, + concurrentRequests: settings.requestQueue.concurrentRequests, + }, + }; +} diff --git a/src/shared/utils/circuitBreaker.ts b/src/shared/utils/circuitBreaker.ts index 3f9e0e332a..3700bba31c 100644 --- a/src/shared/utils/circuitBreaker.ts +++ b/src/shared/utils/circuitBreaker.ts @@ -180,6 +180,7 @@ export class CircuitBreaker { state: this.state, failureCount: this.failureCount, lastFailureTime: this.lastFailureTime, + retryAfterMs: this.getRetryAfterMs(), }; } diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index ae120f3da7..fc6d83101e 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -616,11 +616,6 @@ export const updateModelAliasSchema = z.object({ alias: z.string().trim().min(1, "Alias is required").max(200), }); -export const clearModelAvailabilitySchema = z.object({ - provider: z.string().trim().min(1, "provider is required").max(120), - model: modelIdSchema, -}); - /** Align with `sanitizeUpstreamHeadersMap` — allow non-ASCII names; reject Host / hop-by-hop / whitespace / ":". */ const upstreamHeaderNameSchema = z .string() @@ -709,7 +704,7 @@ export const toggleRateLimitSchema = z.object({ enabled: z.boolean(), }); -const resilienceProfileSchema = z.object({ +const legacyResilienceProfileSchema = z.object({ transientCooldown: z.number().min(0), rateLimitCooldown: z.number().min(0), maxBackoffLevel: z.number().int().min(0), @@ -717,30 +712,87 @@ const resilienceProfileSchema = z.object({ circuitBreakerReset: z.number().min(0), }); -const resilienceDefaultsSchema = z +const legacyResilienceDefaultsSchema = z .object({ requestsPerMinute: z.number().int().min(1).optional(), - minTimeBetweenRequests: z.number().int().min(1).optional(), + minTimeBetweenRequests: z.number().int().min(0).optional(), concurrentRequests: z.number().int().min(1).optional(), }) .strict(); +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(); + +const connectionCooldownProfileSchema = z + .object({ + baseCooldownMs: z.number().int().min(0).optional(), + useUpstreamRetryHints: z.boolean().optional(), + maxBackoffSteps: z.number().int().min(0).optional(), + }) + .strict(); + +const providerBreakerProfileSchema = z + .object({ + failureThreshold: z.number().int().min(1).optional(), + resetTimeoutMs: z.number().int().min(1000).optional(), + }) + .strict(); + +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(); + export const updateResilienceSchema = z .object({ - profiles: z + requestQueue: requestQueueSettingsSchema.optional(), + connectionCooldown: z .object({ - oauth: resilienceProfileSchema.optional(), - apikey: resilienceProfileSchema.optional(), + oauth: connectionCooldownProfileSchema.optional(), + apikey: connectionCooldownProfileSchema.optional(), }) .strict() .optional(), - defaults: resilienceDefaultsSchema.optional(), + providerBreaker: z + .object({ + oauth: providerBreakerProfileSchema.optional(), + apikey: providerBreakerProfileSchema.optional(), + }) + .strict() + .optional(), + waitForCooldown: waitForCooldownSettingsSchema.optional(), + profiles: z + .object({ + oauth: legacyResilienceProfileSchema.optional(), + apikey: legacyResilienceProfileSchema.optional(), + }) + .strict() + .optional(), + defaults: legacyResilienceDefaultsSchema.optional(), }) + .strict() .superRefine((value, ctx) => { - if (!value.profiles && !value.defaults) { + if ( + !value.requestQueue && + !value.connectionCooldown && + !value.providerBreaker && + !value.waitForCooldown && + !value.profiles && + !value.defaults + ) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "Must provide profiles or defaults", + message: "Must provide resilience settings to update", path: [], }); } diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 70f546be1b..b66d1ea534 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -42,11 +42,6 @@ import { // Pipeline integration — wired modules import { getCircuitBreaker } from "../../shared/utils/circuitBreaker"; -import { - isModelAvailable, - markModelAsProblematic, - clearModelUnavailability, -} from "../../domain/modelAvailability"; import { markAccountExhaustedFrom429 } from "../../domain/quotaCache"; import { RequestTelemetry, recordTelemetry } from "../../shared/utils/requestTelemetry"; import { generateRequestId } from "../../shared/utils/requestId"; @@ -107,6 +102,8 @@ function intersectAllowedConnectionIds(primary: unknown, secondary: unknown): st return first || second || null; } +const PROVIDER_BREAKER_FAILURE_STATUSES = new Set([408, 500, 502, 503, 504]); + /** * Handle chat completion request * Supports: OpenAI, Claude, Gemini, OpenAI Responses API formats @@ -324,14 +321,6 @@ export async function handleChat(request: any, clientRawRequest: any = null) { return false; } - // Fixed-account combo steps must bypass the provider/model cooldown gate here. - // A previous account failure can quarantine the model globally, but the next - // step may intentionally pin a different connection for the same model. - if (!hasForcedConnection && !isModelAvailable(provider, resolvedModel)) { - log.debug("AVAILABILITY", `${provider}/${modelInfo.model} in cooldown, skipping`); - return false; - } - const creds = await getProviderCredentialsWithQuotaPreflight( provider, null, @@ -524,7 +513,7 @@ async function handleSingleModelChat( ? "fixed combo step connection" : undefined; - // 2. Pipeline gates (availability + circuit breaker) + // 2. Pipeline gates (availability + provider circuit breaker) const providerProfile = await getRuntimeProviderProfile(provider); const gate = await checkPipelineGates(provider, model, { ignoreCircuitBreaker: forceLiveComboTest || hasForcedConnection, @@ -535,8 +524,8 @@ async function handleSingleModelChat( if (gate) return gate; const breaker = getCircuitBreaker(provider, { - failureThreshold: providerProfile.circuitBreakerThreshold, - resetTimeout: providerProfile.circuitBreakerReset, + failureThreshold: providerProfile.failureThreshold, + resetTimeout: providerProfile.resetTimeoutMs, onStateChange: (name: string, from: string, to: string) => log.info("CIRCUIT", `${name}: ${from} → ${to}`), }); @@ -549,9 +538,10 @@ async function handleSingleModelChat( const retrySettings = disableCooldownAwareRetry ? { ...baseRetrySettings, - requestRetry: 0, - maxRetryIntervalSec: 0, - maxRetryIntervalMs: 0, + enabled: false, + maxRetries: 0, + maxRetryWaitSec: 0, + maxRetryWaitMs: 0, } : baseRetrySettings; const requestSignal = request?.signal ?? null; @@ -597,26 +587,6 @@ async function handleSingleModelChat( ); if (!credentials || "allRateLimited" in credentials) { - if ([408, 429, 500, 502, 503, 504].includes(Number(lastStatus))) { - const quarantine = markModelAsProblematic(provider, model, { - status: Number(lastStatus), - baseCooldownMs: lastCooldownMs, - reason: `HTTP ${lastStatus}`, - profile: providerProfile, - }); - if (quarantine.quarantined) { - log.info( - "AVAILABILITY", - `${provider}/${model} marked unavailable — all accounts exhausted (HTTP ${lastStatus}, cooldown ${Math.ceil(quarantine.cooldownMs / 1000)}s, failureCount ${quarantine.failureCount}/${quarantine.threshold})` - ); - } else { - log.info( - "AVAILABILITY", - `${provider}/${model} recorded exhaustion failure ${quarantine.failureCount}/${quarantine.threshold} (HTTP ${lastStatus}, cooldown basis ${Math.ceil(quarantine.cooldownMs / 1000)}s)` - ); - } - } - if (credentials?.allRateLimited) { const retryDecision = getCooldownAwareRetryDecision({ retryAfter: credentials.retryAfter, @@ -628,7 +598,7 @@ async function handleSingleModelChat( const waitSec = Math.max(Math.ceil(retryDecision.waitMs / 1000), 0); log.info( "COOLDOWN_RETRY", - `${provider}/${model} all accounts cooling down (${retryDecision.retryAfterHuman || `retry in ${waitSec}s`}) — waiting ${waitSec}s before retry ${requestRetryAttempt + 1}/${retrySettings.requestRetry}` + `${provider}/${model} all connections cooling down (${retryDecision.retryAfterHuman || `retry in ${waitSec}s`}) — waiting ${waitSec}s before retry ${requestRetryAttempt + 1}/${retrySettings.maxRetries}` ); const completed = await waitForCooldownAwareRetry(retryDecision.waitMs, requestSignal); @@ -643,12 +613,21 @@ async function handleSingleModelChat( requestRetryAttempt += 1; log.info( "COOLDOWN_RETRY", - `${provider}/${model} cooldown elapsed — restarting request attempt ${requestRetryAttempt}/${retrySettings.requestRetry}` + `${provider}/${model} cooldown elapsed — restarting request attempt ${requestRetryAttempt}/${retrySettings.maxRetries}` ); continue requestAttemptLoop; } } + const breakerFailureStatus = Number(lastStatus ?? credentials?.lastErrorCode); + if ( + !forceLiveComboTest && + credentials?.allRateLimited && + PROVIDER_BREAKER_FAILURE_STATUSES.has(breakerFailureStatus) + ) { + breaker._onFailure(); + } + return handleNoCredentials( credentials, excludedConnectionIds.size > 0 ? Array.from(excludedConnectionIds)[0] : null, @@ -717,11 +696,9 @@ async function handleSingleModelChat( const proxyInfo = await safeResolveProxy(credentials.connectionId); const proxyStartTime = Date.now(); - // 4. Execute chat via core (with circuit breaker + optional TLS) + // 4. Execute chat via core after breaker gate checks (with optional TLS tracking) if (telemetry) telemetry.startPhase("connect"); const { result, tlsFingerprintUsed } = await executeChatWithBreaker({ - bypassCircuitBreaker: forceLiveComboTest, - breaker, body: requestBody, provider, model, @@ -765,7 +742,9 @@ async function handleSingleModelChat( if (result.success) { clearModelLock(provider, credentials.connectionId, model); - clearModelUnavailability(provider, model); + if (!forceLiveComboTest) { + breaker._onSuccess(); + } if (injectedHandoff && runtimeOptions.sessionId && comboName) { deleteHandoff(runtimeOptions.sessionId, comboName); } @@ -873,6 +852,10 @@ async function handleSingleModelChat( continue; } + if (!forceLiveComboTest && PROVIDER_BREAKER_FAILURE_STATUSES.has(Number(result.status))) { + breaker._onFailure(); + } + return result.response; } } diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index ef5dd9213e..8099889ed3 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -14,6 +14,7 @@ import { handleChatCore } from "@omniroute/open-sse/handlers/chatCore.ts"; import { errorResponse, modelCooldownResponse, + providerCircuitOpenResponse, unavailableResponse, } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; @@ -23,14 +24,10 @@ import { isTlsFingerprintActive, } from "@omniroute/open-sse/utils/proxyFetch.ts"; import { resolveProxyForConnection } from "@/lib/localDb"; -import { getCircuitBreaker, CircuitBreakerOpenError } from "../../shared/utils/circuitBreaker"; -import { getModelCooldownInfo, isModelAvailable } from "../../domain/modelAvailability"; +import { getCircuitBreaker } from "../../shared/utils/circuitBreaker"; import { logProxyEvent } from "../../lib/proxyLogger"; import { logTranslationEvent } from "../../lib/translatorEvents"; -import { - getRuntimeProviderProfile, - clearProviderFailure, -} from "@omniroute/open-sse/services/accountFallback.ts"; +import { getRuntimeProviderProfile } from "@omniroute/open-sse/services/accountFallback.ts"; export async function resolveModelOrError(modelStr: string, body: any, endpointPath: string = "") { const modelInfo = await getModelInfo(modelStr); @@ -79,30 +76,16 @@ export async function checkPipelineGates( providerProfile?: { circuitBreakerThreshold?: number; circuitBreakerReset?: number; + failureThreshold?: number; + resetTimeoutMs?: number; } | null; } = {} ) { const bypassReason = options.bypassReason || "pipeline override"; - const modelAvailable = isModelAvailable(provider, model); - if (!modelAvailable && options.ignoreModelCooldown) { - log.info("AVAILABILITY", `${provider}/${model} cooldown bypassed (${bypassReason})`); - } else if (!modelAvailable) { - const cooldownInfo = getModelCooldownInfo(provider, model); - const retryAfterSec = cooldownInfo - ? Math.max(Math.ceil(cooldownInfo.remainingMs / 1000), 1) - : 1; - log.warn("AVAILABILITY", `${provider}/${model} is in cooldown, rejecting request`); - return unavailableResponse( - HTTP_STATUS.SERVICE_UNAVAILABLE, - `Model ${provider}/${model} is temporarily unavailable (cooldown)`, - retryAfterSec - ); - } - const providerProfile = options.providerProfile ?? (await getRuntimeProviderProfile(provider)); const breaker = getCircuitBreaker(provider, { - failureThreshold: providerProfile.circuitBreakerThreshold, - resetTimeout: providerProfile.circuitBreakerReset, + failureThreshold: providerProfile.failureThreshold ?? providerProfile.circuitBreakerThreshold, + resetTimeout: providerProfile.resetTimeoutMs ?? providerProfile.circuitBreakerReset, onStateChange: (name: string, from: string, to: string) => log.info("CIRCUIT", `${name}: ${from} → ${to}`), }); @@ -112,19 +95,13 @@ export async function checkPipelineGates( const retryAfterMs = breaker.getRetryAfterMs(); const retryAfterSec = Math.max(Math.ceil(retryAfterMs / 1000), 1); log.warn("CIRCUIT", `Circuit breaker OPEN for ${provider}, rejecting request`); - return unavailableResponse( - HTTP_STATUS.SERVICE_UNAVAILABLE, - `Provider ${provider} circuit breaker is open`, - retryAfterSec - ); + return providerCircuitOpenResponse(provider, retryAfterSec); } return null; } export async function executeChatWithBreaker({ - bypassCircuitBreaker, - breaker, body, provider, model, @@ -173,46 +150,18 @@ export async function executeChatWithBreaker({ }, onRequestSuccess: async () => { await clearAccountError(credentials.connectionId, credentials); - // Clear provider-level failure state on successful request - clearProviderFailure(provider); }, }) ); - if (bypassCircuitBreaker) { - if (!proxyInfo?.proxy && isTlsFingerprintActive()) { - const tracked = await runWithTlsTracking(chatFn); - return { result: tracked.result, tlsFingerprintUsed: tracked.tlsFingerprintUsed }; - } - - const result = await chatFn(); - return { result, tlsFingerprintUsed: false }; - } - if (!proxyInfo?.proxy && isTlsFingerprintActive()) { - const tracked = await breaker.execute(async () => runWithTlsTracking(chatFn)); + const tracked = await runWithTlsTracking(chatFn); return { result: tracked.result, tlsFingerprintUsed: tracked.tlsFingerprintUsed }; } - const result = await breaker.execute(chatFn); + const result = await chatFn(); return { result, tlsFingerprintUsed: false }; } catch (cbErr: any) { - if (cbErr instanceof CircuitBreakerOpenError) { - log.warn("CIRCUIT", `${provider} circuit open during retry: ${cbErr.message}`); - return { - result: { - success: false, - response: unavailableResponse( - HTTP_STATUS.SERVICE_UNAVAILABLE, - `Provider ${provider} circuit breaker is open`, - Math.ceil(cbErr.retryAfterMs / 1000) - ), - status: HTTP_STATUS.SERVICE_UNAVAILABLE, - }, - tlsFingerprintUsed: false, - }; - } - if (cbErr?.code === "PROXY_UNREACHABLE" || /proxy unreachable/i.test(cbErr?.message || "")) { const detail = cbErr?.message || "Proxy unreachable"; log.warn("PROXY", detail); diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index ec601a3810..59e64366ad 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -19,12 +19,14 @@ import { hasPerModelQuota, getRuntimeProviderProfile, recordModelLockoutFailure, - isProviderInCooldown, - getProviderCooldownRemainingMs, } from "@omniroute/open-sse/services/accountFallback.ts"; import { isLocalProvider } from "@omniroute/open-sse/config/providerRegistry.ts"; import { COOLDOWN_MS } from "@omniroute/open-sse/config/constants.ts"; import { preflightQuota } from "@omniroute/open-sse/services/quotaPreflight.ts"; +import { + classifyProviderError, + PROVIDER_ERROR_TYPES, +} from "@omniroute/open-sse/services/errorClassifier.ts"; import { getCodexModelScope } from "@omniroute/open-sse/executors/codex.ts"; import { getProviderAlias, resolveProviderId } from "@/shared/constants/providers"; import { isModelExcludedByConnection } from "@/domain/connectionModelRules"; @@ -253,6 +255,31 @@ function isTerminalConnectionStatus(connection: ProviderConnectionView): boolean return status === "credits_exhausted" || status === "banned" || status === "expired"; } +function resolveTerminalConnectionStatus( + status: number, + result: { permanent?: boolean; creditsExhausted?: boolean }, + providerErrorType: string | null = null +): string | null { + if (result.creditsExhausted || status === 402) return "credits_exhausted"; + if ( + providerErrorType === PROVIDER_ERROR_TYPES.PROJECT_ROUTE_ERROR || + providerErrorType === PROVIDER_ERROR_TYPES.OAUTH_INVALID_TOKEN + ) { + return null; + } + if (result.permanent || providerErrorType === PROVIDER_ERROR_TYPES.FORBIDDEN || status === 403) { + return "banned"; + } + if ( + providerErrorType === PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED || + providerErrorType === PROVIDER_ERROR_TYPES.UNAUTHORIZED || + status === 401 + ) { + return "expired"; + } + return null; +} + export function resolveQuotaLimitPolicy( provider: string, providerSpecificData: JsonRecord @@ -684,22 +711,6 @@ export async function getProviderCredentials( return true; }); - // Check if the entire provider is in cooldown (too many transient failures) - if (isProviderInCooldown(provider)) { - const cooldownRemaining = getProviderCooldownRemainingMs(provider); - log.warn( - "AUTH", - `${provider} | provider in cooldown for ${Math.ceil((cooldownRemaining || 0) / 1000)}s (${availableConnections.length} connections bypassed)` - ); - return { - allRateLimited: true, - retryAfter: new Date(Date.now() + (cooldownRemaining || 0)).toISOString(), - retryAfterHuman: formatRetryAfter( - new Date(Date.now() + (cooldownRemaining || 0)).toISOString() - ), - }; - } - log.debug( "AUTH", `${provider} | available: ${availableConnections.length}/${connections.length}` @@ -1206,6 +1217,15 @@ export async function markAccountUnavailable( const effectiveProviderProfile = providerProfile || (provider ? await getRuntimeProviderProfile(provider) : null); + const fallbackResult = checkFallbackError( + status, + errorText, + backoffLevel, + model, + provider, + null, + effectiveProviderProfile + ); // Read passthroughModels from connection config (user-configured per-model quota) const connProviderSpecificData = (conn?.providerSpecificData as Record) || {}; @@ -1216,18 +1236,20 @@ export async function markAccountUnavailable( const isPerModelQuotaProvider = hasPerModelQuota(provider, model, connectionPassthroughModels); if (isPerModelQuotaProvider && provider && model && (status === 404 || status === 429)) { const reason = status === 404 ? "not_found" : "rate_limited"; - const fallbackCooldown = - status === 404 - ? (effectiveProviderProfile?.transientCooldown ?? COOLDOWN_MS.notFoundLocal) - : 0; const lockout = recordModelLockoutFailure( provider, connectionId, model, reason, status, - fallbackCooldown, - effectiveProviderProfile + status === 404 + ? (effectiveProviderProfile?.baseCooldownMs ?? COOLDOWN_MS.notFoundLocal) + : (fallbackResult.baseCooldownMs ?? effectiveProviderProfile?.baseCooldownMs ?? 0), + effectiveProviderProfile, + { + exactCooldownMs: + fallbackResult.usedUpstreamRetryHint === true ? fallbackResult.cooldownMs : null, + } ); // Update last error for observability (without changing terminal status) updateProviderConnection(connectionId, { @@ -1242,18 +1264,12 @@ export async function markAccountUnavailable( ); return { shouldFallback: true, cooldownMs: lockout.cooldownMs }; } - - const result = checkFallbackError( - status, - errorText, - backoffLevel, - model, - provider, - null, - effectiveProviderProfile - ); - const { shouldFallback, cooldownMs, newBackoffLevel, reason } = result; + const result = fallbackResult; + const { shouldFallback, cooldownMs: rawCooldownMs, newBackoffLevel, reason } = result; if (!shouldFallback) return { shouldFallback: false, cooldownMs: 0 }; + const providerErrorType = classifyProviderError(status, errorText); + const terminalStatus = resolveTerminalConnectionStatus(status, result, providerErrorType); + const cooldownMs = terminalStatus ? 0 : rawCooldownMs; // ── 404 model-only lockout: connection stays active ── // For local providers (detected by URL), a 404 means the specific model @@ -1286,7 +1302,6 @@ export async function markAccountUnavailable( return { shouldFallback: true, cooldownMs: lockout.cooldownMs }; } - const rateLimitedUntil = getUnavailableUntil(cooldownMs); const errorMsg = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error"; // T09: Codex per-scope lockout (do not block the whole account globally). @@ -1294,7 +1309,7 @@ export async function markAccountUnavailable( const scope = getCodexModelScope(model); const existingScopeMap = asRecord(conn.providerSpecificData.codexScopeRateLimitedUntil); const persistedScopeUntil = getCodexScopeRateLimitedUntil(conn.providerSpecificData, model); - const scopeRateLimitedUntil = persistedScopeUntil || rateLimitedUntil; + const scopeRateLimitedUntil = persistedScopeUntil || getUnavailableUntil(cooldownMs); const scopeCooldownMs = Math.max(new Date(scopeRateLimitedUntil).getTime() - Date.now(), 0); await updateProviderConnection(connectionId, { @@ -1323,14 +1338,27 @@ export async function markAccountUnavailable( return { shouldFallback: true, cooldownMs: scopeCooldownMs }; } - await updateProviderConnection(connectionId, { - rateLimitedUntil, - testStatus: "unavailable", + const baseUpdate = { lastError: errorMsg, + lastErrorType: providerErrorType, errorCode: status, lastErrorAt: new Date().toISOString(), backoffLevel: newBackoffLevel ?? backoffLevel, - }); + }; + + if (cooldownMs > 0) { + await updateProviderConnection(connectionId, { + ...baseUpdate, + rateLimitedUntil: getUnavailableUntil(cooldownMs), + testStatus: "unavailable", + }); + } else { + await updateProviderConnection(connectionId, { + ...baseUpdate, + rateLimitedUntil: null, + ...(terminalStatus ? { testStatus: terminalStatus } : {}), + }); + } // T-AUTODISABLE: If auto-disable setting is enabled and error is permanent/terminal, // mark account as inactive so it is never retried again. @@ -1354,11 +1382,6 @@ export async function markAccountUnavailable( } } - // Per-model lockout: lock the specific model if known - if (provider && model && cooldownMs > 0) { - lockModel(provider, connectionId, model, reason || "unknown", cooldownMs); - } - if (provider && status && errorMsg) { console.error(`❌ ${provider} [${status}]: ${errorMsg}`); } diff --git a/src/sse/services/cooldownAwareRetry.ts b/src/sse/services/cooldownAwareRetry.ts index 99f1ef5f4a..14a95c605d 100644 --- a/src/sse/services/cooldownAwareRetry.ts +++ b/src/sse/services/cooldownAwareRetry.ts @@ -1,14 +1,14 @@ import { formatRetryAfter } from "@omniroute/open-sse/services/accountFallback.ts"; +import { resolveResilienceSettings } from "@/lib/resilience/settings"; -const DEFAULT_REQUEST_RETRY = 3; -const DEFAULT_MAX_RETRY_INTERVAL_SEC = 30; const MAX_REQUEST_RETRY = 10; const MAX_RETRY_INTERVAL_SEC = 300; export interface CooldownAwareRetrySettings { - requestRetry: number; - maxRetryIntervalSec: number; - maxRetryIntervalMs: number; + enabled: boolean; + maxRetries: number; + maxRetryWaitSec: number; + maxRetryWaitMs: number; } function normalizeInteger( @@ -35,20 +35,26 @@ function normalizeInteger( export function resolveCooldownAwareRetrySettings( settings: Record | null | undefined ): CooldownAwareRetrySettings { - const requestRetry = normalizeInteger(settings?.requestRetry, DEFAULT_REQUEST_RETRY, { + const waitForCooldown = resolveResilienceSettings(settings).waitForCooldown; + const maxRetries = normalizeInteger(waitForCooldown.maxRetries, waitForCooldown.maxRetries, { min: 0, max: MAX_REQUEST_RETRY, }); - const maxRetryIntervalSec = normalizeInteger( - settings?.maxRetryIntervalSec, - DEFAULT_MAX_RETRY_INTERVAL_SEC, - { min: 0, max: MAX_RETRY_INTERVAL_SEC } + const maxRetryWaitSec = normalizeInteger( + waitForCooldown.maxRetryWaitSec, + waitForCooldown.maxRetryWaitSec, + { + min: 0, + max: MAX_RETRY_INTERVAL_SEC, + } ); + const enabled = Boolean(waitForCooldown.enabled) && maxRetries > 0 && maxRetryWaitSec > 0; return { - requestRetry, - maxRetryIntervalSec, - maxRetryIntervalMs: maxRetryIntervalSec * 1000, + enabled, + maxRetries, + maxRetryWaitSec, + maxRetryWaitMs: maxRetryWaitSec * 1000, }; } @@ -90,9 +96,10 @@ export function getCooldownAwareRetryDecision({ } { const closest = computeClosestRetryAfter(retryAfter); if ( - settings.requestRetry <= 0 || - settings.maxRetryIntervalMs <= 0 || - attempt >= settings.requestRetry || + !settings.enabled || + settings.maxRetries <= 0 || + settings.maxRetryWaitMs <= 0 || + attempt >= settings.maxRetries || closest.waitMs === null ) { return { @@ -103,7 +110,7 @@ export function getCooldownAwareRetryDecision({ }; } - if (closest.waitMs > settings.maxRetryIntervalMs) { + if (closest.waitMs > settings.maxRetryWaitMs) { return { shouldRetry: false, retryAfter: closest.retryAfter, diff --git a/src/types/settings.ts b/src/types/settings.ts index 9f4f32b38e..273b5bf941 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -1,4 +1,5 @@ import type { HideableSidebarItemId } from "@/shared/constants/sidebarVisibility"; +import type { ResilienceSettings } from "@/lib/resilience/settings"; /** * Application settings stored in SQLite key-value pairs. @@ -20,15 +21,13 @@ export interface Settings { jwtSecret?: string; hideHealthCheckLogs?: boolean; hiddenSidebarItems?: HideableSidebarItemId[]; + resilienceSettings?: ResilienceSettings; } export interface ComboDefaults { strategy: "priority" | "weighted" | "round-robin" | "context-relay"; maxRetries: number; retryDelayMs: number; - timeoutMs: number; - healthCheckEnabled: boolean; - healthCheckTimeoutMs: number; maxComboDepth: number; trackMetrics: boolean; concurrencyPerModel?: number; diff --git a/tests/e2e/resilience-plan-alignment.spec.ts b/tests/e2e/resilience-plan-alignment.spec.ts new file mode 100644 index 0000000000..817ebcf5bc --- /dev/null +++ b/tests/e2e/resilience-plan-alignment.spec.ts @@ -0,0 +1,374 @@ +import { expect, test, type Page } from "@playwright/test"; +import { gotoDashboardRoute } from "./helpers/dashboardAuth"; + +const resilienceSettings = { + requestQueue: { + autoEnableApiKeyProviders: true, + requestsPerMinute: 100, + minTimeBetweenRequestsMs: 200, + concurrentRequests: 10, + maxWaitMs: 120000, + }, + connectionCooldown: { + oauth: { + baseCooldownMs: 60000, + useUpstreamRetryHints: false, + maxBackoffSteps: 8, + }, + apikey: { + baseCooldownMs: 3000, + useUpstreamRetryHints: true, + maxBackoffSteps: 5, + }, + }, + providerBreaker: { + oauth: { + failureThreshold: 3, + resetTimeoutMs: 60000, + }, + apikey: { + failureThreshold: 5, + resetTimeoutMs: 30000, + }, + }, + waitForCooldown: { + enabled: true, + maxRetries: 3, + maxRetryWaitSec: 30, + }, +}; + +async function mockResilienceSettings(page: Page) { + await page.route("**/api/resilience", async (route) => { + const method = route.request().method(); + if (method === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(resilienceSettings), + }); + return; + } + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + ok: true, + ...resilienceSettings, + }), + }); + }); +} + +async function mockHealthPageApis(page: Page) { + const now = new Date().toISOString(); + + await page.route("**/api/monitoring/health", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + status: "error", + system: { + uptime: 3723, + version: "3.7.0", + nodeVersion: "22.12.0", + memoryUsage: { + rss: 64 * 1024 * 1024, + heapUsed: 24 * 1024 * 1024, + heapTotal: 48 * 1024 * 1024, + }, + }, + providerHealth: { + openai: { + state: "OPEN", + failures: 3, + retryAfterMs: 15000, + lastFailure: now, + }, + gemini: { + state: "HALF_OPEN", + failures: 1, + retryAfterMs: 5000, + lastFailure: now, + }, + groq: { + state: "CLOSED", + failures: 0, + retryAfterMs: 0, + lastFailure: null, + }, + }, + providerSummary: { + configuredCount: 3, + activeCount: 2, + monitoredCount: 3, + }, + rateLimitStatus: {}, + lockouts: {}, + sessions: { + activeCount: 0, + stickyBoundCount: 0, + byApiKey: {}, + top: [], + }, + quotaMonitor: { + active: 0, + alerting: 0, + exhausted: 0, + errors: 0, + byProvider: {}, + monitors: [], + }, + }), + }); + }); + + await page.route("**/api/telemetry/summary", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ p50: 120, p95: 240, p99: 450, totalRequests: 18 }), + }); + }); + + await page.route("**/api/cache/stats", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ size: 3, maxSize: 100, hitRate: 50, hits: 2, misses: 2 }), + }); + }); + + await page.route("**/api/rate-limits", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + cacheStats: { + defaultCount: 0, + tool: { entries: 0, patterns: 0 }, + family: { entries: 0, patterns: 0 }, + session: { entries: 0, patterns: 0 }, + }, + }), + }); + }); + + await page.route("**/api/health/degradation", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + summary: { full: 0, reduced: 0, minimal: 0, default: 0 }, + features: [], + }), + }); + }); + + await page.route("**/api/v1/db/health", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + isHealthy: true, + issues: [], + repairedCount: 0, + backupCreated: false, + }), + }); + }); +} + +async function mockProvidersPageApis(page: Page) { + await page.route("**/api/providers", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + connections: [ + { + id: "conn-openai-main", + provider: "openai", + authType: "apikey", + name: "OpenAI Main", + testStatus: "active", + }, + { + id: "conn-gemini-main", + provider: "gemini", + authType: "apikey", + name: "Gemini Main", + testStatus: "active", + }, + ], + }), + }); + }); + + await page.route("**/api/provider-nodes", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ nodes: [], ccCompatibleProviderEnabled: false }), + }); + }); + + await page.route("**/api/providers/expiration", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({}), + }); + }); + + await page.route("**/api/system/env/repair", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ available: false, missingCount: 0 }), + }); + }); +} + +async function mockIntelligentCombosPageApis(page: Page) { + await page.route("**/api/combos", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + combos: [ + { + id: "combo-auto", + name: "combo-auto", + models: ["openai/gpt-4o-mini", "gemini/gemini-2.5-pro"], + strategy: "auto", + config: { + candidatePool: ["openai", "gemini", "anthropic"], + modePack: "ship-fast", + explorationRate: 0.15, + }, + isActive: true, + }, + ], + }), + }); + }); + + await page.route("**/api/combos/metrics", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ metrics: {} }), + }); + }); + + await page.route("**/api/providers", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + connections: [ + { id: "conn-openai", provider: "openai", name: "OpenAI", testStatus: "active" }, + { id: "conn-gemini", provider: "gemini", name: "Gemini", testStatus: "active" }, + { + id: "conn-anthropic", + provider: "anthropic", + name: "Anthropic", + testStatus: "active", + }, + ], + }), + }); + }); + + await page.route("**/api/provider-nodes", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ nodes: [] }), + }); + }); + + await page.route("**/api/settings/proxy", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ combos: {} }), + }); + }); +} + +test.describe("Resilience Plan Alignment", () => { + test("resilience settings page only shows the plan-aligned cooldown fields", async ({ page }) => { + await mockResilienceSettings(page); + + await gotoDashboardRoute(page, "/dashboard/settings?tab=resilience"); + await expect(page.getByText("Connection Cooldown")).toBeVisible({ timeout: 15000 }); + await expect(page.getByText("Base cooldown", { exact: true }).first()).toBeVisible(); + await expect(page.getByText("Use upstream retry hints", { exact: true }).first()).toBeVisible(); + await expect(page.getByText("Max backoff steps", { exact: true }).first()).toBeVisible(); + await expect(page.getByText(/Rate-limit fallback/i)).toHaveCount(0); + }); + + test("health page renders provider breaker runtime state for multiple providers", async ({ + page, + }) => { + await mockHealthPageApis(page); + + await gotoDashboardRoute(page, "/dashboard/health"); + await expect(page.getByText("Provider Health")).toBeVisible({ timeout: 15000 }); + await expect(page.getByText("OpenAI")).toBeVisible(); + await expect(page.getByText("Gemini")).toBeVisible(); + await expect(page.getByText("Groq")).toBeVisible(); + await expect(page.getByText("Recovering", { exact: true }).first()).toBeVisible(); + await expect(page.getByText("Down", { exact: true }).first()).toBeVisible(); + }); + + test("providers page no longer requests legacy model availability data", async ({ page }) => { + let availabilityRequests = 0; + + await mockProvidersPageApis(page); + await page.route("**/api/models/availability", async (route) => { + availabilityRequests += 1; + await route.fulfill({ + status: 410, + contentType: "application/json", + body: JSON.stringify({ error: "removed" }), + }); + }); + + await gotoDashboardRoute(page, "/dashboard/providers"); + await page.waitForLoadState("networkidle"); + + expect(availabilityRequests).toBe(0); + await expect(page.getByText("OpenAI").first()).toBeVisible(); + await expect(page.getByText(/Model Availability/i)).toHaveCount(0); + }); + + test("intelligent combo panel stays config-only and does not fetch breaker runtime state", async ({ + page, + }) => { + let monitoringHealthRequests = 0; + + await mockIntelligentCombosPageApis(page); + await page.route("**/api/monitoring/health", async (route) => { + monitoringHealthRequests += 1; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ providerHealth: {} }), + }); + }); + + await gotoDashboardRoute(page, "/dashboard/combos?filter=intelligent"); + await page.waitForLoadState("networkidle"); + + expect(monitoringHealthRequests).toBe(0); + await expect(page.getByText("Intelligent Routing Dashboard")).toBeVisible({ timeout: 15000 }); + await expect(page.getByText("Routing Inputs", { exact: true })).toBeVisible(); + await expect(page.getByText(/Excluded Providers/i)).toHaveCount(0); + await expect(page.getByText(/Incident Mode/i)).toHaveCount(0); + }); +}); diff --git a/tests/integration/_chatPipelineHarness.ts b/tests/integration/_chatPipelineHarness.ts index d5a7cdbad1..57b2d745ed 100644 --- a/tests/integration/_chatPipelineHarness.ts +++ b/tests/integration/_chatPipelineHarness.ts @@ -33,8 +33,6 @@ export async function createChatPipelineHarness(prefix) { const { initTranslators } = await import("../../open-sse/translator/index.ts"); const { clearInflight } = await import("../../open-sse/services/requestDedup.ts"); const { BaseExecutor } = await import("../../open-sse/executors/base.ts"); - const { resetAllAvailability, setModelUnavailable } = - await import("../../src/domain/modelAvailability.ts"); const { resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts"); const originalFetch = globalThis.fetch; @@ -209,7 +207,6 @@ export async function createChatPipelineHarness(prefix) { clearInflight(); idempotencyLayerModule.clearIdempotency(); semanticCacheModule.clearCache(); - resetAllAvailability(); resetAllCircuitBreakers(); apiKeysDb.resetApiKeyState(); readCacheDb.invalidateDbCache(); @@ -229,7 +226,6 @@ export async function createChatPipelineHarness(prefix) { idempotencyLayerModule.clearIdempotency(); semanticCacheModule.clearCache(); clearSkillState(); - resetAllAvailability(); resetAllCircuitBreakers(); core.resetDbInstance(); fs.rmSync(testDataDir, { recursive: true, force: true }); @@ -292,7 +288,6 @@ export async function createChatPipelineHarness(prefix) { semanticCacheModule, seedApiKey, seedConnection, - setModelUnavailable, settingsDb, skillByIdRouteModule, skillExecutor, diff --git a/tests/integration/chat-pipeline.test.ts b/tests/integration/chat-pipeline.test.ts index 10f5d366f4..ce6f4641b3 100644 --- a/tests/integration/chat-pipeline.test.ts +++ b/tests/integration/chat-pipeline.test.ts @@ -23,9 +23,8 @@ const { handleChat } = await import("../../src/sse/handlers/chat.ts"); const { initTranslators } = await import("../../open-sse/translator/index.ts"); const { clearInflight } = await import("../../open-sse/services/requestDedup.ts"); const { BaseExecutor } = await import("../../open-sse/executors/base.ts"); -const { resetAllAvailability, setModelUnavailable } = - await import("../../src/domain/modelAvailability.ts"); -const { resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts"); +const { getCircuitBreaker, resetAllCircuitBreakers } = + await import("../../src/shared/utils/circuitBreaker.ts"); const { clearProviderFailure } = await import("../../open-sse/services/accountFallback.ts"); const originalFetch = globalThis.fetch; @@ -295,7 +294,6 @@ async function resetStorage() { globalThis.fetch = originalFetch; process.env.REQUIRE_API_KEY = "false"; clearInflight(); - resetAllAvailability(); resetAllCircuitBreakers(); apiKeysDb.resetApiKeyState(); readCacheDb.invalidateDbCache(); @@ -442,7 +440,6 @@ test.after(async () => { BaseExecutor.RETRY_CONFIG.delayMs = originalRetryDelayMs; globalThis.fetch = originalFetch; clearInflight(); - resetAllAvailability(); resetAllCircuitBreakers(); core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); @@ -909,26 +906,6 @@ test("chat pipeline returns current no-credentials contract when no provider con assert.match(json.error.message, /No credentials for provider: openai/); }); -test("chat pipeline returns 503 when the requested model is temporarily unavailable", async () => { - await seedConnection("openai", { apiKey: "sk-openai-unavailable" }); - setModelUnavailable("openai", "gpt-4o-mini", 60000, "test cooldown"); - - const response = await handleChat( - buildRequest({ - body: { - model: "openai/gpt-4o-mini", - stream: false, - messages: [{ role: "user", content: "Provider unavailable" }], - }, - }) - ); - - const json = await response.json(); - assert.equal(response.status, 503); - assert.ok(Number(response.headers.get("Retry-After")) >= 1); - assert.match(json.error.message, /temporarily unavailable/i); -}); - test("chat pipeline surfaces upstream 500 responses as structured errors", async () => { await seedConnection("openai", { apiKey: "sk-openai-500" }); @@ -993,6 +970,46 @@ test("chat pipeline returns 429 with Retry-After when the upstream rate-limits t assert.match(json.error.message, /\[openai\/gpt-4o-mini\]/); }); +test("chat pipeline keeps provider breaker closed for repeated connection-scoped 429s", async () => { + await seedConnection("openai", { apiKey: "sk-openai-429-breaker" }); + await settingsDb.updateSettings({ + requestRetry: 0, + maxRetryIntervalSec: 0, + }); + + globalThis.fetch = async () => + new Response( + JSON.stringify({ + error: { + message: "Rate limit exceeded. Your quota will reset after 30s.", + }, + }), + { + status: 429, + headers: { "Content-Type": "application/json" }, + } + ); + + for (let i = 0; i < 3; i += 1) { + const response = await handleChat( + buildRequest({ + body: { + model: "openai/gpt-4o-mini", + stream: false, + messages: [{ role: "user", content: `Trigger 429 #${i + 1}` }], + }, + }) + ); + assert.equal(response.status, 429); + } + + const breaker = getCircuitBreaker("openai"); + const status = breaker.getStatus(); + + assert.equal(status.state, "CLOSED"); + assert.equal(status.failureCount, 0); +}); + test("chat pipeline maps upstream timeouts to 504 responses", async () => { await seedConnection("openai", { apiKey: "sk-openai-timeout" }); diff --git a/tests/integration/chatcore-compression-integration.test.ts b/tests/integration/chatcore-compression-integration.test.ts index 13adcd92e6..2ab34d8fa0 100644 --- a/tests/integration/chatcore-compression-integration.test.ts +++ b/tests/integration/chatcore-compression-integration.test.ts @@ -15,14 +15,12 @@ const readCacheDb = await import("../../src/lib/db/readCache.ts"); const combosDb = await import("../../src/lib/db/combos.ts"); const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts"); const { estimateTokens, getTokenLimit } = await import("../../open-sse/services/contextManager.ts"); -const { resetAllAvailability } = await import("../../src/domain/modelAvailability.ts"); const { resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts"); const originalFetch = globalThis.fetch; async function resetStorage() { globalThis.fetch = originalFetch; - resetAllAvailability(); resetAllCircuitBreakers(); readCacheDb.invalidateDbCache(); await new Promise((resolve) => setTimeout(resolve, 20)); diff --git a/tests/integration/integration-wiring.test.ts b/tests/integration/integration-wiring.test.ts index a9e99a6809..4ecd2da607 100644 --- a/tests/integration/integration-wiring.test.ts +++ b/tests/integration/integration-wiring.test.ts @@ -84,8 +84,8 @@ describe("Pipeline Wiring — sse chat handler", () => { assert.match(src, /getCircuitBreaker|CircuitBreakerOpenError/); }); - it("should import model availability integration", () => { - assert.match(src, /isModelAvailable|setModelUnavailable/); + it("should use credential preflight instead of global model quarantine gates", () => { + assert.match(src, /getProviderCredentialsWithQuotaPreflight/); }); it("should import request telemetry integration", () => { @@ -129,7 +129,6 @@ describe("Pipeline Wiring — middleware proxy", () => { describe("API Routes — existence check", () => { const routes = [ "src/app/api/cache/stats/route.ts", - "src/app/api/models/availability/route.ts", "src/app/api/telemetry/summary/route.ts", "src/app/api/usage/budget/route.ts", "src/app/api/usage/quota/route.ts", @@ -152,10 +151,6 @@ describe("API Routes — export HTTP methods", () => { assertRouteMethods("src/app/api/cache/stats/route.ts", ["GET", "DELETE"]); }); - it("/api/models/availability should export GET and POST", () => { - assertRouteMethods("src/app/api/models/availability/route.ts", ["GET", "POST"]); - }); - it("/api/telemetry/summary should export GET", () => { assertRouteMethods("src/app/api/telemetry/summary/route.ts", ["GET"]); }); diff --git a/tests/integration/proxy-pipeline.test.ts b/tests/integration/proxy-pipeline.test.ts index 847c27cb4a..42540df355 100644 --- a/tests/integration/proxy-pipeline.test.ts +++ b/tests/integration/proxy-pipeline.test.ts @@ -88,25 +88,25 @@ describe("Chat Pipeline — combo fallback support", () => { assert.match(src, /handleSingleModel.*handleSingleModelChat/s); }); - it("should check model availability before attempting combo models", () => { - assert.match(src, /isModelAvailable/); + it("should preflight provider credentials before attempting combo models", () => { + assert.match(src, /getProviderCredentialsWithQuotaPreflight/); }); }); describe("Chat Pipeline — circuit breaker integration", () => { const helpersSrc = readSrc("sse/handlers/chatHelpers.ts"); - it("should import CircuitBreakerOpenError", () => { + it("should import providerCircuitOpenResponse", () => { assert.ok(helpersSrc, "chatHelpers.ts should exist"); - assert.match(helpersSrc, /CircuitBreakerOpenError/); + assert.match(helpersSrc, /providerCircuitOpenResponse/); }); - it("should handle CircuitBreakerOpenError with retry-after", () => { + it("should handle circuit-open responses with retry-after", () => { assert.match(helpersSrc, /retryAfterMs/); }); - it("should reject requests when circuit is open", () => { - assert.match(helpersSrc, /circuit breaker is open/i); + it("should reject requests when circuit is open via structured provider breaker response", () => { + assert.match(helpersSrc, /providerCircuitOpenResponse\(provider,\s*retryAfterSec\)/); }); }); diff --git a/tests/integration/resilience-http-e2e.test.ts b/tests/integration/resilience-http-e2e.test.ts new file mode 100644 index 0000000000..f6ea094ab2 --- /dev/null +++ b/tests/integration/resilience-http-e2e.test.ts @@ -0,0 +1,746 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import http from "node:http"; +import net from "node:net"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-resilience-http-e2e-")); +const DASHBOARD_PORT = await getFreePort(); +const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url)); + +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "resilience-http-e2e-secret-123456"; +process.env.REQUIRE_API_KEY = "false"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const combosDb = await import("../../src/lib/db/combos.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); + +function getFreePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Failed to allocate a free port")); + return; + } + const { port } = address; + server.close((closeError) => { + if (closeError) reject(closeError); + else resolve(port); + }); + }); + }); +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function buildCompletion(content: string) { + return { + status: 200, + body: { + id: `chatcmpl_${Math.random().toString(16).slice(2, 8)}`, + object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], + usage: { prompt_tokens: 4, completion_tokens: 2, total_tokens: 6 }, + }, + }; +} + +function buildError(status: number, message: string, headers: Record = {}) { + return { + status, + headers, + body: { error: { message } }, + }; +} + +type PlannedResponse = { + status: number; + body: Record; + headers?: Record; + delayMs?: number; +}; + +type TokenBehavior = { + defaultResponse: PlannedResponse; + queue: PlannedResponse[]; + hits: number; + startedAt: number[]; + bodies: Array>; +}; + +function createFakeOpenAiRelay() { + const behaviors = new Map(); + let server: http.Server | null = null; + let baseUrl = ""; + + const handleRequest = async (req: http.IncomingMessage, res: http.ServerResponse) => { + const chunks: Buffer[] = []; + req.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + req.on("end", async () => { + const authHeader = String(req.headers.authorization || ""); + const token = authHeader.replace(/^Bearer\s+/i, "").trim(); + const rawBody = Buffer.concat(chunks).toString("utf8"); + const parsedBody = rawBody ? JSON.parse(rawBody) : {}; + + if (req.method === "GET" && req.url?.startsWith("/v1/models")) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ data: [{ id: "test-model", object: "model" }] })); + return; + } + + if (req.method !== "POST" || !req.url?.startsWith("/v1/chat/completions")) { + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: { message: `Unhandled path: ${req.method} ${req.url}` } })); + return; + } + + const behavior = behaviors.get(token); + if (!behavior) { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: { message: `Unknown token: ${token || "missing"}` } })); + return; + } + + behavior.hits += 1; + behavior.startedAt.push(Date.now()); + behavior.bodies.push(parsedBody as Record); + + const planned = behavior.queue.shift() || behavior.defaultResponse; + if (planned.delayMs && planned.delayMs > 0) { + await sleep(planned.delayMs); + } + + const headers = { "Content-Type": "application/json", ...(planned.headers || {}) }; + res.writeHead(planned.status, headers); + res.end(JSON.stringify(planned.body)); + }); + }; + + return { + async start() { + const port = await getFreePort(); + await new Promise((resolve, reject) => { + server = http.createServer((req, res) => { + void handleRequest(req, res); + }); + server.once("error", reject); + server.listen(port, "127.0.0.1", () => resolve()); + }); + baseUrl = `http://127.0.0.1:${port}/v1`; + return baseUrl; + }, + getBaseUrl() { + if (!baseUrl) throw new Error("Fake relay has not started yet"); + return baseUrl; + }, + configureToken( + token: string, + config: { defaultResponse: PlannedResponse; queue?: PlannedResponse[] } + ) { + behaviors.set(token, { + defaultResponse: config.defaultResponse, + queue: [...(config.queue || [])], + hits: 0, + startedAt: [], + bodies: [], + }); + }, + getState(token: string) { + const state = behaviors.get(token); + if (!state) throw new Error(`Unknown token state for ${token}`); + return state; + }, + resetState(token: string, queue?: PlannedResponse[]) { + const state = behaviors.get(token); + if (!state) throw new Error(`Unknown token state for ${token}`); + state.hits = 0; + state.startedAt = []; + state.bodies = []; + state.queue = [...(queue || [])]; + }, + async stop() { + if (!server) return; + await new Promise((resolve) => server?.close(() => resolve())); + server = null; + }, + }; +} + +function createServerProcess(dataDir: string, port: number) { + const stdoutLines: string[] = []; + const stderrLines: string[] = []; + const child = spawn(process.execPath, ["scripts/run-next.mjs", "dev"], { + cwd: REPO_ROOT, + env: { + ...process.env, + DATA_DIR: dataDir, + PORT: String(port), + DASHBOARD_PORT: String(port), + API_PORT: String(port), + HOST: "127.0.0.1", + REQUIRE_API_KEY: "false", + API_KEY_SECRET: process.env.API_KEY_SECRET || "resilience-http-e2e-secret-123456", + DISABLE_SQLITE_AUTO_BACKUP: "true", + INITIAL_PASSWORD: "", + NEXT_TELEMETRY_DISABLED: "1", + OMNIROUTE_DISABLE_BACKGROUND_SERVICES: "true", + OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK: "true", + OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK: "true", + OMNIROUTE_HIDE_HEALTHCHECK_LOGS: "true", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + child.stdout.on("data", (chunk) => { + const lines = String(chunk).split(/\r?\n/).filter(Boolean); + stdoutLines.push(...lines); + if (stdoutLines.length > 200) stdoutLines.splice(0, stdoutLines.length - 200); + }); + child.stderr.on("data", (chunk) => { + const lines = String(chunk).split(/\r?\n/).filter(Boolean); + stderrLines.push(...lines); + if (stderrLines.length > 200) stderrLines.splice(0, stderrLines.length - 200); + }); + + return { + child, + stdoutLines, + stderrLines, + baseUrl: `http://127.0.0.1:${port}`, + }; +} + +async function waitForServer( + baseUrl: string, + logs: { stdoutLines: string[]; stderrLines: string[] } +) { + const startedAt = Date.now(); + let lastError = ""; + while (Date.now() - startedAt < 120_000) { + try { + const response = await fetch(`${baseUrl}/api/monitoring/health`, { + signal: AbortSignal.timeout(5_000), + }); + if (response.ok) return; + lastError = `HTTP ${response.status}`; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + await sleep(500); + } + + throw new Error( + [ + `Timed out waiting for OmniRoute to start: ${lastError}`, + "--- stdout ---", + ...logs.stdoutLines.slice(-40), + "--- stderr ---", + ...logs.stderrLines.slice(-40), + ].join("\n") + ); +} + +async function stopProcess(child: ReturnType) { + if (child.killed) return; + child.kill("SIGTERM"); + const exited = await Promise.race([ + new Promise((resolve) => child.once("exit", () => resolve(true))), + sleep(5_000).then(() => false), + ]); + if (!exited && !child.killed) { + child.kill("SIGKILL"); + await new Promise((resolve) => child.once("exit", () => resolve())); + } +} + +async function seedCompatibleProvider(prefix: string, apiKey: string, baseUrl: string) { + const providerId = `openai-compatible-chat-e2e-${prefix}`; + await providersDb.createProviderNode({ + id: providerId, + type: "openai-compatible", + name: `E2E ${prefix}`, + prefix, + apiType: "chat", + baseUrl, + }); + await providersDb.createProviderConnection({ + provider: providerId, + authType: "apikey", + name: `conn-${prefix}`, + apiKey, + isActive: true, + testStatus: "active", + providerSpecificData: { + baseUrl, + apiType: "chat", + }, + }); + return { providerId, model: `${prefix}/test-model`, apiKey }; +} + +function buildResilienceConfig(overrides: Record = {}) { + const base = { + requestQueue: { + autoEnableApiKeyProviders: true, + requestsPerMinute: 120, + minTimeBetweenRequestsMs: 0, + concurrentRequests: 4, + maxWaitMs: 2_000, + }, + connectionCooldown: { + oauth: { + baseCooldownMs: 500, + useUpstreamRetryHints: true, + maxBackoffSteps: 3, + }, + apikey: { + baseCooldownMs: 300, + useUpstreamRetryHints: false, + maxBackoffSteps: 0, + }, + }, + providerBreaker: { + oauth: { + failureThreshold: 3, + resetTimeoutMs: 2_000, + }, + apikey: { + failureThreshold: 2, + resetTimeoutMs: 1_500, + }, + }, + waitForCooldown: { + enabled: false, + maxRetries: 0, + maxRetryWaitSec: 0, + }, + }; + + return { + ...base, + ...overrides, + requestQueue: { + ...base.requestQueue, + ...((overrides.requestQueue as Record) || {}), + }, + connectionCooldown: { + oauth: { + ...base.connectionCooldown.oauth, + ...(((overrides.connectionCooldown as Record)?.oauth as Record< + string, + unknown + >) || {}), + }, + apikey: { + ...base.connectionCooldown.apikey, + ...(((overrides.connectionCooldown as Record)?.apikey as Record< + string, + unknown + >) || {}), + }, + }, + providerBreaker: { + oauth: { + ...base.providerBreaker.oauth, + ...(((overrides.providerBreaker as Record)?.oauth as Record< + string, + unknown + >) || {}), + }, + apikey: { + ...base.providerBreaker.apikey, + ...(((overrides.providerBreaker as Record)?.apikey as Record< + string, + unknown + >) || {}), + }, + }, + waitForCooldown: { + ...base.waitForCooldown, + ...((overrides.waitForCooldown as Record) || {}), + }, + }; +} + +async function patchResilience(baseUrl: string, config: Record) { + const response = await fetch(`${baseUrl}/api/resilience`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(config), + signal: AbortSignal.timeout(10_000), + }); + const payload = await response.json(); + assert.equal(response.status, 200, JSON.stringify(payload)); + return payload; +} + +async function getJson(url: string) { + const response = await fetch(url, { signal: AbortSignal.timeout(10_000) }); + const json = await response.json(); + return { response, json }; +} + +async function postChat(baseUrl: string, model: string, content: string) { + const response = await fetch(`${baseUrl}/api/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model, + stream: false, + messages: [{ role: "user", content }], + }), + signal: AbortSignal.timeout(20_000), + }); + const text = await response.text(); + const json = text ? JSON.parse(text) : {}; + return { response, json }; +} + +const relay = createFakeOpenAiRelay(); +let app: + | { + child: ReturnType; + stdoutLines: string[]; + stderrLines: string[]; + baseUrl: string; + } + | undefined; + +const TOKENS = { + p1: "sk-e2e-p1", + p2: "sk-e2e-p2", + p3: "sk-e2e-p3", + p4: "sk-e2e-p4", + p5: "sk-e2e-p5", + p6: "sk-e2e-p6", + p7: "sk-e2e-p7", + p8: "sk-e2e-p8", +}; + +test.before(async () => { + const fakeBaseUrl = await relay.start(); + + relay.configureToken(TOKENS.p1, { + defaultResponse: buildCompletion("primary healthy again"), + queue: [buildError(503, "primary transient failure")], + }); + relay.configureToken(TOKENS.p2, { + defaultResponse: buildCompletion("secondary stable"), + }); + relay.configureToken(TOKENS.p3, { + defaultResponse: buildCompletion("wait-for-cooldown via upstream hint"), + queue: [buildError(429, "rate limited, retry after 1 second", { "Retry-After": "1" })], + }); + relay.configureToken(TOKENS.p4, { + defaultResponse: buildCompletion("ignored upstream retry hint"), + queue: [buildError(429, "rate limited, retry after 5 seconds", { "Retry-After": "5" })], + }); + relay.configureToken(TOKENS.p5, { + defaultResponse: buildCompletion("breaker target recovered"), + queue: [buildError(503, "breaker failure #1"), buildError(503, "breaker failure #2")], + }); + relay.configureToken(TOKENS.p6, { + defaultResponse: buildCompletion("round robin A"), + }); + relay.configureToken(TOKENS.p7, { + defaultResponse: buildCompletion("round robin B"), + }); + relay.configureToken(TOKENS.p8, { + defaultResponse: { + ...buildCompletion("queued connection request"), + delayMs: 250, + }, + }); + + await seedCompatibleProvider("p1", TOKENS.p1, fakeBaseUrl); + await seedCompatibleProvider("p2", TOKENS.p2, fakeBaseUrl); + await seedCompatibleProvider("p3", TOKENS.p3, fakeBaseUrl); + await seedCompatibleProvider("p4", TOKENS.p4, fakeBaseUrl); + await seedCompatibleProvider("p5", TOKENS.p5, fakeBaseUrl); + await seedCompatibleProvider("p6", TOKENS.p6, fakeBaseUrl); + await seedCompatibleProvider("p7", TOKENS.p7, fakeBaseUrl); + await seedCompatibleProvider("p8", TOKENS.p8, fakeBaseUrl); + + await combosDb.createCombo({ + name: "res-priority-fallback", + strategy: "priority", + config: { maxRetries: 0, retryDelayMs: 0 }, + models: ["p1/test-model", "p2/test-model"], + }); + await combosDb.createCombo({ + name: "res-breaker-combo", + strategy: "priority", + config: { maxRetries: 0, retryDelayMs: 0 }, + models: ["p5/test-model", "p2/test-model"], + }); + await combosDb.createCombo({ + name: "res-rr", + strategy: "round-robin", + config: { maxRetries: 0, retryDelayMs: 0, concurrencyPerModel: 1, queueTimeoutMs: 800 }, + models: ["p6/test-model", "p7/test-model"], + }); + + await settingsDb.updateSettings({ + resilienceSettings: buildResilienceConfig(), + requestRetry: 0, + maxRetryIntervalSec: 0, + requireLogin: false, + setupComplete: true, + }); + + core.closeDbInstance(); + + app = createServerProcess(TEST_DATA_DIR, DASHBOARD_PORT); + await waitForServer(app.baseUrl, app); + + await patchResilience(app.baseUrl, buildResilienceConfig()); + + const warmup = await postChat(app.baseUrl, "p2/test-model", "warm up chat route"); + assert.equal(warmup.response.status, 200, JSON.stringify(warmup.json)); + relay.resetState(TOKENS.p2); +}); + +test.after(async () => { + if (app) { + await stopProcess(app.child); + } + await relay.stop(); + core.closeDbInstance(); + await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("resilience API only exposes configuration, not runtime breaker state", async () => { + assert.ok(app); + const { response, json } = await getJson(`${app.baseUrl}/api/resilience`); + + assert.equal(response.status, 200); + assert.deepEqual(Object.keys(json).sort(), [ + "connectionCooldown", + "legacy", + "providerBreaker", + "requestQueue", + "waitForCooldown", + ]); + assert.equal("providerBreakers" in json, false); + assert.equal("runtime" in json, false); +}); + +test("request queue serializes concurrent requests on the same connection", async () => { + assert.ok(app); + await patchResilience( + app.baseUrl, + buildResilienceConfig({ + requestQueue: { + concurrentRequests: 1, + maxWaitMs: 1_500, + }, + }) + ); + relay.resetState(TOKENS.p8); + + const startedAt = Date.now(); + const [first, second] = await Promise.all([ + postChat(app.baseUrl, "p8/test-model", "queue-one"), + postChat(app.baseUrl, "p8/test-model", "queue-two"), + ]); + const elapsed = Date.now() - startedAt; + const state = relay.getState(TOKENS.p8); + + assert.equal(first.response.status, 200, JSON.stringify(first.json)); + assert.equal(second.response.status, 200, JSON.stringify(second.json)); + assert.equal(state.hits, 2); + assert.ok(elapsed >= 450, `expected queued elapsed >= 450ms, got ${elapsed}ms`); + assert.ok( + state.startedAt[1] - state.startedAt[0] >= 180, + `expected second request to be delayed by queue, got ${state.startedAt[1] - state.startedAt[0]}ms` + ); +}); + +test("priority combo falls back on 503 and skips the cooled-down primary on the next request", async () => { + assert.ok(app); + await patchResilience(app.baseUrl, buildResilienceConfig()); + relay.resetState(TOKENS.p1, [buildError(503, "primary transient failure")]); + relay.resetState(TOKENS.p2); + + const first = await postChat(app.baseUrl, "res-priority-fallback", "priority fallback request"); + assert.equal(first.response.status, 200, JSON.stringify(first.json)); + assert.equal(first.json.choices[0].message.content, "secondary stable"); + assert.equal(relay.getState(TOKENS.p1).hits, 1); + assert.equal(relay.getState(TOKENS.p2).hits, 1); + + const second = await postChat(app.baseUrl, "res-priority-fallback", "priority fallback again"); + assert.equal(second.response.status, 200, JSON.stringify(second.json)); + assert.equal(second.json.choices[0].message.content, "secondary stable"); + assert.equal(relay.getState(TOKENS.p1).hits, 1); + assert.equal(relay.getState(TOKENS.p2).hits, 2); +}); + +test("wait-for-cooldown honors upstream Retry-After when enabled", async () => { + assert.ok(app); + await patchResilience( + app.baseUrl, + buildResilienceConfig({ + connectionCooldown: { + apikey: { + useUpstreamRetryHints: true, + baseCooldownMs: 200, + }, + }, + waitForCooldown: { + enabled: true, + maxRetries: 1, + maxRetryWaitSec: 2, + }, + }) + ); + relay.resetState(TOKENS.p3); + const warmup = await postChat(app.baseUrl, "p3/test-model", "warm provider-specific route"); + assert.equal(warmup.response.status, 200, JSON.stringify(warmup.json)); + relay.resetState(TOKENS.p3, [ + buildError(429, "rate limited, retry after 1 second", { "Retry-After": "1" }), + ]); + + const startedAt = Date.now(); + const result = await postChat(app.baseUrl, "p3/test-model", "wait for cooldown via upstream"); + const elapsed = Date.now() - startedAt; + + assert.equal(result.response.status, 200, JSON.stringify(result.json)); + assert.equal(result.json.choices[0].message.content, "wait-for-cooldown via upstream hint"); + assert.equal(relay.getState(TOKENS.p3).hits, 2); + assert.ok(elapsed >= 800, `expected upstream wait >= 800ms, got ${elapsed}ms`); +}); + +test("connection cooldown can ignore upstream Retry-After and use the configured local cooldown", async () => { + assert.ok(app); + await patchResilience( + app.baseUrl, + buildResilienceConfig({ + connectionCooldown: { + apikey: { + useUpstreamRetryHints: false, + baseCooldownMs: 200, + }, + }, + waitForCooldown: { + enabled: true, + maxRetries: 1, + maxRetryWaitSec: 2, + }, + }) + ); + relay.resetState(TOKENS.p4); + const warmup = await postChat(app.baseUrl, "p4/test-model", "warm provider-specific route"); + assert.equal(warmup.response.status, 200, JSON.stringify(warmup.json)); + relay.resetState(TOKENS.p4, [ + buildError(429, "rate limited, retry after 30 seconds", { "Retry-After": "30" }), + ]); + + const startedAt = Date.now(); + const result = await postChat(app.baseUrl, "p4/test-model", "ignore upstream retry-after"); + const elapsed = Date.now() - startedAt; + + assert.equal(result.response.status, 200, JSON.stringify(result.json)); + assert.equal(result.json.choices[0].message.content, "ignored upstream retry hint"); + assert.equal(relay.getState(TOKENS.p4).hits, 2); + assert.ok( + elapsed < 5_000, + `expected ignored upstream hint to avoid a 30s wait, got ${elapsed}ms` + ); +}); + +test("provider circuit breaker opens after repeated final failures and Health reports it", async () => { + assert.ok(app); + await patchResilience( + app.baseUrl, + buildResilienceConfig({ + waitForCooldown: { + enabled: false, + maxRetries: 0, + maxRetryWaitSec: 0, + }, + providerBreaker: { + apikey: { + failureThreshold: 2, + resetTimeoutMs: 1_500, + }, + }, + }) + ); + relay.resetState(TOKENS.p5, [ + buildError(503, "breaker failure #1"), + buildError(503, "breaker failure #2"), + ]); + + const first = await postChat(app.baseUrl, "p5/test-model", "breaker first failure"); + const second = await postChat(app.baseUrl, "p5/test-model", "breaker second failure"); + const third = await postChat(app.baseUrl, "p5/test-model", "breaker should now be open"); + + assert.equal(first.response.status, 503); + assert.equal(second.response.status, 503); + assert.equal(third.response.status, 503); + assert.match(String(second.json.error?.message || ""), /reset after/i); + assert.match(String(third.json.error?.message || ""), /circuit breaker is open/i); + assert.equal(relay.getState(TOKENS.p5).hits, 1); + + const health = await getJson(`${app.baseUrl}/api/monitoring/health`); + assert.equal(health.response.status, 200); + const breakerEntry = (health.json.providerBreakers || []).find( + (entry: Record) => entry.provider === "openai-compatible-chat-e2e-p5" + ); + assert.ok(breakerEntry, "expected provider breaker entry for p5"); + assert.equal(breakerEntry.state, "OPEN"); + assert.ok(Number(breakerEntry.failureCount) >= 2); + assert.ok(Number(breakerEntry.retryAfterMs) > 0); + assert.ok( + (health.json.providerBreakers || []).every( + (entry: Record) => !String(entry.provider || "").includes(":") + ) + ); +}); + +test("combo respects the global provider breaker and falls through without a combo-local breaker", async () => { + assert.ok(app); + relay.resetState(TOKENS.p2); + + const result = await postChat( + app.baseUrl, + "res-breaker-combo", + "combo should skip broken provider" + ); + + assert.equal(result.response.status, 200, JSON.stringify(result.json)); + assert.equal(result.json.choices[0].message.content, "secondary stable"); + assert.equal(relay.getState(TOKENS.p5).hits, 1); + assert.equal(relay.getState(TOKENS.p2).hits, 1); +}); + +test("round-robin combo still alternates healthy providers after combo breaker removal", async () => { + assert.ok(app); + await patchResilience(app.baseUrl, buildResilienceConfig()); + relay.resetState(TOKENS.p6); + relay.resetState(TOKENS.p7); + + const first = await postChat(app.baseUrl, "res-rr", "round robin one"); + const second = await postChat(app.baseUrl, "res-rr", "round robin two"); + + assert.equal(first.response.status, 200, JSON.stringify(first.json)); + assert.equal(second.response.status, 200, JSON.stringify(second.json)); + assert.equal(first.json.choices[0].message.content, "round robin A"); + assert.equal(second.json.choices[0].message.content, "round robin B"); + assert.equal(relay.getState(TOKENS.p6).hits, 1); + assert.equal(relay.getState(TOKENS.p7).hits, 1); +}); diff --git a/tests/integration/security-hardening.test.ts b/tests/integration/security-hardening.test.ts index df01b34c2f..cb6060e247 100644 --- a/tests/integration/security-hardening.test.ts +++ b/tests/integration/security-hardening.test.ts @@ -232,7 +232,6 @@ test("T06 route payload validation uses validateBody in critical endpoints", () "src/app/api/policies/route.ts", "src/app/api/fallback/chains/route.ts", "src/app/api/models/route.ts", - "src/app/api/models/availability/route.ts", "src/app/api/provider-models/route.ts", "src/app/api/pricing/route.ts", "src/app/api/rate-limits/route.ts", diff --git a/tests/unit/account-fallback-service.test.ts b/tests/unit/account-fallback-service.test.ts index 912aa9836e..2095057770 100644 --- a/tests/unit/account-fallback-service.test.ts +++ b/tests/unit/account-fallback-service.test.ts @@ -207,8 +207,37 @@ test("lockModelIfPerModelQuota only locks supported providers and real models", }); test("getProviderProfile differentiates oauth and api-key providers", () => { - assert.deepEqual(getProviderProfile("claude"), PROVIDER_PROFILES.oauth); - assert.deepEqual(getProviderProfile("openai"), PROVIDER_PROFILES.apikey); + const oauthProfile = getProviderProfile("claude"); + assert.equal(oauthProfile.transientCooldown, PROVIDER_PROFILES.oauth.transientCooldown); + assert.equal( + oauthProfile.rateLimitCooldown, + oauthProfile.useUpstreamRetryHints ? 0 : oauthProfile.baseCooldownMs + ); + assert.equal(oauthProfile.maxBackoffLevel, PROVIDER_PROFILES.oauth.maxBackoffLevel); + assert.equal( + oauthProfile.circuitBreakerThreshold, + PROVIDER_PROFILES.oauth.circuitBreakerThreshold + ); + assert.equal(oauthProfile.circuitBreakerReset, PROVIDER_PROFILES.oauth.circuitBreakerReset); + assert.equal(oauthProfile.baseCooldownMs, PROVIDER_PROFILES.oauth.transientCooldown); + assert.equal(oauthProfile.failureThreshold, PROVIDER_PROFILES.oauth.circuitBreakerThreshold); + assert.equal(oauthProfile.resetTimeoutMs, PROVIDER_PROFILES.oauth.circuitBreakerReset); + + const apiKeyProfile = getProviderProfile("openai"); + assert.equal(apiKeyProfile.transientCooldown, PROVIDER_PROFILES.apikey.transientCooldown); + assert.equal( + apiKeyProfile.rateLimitCooldown, + apiKeyProfile.useUpstreamRetryHints ? 0 : apiKeyProfile.baseCooldownMs + ); + assert.equal(apiKeyProfile.maxBackoffLevel, PROVIDER_PROFILES.apikey.maxBackoffLevel); + assert.equal( + apiKeyProfile.circuitBreakerThreshold, + PROVIDER_PROFILES.apikey.circuitBreakerThreshold + ); + assert.equal(apiKeyProfile.circuitBreakerReset, PROVIDER_PROFILES.apikey.circuitBreakerReset); + assert.equal(apiKeyProfile.baseCooldownMs, PROVIDER_PROFILES.apikey.transientCooldown); + assert.equal(apiKeyProfile.failureThreshold, PROVIDER_PROFILES.apikey.circuitBreakerThreshold); + assert.equal(apiKeyProfile.resetTimeoutMs, PROVIDER_PROFILES.apikey.circuitBreakerReset); }); test("shouldMarkAccountExhaustedFrom429 skips connection poisoning for compatible providers", () => { @@ -229,11 +258,11 @@ test("recordModelLockoutFailure uses provider profile cooldowns, backoff, and re const compatibleProvider = "openai-compatible-custom-node"; const compatibleModel = "custom-model-a"; const profile = { - transientCooldown: 250, - rateLimitCooldown: 125, - maxBackoffLevel: 2, - circuitBreakerThreshold: 60, - circuitBreakerReset: 500, + baseCooldownMs: 125, + useUpstreamRetryHints: false, + maxBackoffSteps: 2, + failureThreshold: 60, + resetTimeoutMs: 500, }; const first = recordModelLockoutFailure( @@ -298,8 +327,8 @@ test("recordModelLockoutFailure uses provider profile cooldowns, backoff, and re }); // Provider-level failure circuit breaker tests -test("isProviderFailureCode correctly identifies transient error codes", () => { - assert.equal(isProviderFailureCode(429), true); +test("isProviderFailureCode correctly identifies provider-wide transient error codes", () => { + assert.equal(isProviderFailureCode(429), false); assert.equal(isProviderFailureCode(408), true); assert.equal(isProviderFailureCode(500), true); assert.equal(isProviderFailureCode(502), true); @@ -360,62 +389,16 @@ test("recordProviderFailure tracks failures and triggers cooldown after threshol } }); -test("recordProviderFailure resets counter after failure window expires", () => { - const originalNow = Date.now; - let now = 1_700_000_000_000; - Date.now = () => now; +test("checkFallbackError no longer mutates provider breaker state on per-connection failures", () => { + const provider = "test-provider-check"; + clearProviderFailure(provider); - try { - const provider = "test-provider-window"; - clearProviderFailure(provider); - - // Record 3 failures - for (let i = 0; i < 3; i++) { - recordProviderFailure(provider); - now += 1000; - } - assert.equal(isProviderInCooldown(provider), false); - - // Wait for failure window to expire (20 minutes + 1 second) - now += 20 * 60 * 1000 + 1000; - - // Next failure should reset counter, not trigger cooldown - recordProviderFailure(provider); - assert.equal(isProviderInCooldown(provider), false); - - // Need 4 more failures to trigger cooldown - for (let i = 0; i < 4; i++) { - recordProviderFailure(provider); - now += 1000; - } - assert.equal(isProviderInCooldown(provider), true); - } finally { - Date.now = originalNow; - clearProviderFailure("test-provider-window"); + for (let i = 0; i < 5; i++) { + checkFallbackError(429, "rate limited", 0, null, provider); } -}); -test("checkFallbackError records provider failure for transient errors", () => { - const originalNow = Date.now; - let now = 1_700_000_000_000; - Date.now = () => now; - - try { - const provider = "test-provider-check"; - clearProviderFailure(provider); - - // Simulate 5 transient errors through checkFallbackError - for (let i = 0; i < 5; i++) { - checkFallbackError(429, "rate limited", 0, null, provider); - now += 1000; - } - - // Provider should now be in cooldown - assert.equal(isProviderInCooldown(provider), true); - } finally { - Date.now = originalNow; - clearProviderFailure("test-provider-check"); - } + assert.equal(isProviderInCooldown(provider), false); + clearProviderFailure(provider); }); test("checkFallbackError does not record provider failure for non-transient errors", () => { diff --git a/tests/unit/auth-terminal-status.test.ts b/tests/unit/auth-terminal-status.test.ts index a62c4ba788..e3cad8eea0 100644 --- a/tests/unit/auth-terminal-status.test.ts +++ b/tests/unit/auth-terminal-status.test.ts @@ -109,3 +109,129 @@ test("markAccountUnavailable does not overwrite terminal status", async () => { const after = await providersDb.getProviderConnectionById(conn.id); assert.equal(after.testStatus, "credits_exhausted"); }); + +test("markAccountUnavailable marks 401 connections as expired without adding cooldown", async () => { + await resetStorage(); + + const conn = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + apiKey: "sk-expired", + isActive: true, + testStatus: "active", + }); + + const result = await auth.markAccountUnavailable( + conn.id, + 401, + "unauthorized", + "openai", + "gpt-4.1" + ); + const after = await providersDb.getProviderConnectionById(conn.id); + + assert.equal(result.shouldFallback, true); + assert.equal(result.cooldownMs, 0); + assert.equal(after.testStatus, "expired"); + assert.ok(!after.rateLimitedUntil); +}); + +test("markAccountUnavailable marks 402 connections as credits_exhausted without adding cooldown", async () => { + await resetStorage(); + + const conn = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + apiKey: "sk-credits", + isActive: true, + testStatus: "active", + }); + + const result = await auth.markAccountUnavailable( + conn.id, + 402, + "payment required", + "openai", + "gpt-4.1" + ); + const after = await providersDb.getProviderConnectionById(conn.id); + + assert.equal(result.shouldFallback, true); + assert.equal(result.cooldownMs, 0); + assert.equal(after.testStatus, "credits_exhausted"); + assert.ok(!after.rateLimitedUntil); +}); + +test("markAccountUnavailable marks 403 connections as banned without adding cooldown", async () => { + await resetStorage(); + + const conn = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + apiKey: "sk-banned", + isActive: true, + testStatus: "active", + }); + + const result = await auth.markAccountUnavailable(conn.id, 403, "forbidden", "openai", "gpt-4.1"); + const after = await providersDb.getProviderConnectionById(conn.id); + + assert.equal(result.shouldFallback, true); + assert.equal(result.cooldownMs, 0); + assert.equal(after.testStatus, "banned"); + assert.ok(!after.rateLimitedUntil); +}); + +test("markAccountUnavailable keeps project-route 403 errors non-terminal", async () => { + await resetStorage(); + + const conn = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + apiKey: "sk-project-route", + isActive: true, + testStatus: "active", + }); + + const result = await auth.markAccountUnavailable( + conn.id, + 403, + "The service has not been used in project", + "openai", + "gpt-4.1" + ); + const after = await providersDb.getProviderConnectionById(conn.id); + + assert.equal(result.shouldFallback, true); + assert.equal(result.cooldownMs, 0); + assert.equal(after.testStatus, "active"); + assert.equal(after.lastErrorType, "project_route_error"); + assert.ok(!after.rateLimitedUntil); +}); + +test("markAccountUnavailable keeps oauth-invalid 401 errors non-terminal", async () => { + await resetStorage(); + + const conn = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + apiKey: "sk-oauth-invalid", + isActive: true, + testStatus: "active", + }); + + const result = await auth.markAccountUnavailable( + conn.id, + 401, + "Invalid authentication credentials provided", + "openai", + "gpt-4.1" + ); + const after = await providersDb.getProviderConnectionById(conn.id); + + assert.equal(result.shouldFallback, true); + assert.equal(result.cooldownMs, 0); + assert.equal(after.testStatus, "active"); + assert.equal(after.lastErrorType, "oauth_invalid_token"); + assert.ok(!after.rateLimitedUntil); +}); diff --git a/tests/unit/autocombo-unification.test.ts b/tests/unit/autocombo-unification.test.ts index a622651fdc..4ff04fa2dc 100644 --- a/tests/unit/autocombo-unification.test.ts +++ b/tests/unit/autocombo-unification.test.ts @@ -128,29 +128,40 @@ test("sidebar visibility excludes the removed auto-combo item", async () => { assert.deepEqual(sidebarVisibility.normalizeHiddenSidebarItems(["auto-combo", "home"]), ["home"]); }); -test("intelligent routing helpers normalize config and health state", () => { +test("intelligent routing helpers normalize config and build provider scores", () => { const normalizedConfig = intelligentRouting.normalizeIntelligentRoutingConfig({ candidatePool: ["openai", "anthropic"], explorationRate: "0.25", + modePack: "", + routerStrategy: "", weights: { quota: 0.4 }, }); assert.deepEqual(normalizedConfig.candidatePool, ["openai", "anthropic"]); assert.equal(normalizedConfig.explorationRate, 0.25); + assert.equal(normalizedConfig.modePack, "ship-fast"); + assert.equal(normalizedConfig.routerStrategy, "rules"); assert.equal(normalizedConfig.weights.quota, 0.4); assert.equal( normalizedConfig.weights.health, intelligentRouting.DEFAULT_INTELLIGENT_WEIGHTS.health ); - const healthState = intelligentRouting.extractIntelligentHealthState({ - circuitBreakers: [ - { provider: "openai", state: "OPEN", lastFailure: "2026-04-12T12:00:00Z" }, - { provider: "anthropic", state: "OPEN", lastFailure: "2026-04-12T12:01:00Z" }, - { provider: "google", state: "CLOSED" }, - ], + const providerScores = intelligentRouting.buildIntelligentProviderScores({ + config: normalizedConfig, }); - assert.equal(healthState.incidentMode, true); - assert.equal(healthState.exclusions.length, 2); + assert.equal(providerScores.length, 2); + assert.deepEqual( + providerScores.map((entry) => ({ + provider: entry.provider, + model: entry.model, + score: entry.score, + quotaWeight: entry.factors.quota, + })), + [ + { provider: "openai", model: "auto", score: 0.5, quotaWeight: 0.4 }, + { provider: "anthropic", model: "auto", score: 0.5, quotaWeight: 0.4 }, + ] + ); }); diff --git a/tests/unit/batch-a-domain.test.ts b/tests/unit/batch-a-domain.test.ts index 5efb40bf48..fd7e1237b7 100644 --- a/tests/unit/batch-a-domain.test.ts +++ b/tests/unit/batch-a-domain.test.ts @@ -1,166 +1,13 @@ /** * Batch A — Domain Layer + Infrastructure Tests * - * Tests for: modelAvailability, costRules, fallbackPolicy, + * Tests for: costRules, fallbackPolicy, * errorCodes, requestId, fetchTimeout */ import { describe, it, before, after } from "node:test"; import assert from "node:assert/strict"; -// ──────────────── T-19: Model Availability ──────────────── - -import { - isModelAvailable, - setModelUnavailable, - markModelAsProblematic, - clearModelUnavailability, - getAvailabilityReport, - getUnavailableCount, - resetAllAvailability, -} from "../../src/domain/modelAvailability.ts"; - -describe("modelAvailability", () => { - before(() => resetAllAvailability()); - after(() => resetAllAvailability()); - - it("should report model as available by default", () => { - assert.equal(isModelAvailable("openai", "gpt-4o"), true); - }); - - it("should mark model as unavailable", () => { - setModelUnavailable("openai", "gpt-4o", 60000, "rate limited"); - assert.equal(isModelAvailable("openai", "gpt-4o"), false); - }); - - it("should report unavailable models", () => { - const report = getAvailabilityReport(); - assert.equal(report.length, 1); - assert.equal(report[0].provider, "openai"); - assert.equal(report[0].model, "gpt-4o"); - assert.equal(report[0].reason, "rate limited"); - assert.ok(report[0].remainingMs > 0); - }); - - it("should count unavailable models", () => { - assert.equal(getUnavailableCount(), 1); - }); - - it("should clear model unavailability", () => { - clearModelUnavailability("openai", "gpt-4o"); - assert.equal(isModelAvailable("openai", "gpt-4o"), true); - assert.equal(getUnavailableCount(), 0); - }); - - it("should auto-expire after cooldown", () => { - setModelUnavailable("anthropic", "claude-sonnet-4-20250514", 1, "test"); - // Wait 2ms for expiry - const start = Date.now(); - while (Date.now() - start < 5) {} // spin wait - assert.equal(isModelAvailable("anthropic", "claude-sonnet-4-20250514"), true); - }); - - it("should apply adaptive quarantine for repeated problematic failures", () => { - const first = markModelAsProblematic("nvidia", "z-ai/glm4.7", { - status: 502, - baseCooldownMs: 3000, - reason: "HTTP 502", - }); - const second = markModelAsProblematic("nvidia", "z-ai/glm4.7", { - status: 502, - baseCooldownMs: 3000, - reason: "HTTP 502", - }); - - assert.equal(first.failureCount, 1); - assert.equal(first.cooldownMs, 120000); - assert.equal(second.failureCount, 2); - assert.equal(second.cooldownMs, 240000); - - const report = getAvailabilityReport(); - const entry = report.find((r) => r.provider === "nvidia" && r.model === "z-ai/glm4.7"); - assert.ok(entry, "nvidia model should be quarantined"); - assert.ok(entry.remainingMs >= 200000, "cooldown should be preserved/escalated"); - }); - - it("should reset failure history after model recovery", () => { - clearModelUnavailability("nvidia", "z-ai/glm4.7"); - const afterReset = markModelAsProblematic("nvidia", "z-ai/glm4.7", { - status: 502, - baseCooldownMs: 3000, - reason: "HTTP 502", - }); - - assert.equal(afterReset.failureCount, 1); - assert.equal(afterReset.cooldownMs, 120000); - }); - - it("should use provider profile threshold, cooldowns, and reset window for global quarantine", () => { - const originalNow = Date.now; - let now = 10_000; - Date.now = () => now; - - try { - const profile = { - transientCooldown: 200, - rateLimitCooldown: 100, - maxBackoffLevel: 2, - circuitBreakerThreshold: 3, - circuitBreakerReset: 500, - }; - - const first = markModelAsProblematic("openai", "gpt-4o-mini", { - status: 429, - reason: "HTTP 429", - profile, - }); - const second = markModelAsProblematic("openai", "gpt-4o-mini", { - status: 429, - reason: "HTTP 429", - profile, - }); - - assert.equal(first.failureCount, 1); - assert.equal(first.cooldownMs, 100); - assert.equal(first.quarantined, false); - assert.equal(isModelAvailable("openai", "gpt-4o-mini"), true); - - assert.equal(second.failureCount, 2); - assert.equal(second.cooldownMs, 200); - assert.equal(second.quarantined, false); - assert.equal(isModelAvailable("openai", "gpt-4o-mini"), true); - - now += 10; - const third = markModelAsProblematic("openai", "gpt-4o-mini", { - status: 429, - reason: "HTTP 429", - profile, - }); - - assert.equal(third.failureCount, 3); - assert.equal(third.cooldownMs, 400); - assert.equal(third.quarantined, true); - assert.equal(isModelAvailable("openai", "gpt-4o-mini"), false); - - now += 600; - assert.equal(isModelAvailable("openai", "gpt-4o-mini"), true); - - const afterWindow = markModelAsProblematic("openai", "gpt-4o-mini", { - status: 429, - reason: "HTTP 429", - profile, - }); - - assert.equal(afterWindow.failureCount, 1); - assert.equal(afterWindow.cooldownMs, 100); - assert.equal(afterWindow.quarantined, false); - } finally { - Date.now = originalNow; - clearModelUnavailability("openai", "gpt-4o-mini"); - } - }); -}); - // ──────────────── T-19: Cost Rules ──────────────── import { diff --git a/tests/unit/chat-combo-live-test.test.ts b/tests/unit/chat-combo-live-test.test.ts index 107d601ccd..31c99f7517 100644 --- a/tests/unit/chat-combo-live-test.test.ts +++ b/tests/unit/chat-combo-live-test.test.ts @@ -12,8 +12,6 @@ const providersDb = await import("../../src/lib/db/providers.ts"); const chatRoute = await import("../../src/app/api/v1/chat/completions/route.ts"); const { generateSignature, invalidateBySignature, setCachedResponse } = await import("../../src/lib/semanticCache.ts"); -const { clearModelUnavailability, resetAllAvailability, setModelUnavailable } = - await import("../../src/domain/modelAvailability.ts"); const { getCircuitBreaker, resetAllCircuitBreakers, STATE } = await import("../../src/shared/utils/circuitBreaker.ts"); @@ -28,7 +26,6 @@ async function resetStorage() { core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); - resetAllAvailability(); resetAllCircuitBreakers(); } @@ -79,27 +76,20 @@ test.beforeEach(async () => { test.afterEach(async () => { await flushBackgroundWork(); globalThis.fetch = originalFetch; - resetAllAvailability(); resetAllCircuitBreakers(); }); test.after(async () => { await flushBackgroundWork(); globalThis.fetch = originalFetch; - resetAllAvailability(); resetAllCircuitBreakers(); core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); }); -test("combo live test bypasses local cooldown and breaker state to perform a real upstream request", async () => { +test("combo live test bypasses connection cooldown and breaker state to perform a real upstream request", async () => { const created = await seedSuppressedConnection(); - setModelUnavailable("openai", "gpt-4o-mini", 60_000, "test cooldown"); - const breaker = getCircuitBreaker("openai"); - breaker.state = STATE.OPEN; - breaker.lastFailureTime = Date.now(); - const fetchCalls = []; globalThis.fetch = async (url, init = {}) => { fetchCalls.push({ url: String(url), init }); @@ -120,7 +110,10 @@ test("combo live test bypasses local cooldown and breaker state to perform a rea assert.equal(blockedByCooldown.status, 503); assert.equal(fetchCalls.length, 0); - clearModelUnavailability("openai", "gpt-4o-mini"); + const breaker = getCircuitBreaker("openai"); + breaker.state = STATE.OPEN; + breaker.lastFailureTime = Date.now(); + breaker.resetTimeout = 60_000; const blockedByBreaker = await chatRoute.POST(makeRequest()); assert.equal(blockedByBreaker.status, 503); diff --git a/tests/unit/chat-helpers.test.ts b/tests/unit/chat-helpers.test.ts index 64726e297d..102ccfd84c 100644 --- a/tests/unit/chat-helpers.test.ts +++ b/tests/unit/chat-helpers.test.ts @@ -18,13 +18,10 @@ const { safeLogEvents, withSessionHeader, } = await import("../../src/sse/handlers/chatHelpers.ts"); -const { getModelCooldownInfo, setModelUnavailable, resetAllAvailability } = - await import("../../src/domain/modelAvailability.ts"); -const { getCircuitBreaker, resetAllCircuitBreakers, CircuitBreakerOpenError, STATE } = +const { getCircuitBreaker, resetAllCircuitBreakers, STATE } = await import("../../src/shared/utils/circuitBreaker.ts"); async function resetStorage() { - resetAllAvailability(); resetAllCircuitBreakers(); core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); @@ -92,22 +89,6 @@ test("resolveModelOrError rejects malformed model strings", async () => { assert.match(json.error.message, /Invalid model format/i); }); -test("checkPipelineGates blocks models in cooldown", async () => { - setModelUnavailable("openai", "gpt-4o-mini", 12_000, "cooldown"); - - const response = await checkPipelineGates("openai", "gpt-4o-mini"); - const json = await response.json(); - const cooldownInfo = getModelCooldownInfo("openai", "gpt-4o-mini"); - const retryAfter = Number(response.headers.get("Retry-After")); - - assert.equal(response.status, 503); - assert.ok(cooldownInfo); - assert.ok(retryAfter >= 1); - assert.ok(retryAfter <= 12); - assert.ok(retryAfter >= Math.ceil((cooldownInfo?.remainingMs || 0) / 1000) - 1); - assert.match(json.error.message, /temporarily unavailable/i); -}); - test("checkPipelineGates blocks providers with an open circuit breaker", async () => { const breaker = getCircuitBreaker("openai"); breaker.state = STATE.OPEN; @@ -116,8 +97,8 @@ test("checkPipelineGates blocks providers with an open circuit breaker", async ( const response = await checkPipelineGates("openai", "gpt-4o-mini", { providerProfile: { - circuitBreakerThreshold: 5, - circuitBreakerReset: 5_000, + failureThreshold: 5, + resetTimeoutMs: 5_000, }, }); const json = await response.json(); @@ -127,6 +108,8 @@ test("checkPipelineGates blocks providers with an open circuit breaker", async ( assert.ok(retryAfter >= 4); assert.ok(retryAfter <= 5); assert.match(json.error.message, /circuit breaker is open/i); + assert.equal(json.error.code, "provider_circuit_open"); + assert.equal(response.headers.get("X-OmniRoute-Provider-Breaker"), "open"); }); test("checkPipelineGates reapplies runtime breaker settings to existing breakers", async () => { @@ -139,8 +122,8 @@ test("checkPipelineGates reapplies runtime breaker settings to existing breakers const response = await checkPipelineGates("openai", "gpt-4o-mini", { providerProfile: { - circuitBreakerThreshold: 60, - circuitBreakerReset: 5_000, + failureThreshold: 60, + resetTimeoutMs: 5_000, }, }); @@ -233,60 +216,44 @@ test("safeResolveProxy returns the direct route when no proxy config is present" }); }); -test("executeChatWithBreaker converts circuit-open and proxy-fast-fail errors", async () => { - const credentials = { connectionId: "conn_helper" }; - const openResult = await executeChatWithBreaker({ - bypassCircuitBreaker: false, - breaker: { - execute: async () => { - throw new CircuitBreakerOpenError("already open", "openai", 5_000); - }, - }, - body: { model: "openai/gpt-4o-mini" }, - provider: "openai", - model: "gpt-4o-mini", - refreshedCredentials: credentials, - proxyInfo: null, - log: console, - clientRawRequest: null, - credentials, - apiKeyInfo: null, - userAgent: "", - comboName: null, - comboStrategy: null, - isCombo: false, - extendedContext: false, - }); +test("executeChatWithBreaker converts proxy fast-fail errors", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + const error = new Error("Proxy unreachable"); + (error as Error & { code?: string }).code = "PROXY_UNREACHABLE"; + throw error; + }; - const proxyResult = await executeChatWithBreaker({ - bypassCircuitBreaker: false, - breaker: { - execute: async () => { - const error = new Error("Proxy unreachable"); - error.code = "PROXY_UNREACHABLE"; - throw error; - }, - }, - body: { model: "openai/gpt-4o-mini" }, - provider: "openai", - model: "gpt-4o-mini", - refreshedCredentials: credentials, - proxyInfo: null, - log: console, - clientRawRequest: null, - credentials, - apiKeyInfo: null, - userAgent: "", - comboName: null, - comboStrategy: null, - isCombo: false, - extendedContext: false, - }); + try { + const credentials = { + connectionId: "conn_helper", + apiKey: "sk-openai-helper", + providerSpecificData: {}, + }; + const proxyResult = await executeChatWithBreaker({ + body: { model: "openai/gpt-4o-mini" }, + provider: "openai", + model: "gpt-4o-mini", + refreshedCredentials: credentials, + proxyInfo: null, + log: console, + clientRawRequest: null, + credentials, + apiKeyInfo: null, + userAgent: "", + comboName: null, + comboStrategy: null, + isCombo: false, + extendedContext: false, + comboStepId: null, + comboExecutionKey: null, + }); - assert.equal(openResult.result.status, 503); - assert.equal(openResult.result.response.status, 503); - assert.equal(proxyResult.result.status, 503); - assert.equal(proxyResult.result.error, "Proxy unreachable"); + assert.equal(proxyResult.result.status, 502); + assert.match(String(proxyResult.result.error || ""), /Proxy unreachable/); + } finally { + globalThis.fetch = originalFetch; + } }); test("safeLogEvents tolerates success and timeout payloads", () => { diff --git a/tests/unit/chat-route-coverage.test.ts b/tests/unit/chat-route-coverage.test.ts index 2c7b4ca71d..f38085ff94 100644 --- a/tests/unit/chat-route-coverage.test.ts +++ b/tests/unit/chat-route-coverage.test.ts @@ -14,13 +14,11 @@ const { resetStorage, seedApiKey, seedConnection, - setModelUnavailable, settingsDb, toPlainHeaders, } = harness; const { getCircuitBreaker, STATE } = await import("../../src/shared/utils/circuitBreaker.ts"); -const { clearModelUnavailability } = await import("../../src/domain/modelAvailability.ts"); const { clearProviderFailure } = await import("../../open-sse/services/accountFallback.ts"); const { getDefaultTaskModelMap, resetTaskRoutingStats, setTaskRoutingConfig } = await import("../../open-sse/services/taskAwareRouter.ts"); @@ -345,9 +343,11 @@ test("handleChat returns 400 when no provider credentials exist", async () => { assert.match(json.error.message, /No credentials for provider: openai/); }); -test("handleChat returns 503 for cooled-down models and open circuit breakers", async () => { - await seedConnection("openai", { apiKey: "sk-openai-breaker" }); - setModelUnavailable("openai", "gpt-4o-mini", 60_000, "test cooldown"); +test("handleChat returns 503 for cooled-down connections and 503 for open circuit breakers", async () => { + await seedConnection("openai", { + apiKey: "sk-openai-breaker", + rateLimitedUntil: new Date(Date.now() + 60_000).toISOString(), + }); const cooldownResponse = await handleChat( buildRequest({ @@ -360,15 +360,13 @@ test("handleChat returns 503 for cooled-down models and open circuit breakers", ); const cooldownJson = await cooldownResponse.json(); assert.equal(cooldownResponse.status, 503); - assert.match(cooldownJson.error.message, /temporarily unavailable/i); - - clearModelUnavailability("openai", "gpt-4o-mini"); - const freshBreaker = getCircuitBreaker("openai"); - freshBreaker.reset(); + assert.ok(Number(cooldownResponse.headers.get("Retry-After")) >= 1); + assert.match(cooldownJson.error.message, /\[openai\/gpt-4o-mini\]/i); const breaker = getCircuitBreaker("openai"); breaker.state = STATE.OPEN; breaker.lastFailureTime = Date.now(); + breaker.resetTimeout = 60_000; const breakerBlocked = await handleChat( buildRequest({ @@ -382,6 +380,8 @@ test("handleChat returns 503 for cooled-down models and open circuit breakers", const breakerJson = await breakerBlocked.json(); assert.equal(breakerBlocked.status, 503); + assert.equal(breakerBlocked.headers.get("X-OmniRoute-Provider-Breaker"), "open"); + assert.equal(breakerJson.error.code, "provider_circuit_open"); assert.match(breakerJson.error.message, /circuit breaker is open/i); }); diff --git a/tests/unit/combo-circuit-breaker.test.ts b/tests/unit/combo-circuit-breaker.test.ts deleted file mode 100644 index 5b06a3a848..0000000000 --- a/tests/unit/combo-circuit-breaker.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -// Reset circuit breaker registry between tests -const { CircuitBreaker, getCircuitBreaker, getAllCircuitBreakerStatuses, STATE } = - await import("../../src/shared/utils/circuitBreaker.ts"); - -const { handleComboChat, getComboFromData } = await import("../../open-sse/services/combo.ts"); -const { normalizeComboStep } = await import("../../src/lib/combos/steps.ts"); - -const { PROVIDER_PROFILES } = await import("../../open-sse/config/constants.ts"); - -// ─── Helpers ──────────────────────────────────────────────────────────────── - -/** Create a mock logger */ -function mockLog() { - const entries = []; - return { - info: (tag, msg) => entries.push({ level: "info", tag, msg }), - warn: (tag, msg) => entries.push({ level: "warn", tag, msg }), - error: (tag, msg) => entries.push({ level: "error", tag, msg }), - entries, - }; -} - -/** Create a handleSingleModel that returns given status codes in sequence */ -function mockHandler(statusSequence) { - let callIndex = 0; - return async (body, modelStr) => { - const status = statusSequence[callIndex] ?? statusSequence[statusSequence.length - 1] ?? 200; - callIndex++; - if (status === 200) { - return new Response(JSON.stringify({ ok: true }), { status: 200 }); - } - return new Response(JSON.stringify({ error: { message: `Error ${status}` } }), { - status, - statusText: `Error ${status}`, - }); - }; -} - -function getComboTargetBreakerKey(combo, index = 0) { - const step = normalizeComboStep(combo.models[index], { - comboName: combo.name, - index, - }); - return `combo:${combo.name}:${step.id}`; -} - -// ─── Circuit Breaker Integration Tests ────────────────────────────────────── -// NOTE: combo.ts uses the full model string (e.g. "combo:groq/llama-3.3-70b") -// as the circuit breaker key, not just the provider prefix. - -test("handleComboChat: circuit breaker opens after repeated 502 errors", async () => { - const combo = { - name: "test-combo", - models: [{ model: "groq/llama-3.3-70b", weight: 0 }], - strategy: "priority", - config: { maxRetries: 0 }, - }; - const breakerKey = getComboTargetBreakerKey(combo); - const breaker = getCircuitBreaker(breakerKey, { - failureThreshold: PROVIDER_PROFILES.apikey.circuitBreakerThreshold, - resetTimeout: 60000, - }); - breaker.reset(); - - const log = mockLog(); - - // Send requests that all fail with 502 until the API-key profile threshold is reached. - for (let i = 0; i < PROVIDER_PROFILES.apikey.circuitBreakerThreshold; i++) { - await handleComboChat({ - body: {}, - combo, - handleSingleModel: mockHandler([502]), - isModelAvailable: () => true, - log, - settings: null, - allCombos: null, - }); - } - - // Breaker should now be OPEN - const status = breaker.getStatus(); - console.log("=== BREAKER STATUS AFTER THRESHOLD CALLS ===", status); - assert.equal( - status.state, - STATE.OPEN, - "Breaker should be OPEN after the API-key failure threshold is reached" - ); - assert.equal( - status.failureCount, - PROVIDER_PROFILES.apikey.circuitBreakerThreshold, - "Failure count should match the API-key profile threshold" - ); -}); - -test("handleComboChat: skips models with open circuit breaker", async () => { - // Set up: groq breaker is OPEN, fireworks breaker is CLOSED - const combo = { - name: "test-skip-combo", - models: [ - { model: "groq/llama-3.3-70b", weight: 0 }, - { model: "fireworks/deepseek-v3p1", weight: 0 }, - ], - strategy: "priority", - }; - const groqBreakerKey = getComboTargetBreakerKey(combo, 0); - const groqBreaker = getCircuitBreaker(groqBreakerKey, { - failureThreshold: 3, - resetTimeout: 60000, - }); - groqBreaker.reset(); - // Force open the breaker - groqBreaker._onFailure(); - groqBreaker._onFailure(); - groqBreaker._onFailure(); - assert.equal(groqBreaker.getStatus().state, STATE.OPEN); - - const fireworksBreakerKey = getComboTargetBreakerKey(combo, 1); - const fireworksBreaker = getCircuitBreaker(fireworksBreakerKey, { - failureThreshold: 5, - resetTimeout: 30000, - }); - fireworksBreaker.reset(); - - const log = mockLog(); - - const result = await handleComboChat({ - body: {}, - combo, - handleSingleModel: mockHandler([200]), // fireworks will succeed - isModelAvailable: () => true, - log, - settings: null, - allCombos: null, - }); - - assert.equal(result.ok, true, "Should succeed via fireworks fallback"); - - // Check logs show groq was skipped - const skipLog = log.entries.find( - (e) => e.msg.includes("circuit breaker OPEN") && e.msg.includes("groq") - ); - assert.ok(skipLog, "Should log that groq was skipped due to breaker"); -}); - -test("handleComboChat: returns 503 when all breakers are open", async () => { - const combo = { - name: "test-all-open", - models: [ - { model: "groq/llama-3.3-70b", weight: 0 }, - { model: "fireworks/deepseek-v3p1", weight: 0 }, - ], - strategy: "priority", - }; - - // Open both breakers explicitly before invoking the combo. - const groqBreaker = getCircuitBreaker(getComboTargetBreakerKey(combo, 0), { - failureThreshold: 3, - resetTimeout: 60000, - }); - groqBreaker.reset(); - groqBreaker._onFailure(); - groqBreaker._onFailure(); - groqBreaker._onFailure(); - assert.equal(groqBreaker.getStatus().state, STATE.OPEN); - - const fireworksBreaker = getCircuitBreaker(getComboTargetBreakerKey(combo, 1), { - failureThreshold: 5, - resetTimeout: 30000, - }); - fireworksBreaker.reset(); - for (let i = 0; i < 5; i++) fireworksBreaker._onFailure(); - assert.equal(fireworksBreaker.getStatus().state, STATE.OPEN); - - const log = mockLog(); - - const result = await handleComboChat({ - body: {}, - combo, - handleSingleModel: mockHandler([200]), // Won't be called - isModelAvailable: () => true, - log, - settings: null, - allCombos: null, - }); - - assert.equal(result.status, 503, "Should return 503"); - const body = await result.json(); - assert.ok(body.error.message.includes("circuit breakers open"), "Should mention breakers"); -}); - -test("handleComboChat: 429 errors also trigger circuit breaker", async () => { - const combo = { - name: "test-429", - models: [{ model: "cerebras/llama-3.3-70b", weight: 0 }], - strategy: "priority", - }; - const breakerKey = getComboTargetBreakerKey(combo); - const breaker = getCircuitBreaker(breakerKey, { - failureThreshold: 5, - resetTimeout: 30000, - }); - breaker.reset(); - - const log = mockLog(); - - // 5 x 429 should open breaker - for (let i = 0; i < 5; i++) { - await handleComboChat({ - body: {}, - combo, - handleSingleModel: mockHandler([429]), - isModelAvailable: () => true, - log, - settings: null, - allCombos: null, - }); - } - - assert.equal(breaker.getStatus().state, STATE.OPEN, "429s should open breaker"); -}); - -test("circuit breaker uses provider profile thresholds", () => { - // OAuth providers (e.g. claude) should have lower threshold - assert.equal(PROVIDER_PROFILES.oauth.circuitBreakerThreshold, 3); - // API providers (e.g. groq) should have higher threshold - assert.equal(PROVIDER_PROFILES.apikey.circuitBreakerThreshold, 5); -}); diff --git a/tests/unit/combo-config.test.ts b/tests/unit/combo-config.test.ts index 014ff4e44a..0c39a25993 100644 --- a/tests/unit/combo-config.test.ts +++ b/tests/unit/combo-config.test.ts @@ -13,7 +13,9 @@ test("getDefaultComboConfig returns a fresh copy of the defaults", () => { assert.notEqual(first, second); assert.equal(first.strategy, "priority"); assert.equal(first.maxRetries, 1); - assert.equal(first.timeoutMs, 600000); + assert.equal(first.retryDelayMs, 2000); + assert.ok(!("timeoutMs" in first)); + assert.ok(!("healthCheckEnabled" in first)); assert.equal(first.handoffThreshold, 0.85); assert.equal(first.maxMessagesForSummary, 30); assert.deepEqual(first.handoffProviders, ["codex"]); @@ -27,7 +29,6 @@ test("resolveComboConfig applies the full cascade from defaults to combo overrid { config: { maxRetries: 4, - timeoutMs: 45000, }, }, { @@ -47,12 +48,12 @@ test("resolveComboConfig applies the full cascade from defaults to combo overrid assert.equal(result.strategy, "round-robin"); assert.equal(result.retryDelayMs, 500); - assert.equal(result.timeoutMs, 45000); assert.equal(result.maxRetries, 4); - assert.equal(result.healthCheckEnabled, true); + assert.ok(!("timeoutMs" in result)); + assert.ok(!("healthCheckEnabled" in result)); }); -test("resolveComboConfig ignores null and undefined overrides", () => { +test("resolveComboConfig ignores null, undefined, and legacy resilience overrides", () => { const result = resolveComboConfig( { config: { @@ -75,7 +76,7 @@ test("resolveComboConfig ignores null and undefined overrides", () => { "openai" ); - assert.equal(result.timeoutMs, 600000); + assert.ok(!("timeoutMs" in result)); assert.equal(result.queueTimeoutMs, 15000); assert.equal(result.concurrencyPerModel, 9); assert.equal(result.trackMetrics, false); diff --git a/tests/unit/combo-context-relay.test.ts b/tests/unit/combo-context-relay.test.ts index 7675f05848..b3aa09f78d 100644 --- a/tests/unit/combo-context-relay.test.ts +++ b/tests/unit/combo-context-relay.test.ts @@ -13,12 +13,10 @@ const handoffDb = await import("../../src/lib/db/contextHandoffs.ts"); const { registerCodexConnection } = await import("../../open-sse/services/codexQuotaFetcher.ts"); const { clearSessions, touchSession } = await import("../../open-sse/services/sessionManager.ts"); const { resetAllComboMetrics } = await import("../../open-sse/services/comboMetrics.ts"); -const { resetAllCircuitBreakers, getCircuitBreaker } = - await import("../../src/shared/utils/circuitBreaker.ts"); +const { resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts"); const { resetAll: resetAllSemaphores } = await import("../../open-sse/services/rateLimitSemaphore.ts"); const { _resetAllDecks } = await import("../../src/shared/utils/shuffleDeck.ts"); -const { normalizeComboStep } = await import("../../src/lib/combos/steps.ts"); const originalFetch = globalThis.fetch; @@ -39,6 +37,24 @@ function okResponse(body = { choices: [{ message: { content: "ok" } }] }) { }); } +function providerBreakerOpenResponse() { + return new Response( + JSON.stringify({ + error: { + message: "Provider circuit breaker is open", + code: "provider_circuit_open", + }, + }), + { + status: 503, + headers: { + "content-type": "application/json", + "x-omniroute-provider-breaker": "open", + }, + } + ); +} + function buildQuotaResponse(usedPercent, resetAfterSeconds = 3600) { return new Response( JSON.stringify({ @@ -161,23 +177,13 @@ test("handleComboChat context-relay skips unavailable models and falls through t assert.deepEqual(calls, ["openai/gpt-4o-mini"]); }); -test("handleComboChat context-relay skips models with an open circuit breaker", async () => { +test("handleComboChat context-relay skips targets that report an open provider circuit breaker", async () => { const combo = { name: "relay-breaker", strategy: "context-relay", models: ["codex/gpt-5.4", "openai/gpt-4o-mini"], config: { maxRetries: 0 }, }; - const firstStep = normalizeComboStep(combo.models[0], { - comboName: combo.name, - index: 0, - }); - const breaker = getCircuitBreaker(`combo:${combo.name}:${firstStep.id}`, { - failureThreshold: 1, - resetTimeout: 60000, - }); - breaker._onFailure(); - const log = createLog(); const calls = []; @@ -188,6 +194,9 @@ test("handleComboChat context-relay skips models with an open circuit breaker", combo, handleSingleModel: async (_body, modelStr) => { calls.push(modelStr); + if (modelStr === "codex/gpt-5.4") { + return providerBreakerOpenResponse(); + } return okResponse(); }, isModelAvailable: async () => true, @@ -197,8 +206,10 @@ test("handleComboChat context-relay skips models with an open circuit breaker", }); assert.equal(result.ok, true); - assert.deepEqual(calls, ["openai/gpt-4o-mini"]); - assert.ok(log.entries.some((entry) => entry.msg.includes("circuit breaker OPEN"))); + assert.deepEqual(calls, ["codex/gpt-5.4", "openai/gpt-4o-mini"]); + assert.ok( + log.entries.some((entry) => String(entry.msg).includes("provider circuit breaker OPEN")) + ); }); test("handleComboChat context-relay persists a handoff when codex quota reaches the warning threshold", async () => { diff --git a/tests/unit/combo-routing-engine.test.ts b/tests/unit/combo-routing-engine.test.ts index 823dfbc3bb..c8dd07e2ac 100644 --- a/tests/unit/combo-routing-engine.test.ts +++ b/tests/unit/combo-routing-engine.test.ts @@ -24,8 +24,7 @@ const { saveModelsDevCapabilities, clearModelsDevCapabilities } = await import("../../src/lib/modelsDevSync.ts"); const { getComboMetrics, recordComboRequest, resetAllComboMetrics } = await import("../../open-sse/services/comboMetrics.ts"); -const { getCircuitBreaker, resetAllCircuitBreakers } = - await import("../../src/shared/utils/circuitBreaker.ts"); +const { resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts"); const { acquire: acquireSemaphore, resetAll: resetAllSemaphores } = await import("../../open-sse/services/rateLimitSemaphore.ts"); const { _resetAllDecks } = await import("../../src/shared/utils/shuffleDeck.ts"); @@ -54,6 +53,24 @@ function errorResponse(status: number, message: string = `Error ${status}`) { }); } +function providerBreakerOpenResponse() { + return new Response( + JSON.stringify({ + error: { + message: "Provider circuit breaker is open", + code: "provider_circuit_open", + }, + }), + { + status: 503, + headers: { + "content-type": "application/json", + "x-omniroute-provider-breaker": "open", + }, + } + ); +} + function streamResponse(chunks: any[]) { return new Response(chunks.join(""), { status: 200, @@ -83,7 +100,7 @@ function capabilityEntry(limitContext: any) { }; } -function getComboTargetBreakerKey(comboName: string, index: number, stepInput: any) { +function getComboTargetExecutionKey(comboName: string, index: number, stepInput: any) { const step = normalizeComboStep(stepInput, { comboName, index }); if (!step) throw new Error(`Failed to normalize combo step for ${comboName}#${index}`); return `combo:${comboName}:${step.id}`; @@ -1038,7 +1055,7 @@ test("handleComboChat round-robin returns 404 when no models are configured", as test("handleComboChat round-robin falls through semaphore timeouts and malformed success payloads", async () => { const release = await acquireSemaphore( - getComboTargetBreakerKey("rr-timeout-fallback", 0, "model-a"), + getComboTargetExecutionKey("rr-timeout-fallback", 0, "model-a"), { maxConcurrency: 1, timeoutMs: 100, @@ -1376,38 +1393,36 @@ test("handleComboChat returns a 503 when every model is unavailable before execu assert.equal(payload.error.code, "ALL_ACCOUNTS_INACTIVE"); }); -test("handleComboChat returns the circuit-breaker unavailable response when all breakers are open", async () => { - for (const [index, modelStr] of ["openai/model-a", "openai/model-b"].entries()) { - const breaker = getCircuitBreaker( - getComboTargetBreakerKey("all-breakers-open", index, modelStr), - { - failureThreshold: 1, - resetTimeout: 60000, - } - ); - breaker._onFailure(); - } - +test("handleComboChat falls through targets that return provider circuit breaker open responses", async () => { + const calls = []; + const log = createLog(); const result = await handleComboChat({ body: {}, combo: { - name: "all-breakers-open", + name: "provider-breaker-open", strategy: "priority", models: ["openai/model-a", "openai/model-b"], }, - handleSingleModel: async () => { - throw new Error("handleSingleModel should not run when all breakers are open"); + handleSingleModel: async (_body, modelStr) => { + calls.push(modelStr); + if (modelStr === "openai/model-a") { + return providerBreakerOpenResponse(); + } + return okResponse(); }, isModelAvailable: async () => true, - log: createLog(), + log, settings: null, relayOptions: null as any, allCombos: null, relayOptions: null, }); - assert.equal(result.status, 503); - assert.match((await result.json()).error.message, /circuit breakers open/); + assert.equal(result.ok, true); + assert.deepEqual(calls, ["openai/model-a", "openai/model-b"]); + assert.ok( + log.entries.some((entry) => String(entry.msg).includes("provider circuit breaker OPEN")) + ); }); test("handleComboChat auto strategy honors LKGP after filtering to tool-capable models", async () => { @@ -1799,39 +1814,37 @@ test("handleComboChat round-robin resolves nested combos and returns inactive wh assert.equal(payload.error.code, "ALL_ACCOUNTS_INACTIVE"); }); -test("handleComboChat round-robin returns circuit-breaker unavailable when every model is open", async () => { - for (const [index, modelStr] of ["openai/model-a", "openai/model-b"].entries()) { - const breaker = getCircuitBreaker( - getComboTargetBreakerKey("rr-breakers-open", index, modelStr), - { - failureThreshold: 1, - resetTimeout: 60000, - } - ); - breaker._onFailure(); - } - +test("handleComboChat round-robin skips targets that return provider circuit breaker open responses", async () => { + const calls = []; + const log = createLog(); const result = await handleComboChat({ body: {}, combo: { - name: "rr-breakers-open", + name: "rr-provider-breaker-open", strategy: "round-robin", models: ["openai/model-a", "openai/model-b"], config: { maxRetries: 0 }, }, - handleSingleModel: async () => { - throw new Error("round-robin should not execute when all breakers are open"); + handleSingleModel: async (_body, modelStr) => { + calls.push(modelStr); + if (modelStr === "openai/model-a") { + return providerBreakerOpenResponse(); + } + return okResponse(); }, isModelAvailable: async () => true, - log: createLog(), + log, settings: null, relayOptions: null as any, allCombos: null, relayOptions: null, }); - assert.equal(result.status, 503); - assert.match((await result.json()).error.message, /circuit breakers open/); + assert.equal(result.ok, true); + assert.deepEqual(calls, ["openai/model-a", "openai/model-b"]); + assert.ok( + log.entries.some((entry) => String(entry.msg).includes("provider circuit breaker OPEN")) + ); }); test("handleComboChat round-robin retries a transient failure on the same model before succeeding", async () => { diff --git a/tests/unit/domain-branch-hardening.test.ts b/tests/unit/domain-branch-hardening.test.ts index 63d8e387d7..8f6071dbee 100644 --- a/tests/unit/domain-branch-hardening.test.ts +++ b/tests/unit/domain-branch-hardening.test.ts @@ -11,7 +11,6 @@ const core = await import("../../src/lib/db/core.ts"); const costRules = await import("../../src/domain/costRules.ts"); const fallbackPolicy = await import("../../src/domain/fallbackPolicy.ts"); const lockoutPolicy = await import("../../src/domain/lockoutPolicy.ts"); -const modelAvailability = await import("../../src/domain/modelAvailability.ts"); const providerExpiration = await import("../../src/domain/providerExpiration.ts"); const quotaCache = await import("../../src/domain/quotaCache.ts"); const comboResolver = await import("../../src/domain/comboResolver.ts"); @@ -28,7 +27,6 @@ function isoFromNow(offsetMs) { async function resetStorage() { costRules.resetCostData(); fallbackPolicy.resetAllFallbacks(); - modelAvailability.resetAllAvailability(); providerExpiration.resetExpirations(); quotaCache.stopBackgroundRefresh(); core.resetDbInstance(); @@ -63,7 +61,6 @@ test.after(async () => { quotaCache.stopBackgroundRefresh(); costRules.resetCostData(); fallbackPolicy.resetAllFallbacks(); - modelAvailability.resetAllAvailability(); providerExpiration.resetExpirations(); core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); @@ -192,30 +189,6 @@ test("resolveComboModel also covers implicit defaults and missing optional field assert.deepEqual(comboResolver.getComboFallbacks({}, 0), []); }); -test("modelAvailability tracks missing, active and expired cooldowns", () => { - let now = 1_000; - Date.now = () => now; - - assert.equal(modelAvailability.isModelAvailable("openai", "gpt-4o"), true); - - modelAvailability.setModelUnavailable("openai", "gpt-4o", 100, undefined); - modelAvailability.setModelUnavailable("anthropic", "claude-sonnet", 500, "capacity"); - - assert.equal(modelAvailability.isModelAvailable("openai", "gpt-4o"), false); - assert.equal(modelAvailability.getUnavailableCount(), 2); - - const report = modelAvailability.getAvailabilityReport(); - assert.equal(report.length, 2); - assert.equal(report[0].reason, "unknown"); - assert.equal(report[1].reason, "capacity"); - - now += 150; - assert.equal(modelAvailability.isModelAvailable("openai", "gpt-4o"), true); - assert.equal(modelAvailability.clearModelUnavailability("openai", "gpt-4o"), false); - assert.equal(modelAvailability.clearModelUnavailability("anthropic", "claude-sonnet"), true); - assert.equal(modelAvailability.getUnavailableCount(), 0); -}); - test("providerExpiration derives status, sorting, summary and header-based expiration hints", () => { const expired = providerExpiration.setExpiration( "conn-expired", diff --git a/tests/unit/error-classification.test.ts b/tests/unit/error-classification.test.ts index 9fb24d7181..0f6be74c08 100644 --- a/tests/unit/error-classification.test.ts +++ b/tests/unit/error-classification.test.ts @@ -6,7 +6,7 @@ const { checkFallbackError, getProviderProfile, parseRetryFromErrorText } = const { getProviderCategory } = await import("../../open-sse/config/providerRegistry.ts"); -const { COOLDOWN_MS, PROVIDER_PROFILES, RateLimitReason } = +const { BACKOFF_CONFIG, COOLDOWN_MS, PROVIDER_PROFILES, RateLimitReason } = await import("../../open-sse/config/constants.ts"); // ─── Provider Category Tests ──────────────────────────────────────────────── @@ -41,12 +41,36 @@ test("getProviderCategory: unknown provider defaults to 'apikey'", () => { test("getProviderProfile: OAuth provider returns oauth profile", () => { const profile = getProviderProfile("claude"); - assert.deepEqual(profile, PROVIDER_PROFILES.oauth); + assert.equal(profile.baseCooldownMs, PROVIDER_PROFILES.oauth.transientCooldown); + assert.equal(profile.useUpstreamRetryHints, false); + assert.equal(profile.maxBackoffSteps, PROVIDER_PROFILES.oauth.maxBackoffLevel); + assert.equal(profile.failureThreshold, PROVIDER_PROFILES.oauth.circuitBreakerThreshold); + assert.equal(profile.resetTimeoutMs, PROVIDER_PROFILES.oauth.circuitBreakerReset); + assert.equal(profile.transientCooldown, PROVIDER_PROFILES.oauth.transientCooldown); + assert.equal( + profile.rateLimitCooldown, + profile.useUpstreamRetryHints ? 0 : profile.baseCooldownMs + ); + assert.equal(profile.maxBackoffLevel, PROVIDER_PROFILES.oauth.maxBackoffLevel); + assert.equal(profile.circuitBreakerThreshold, PROVIDER_PROFILES.oauth.circuitBreakerThreshold); + assert.equal(profile.circuitBreakerReset, PROVIDER_PROFILES.oauth.circuitBreakerReset); }); test("getProviderProfile: API provider returns apikey profile", () => { const profile = getProviderProfile("groq"); - assert.deepEqual(profile, PROVIDER_PROFILES.apikey); + assert.equal(profile.baseCooldownMs, PROVIDER_PROFILES.apikey.transientCooldown); + assert.equal(profile.useUpstreamRetryHints, true); + assert.equal(profile.maxBackoffSteps, PROVIDER_PROFILES.apikey.maxBackoffLevel); + assert.equal(profile.failureThreshold, PROVIDER_PROFILES.apikey.circuitBreakerThreshold); + assert.equal(profile.resetTimeoutMs, PROVIDER_PROFILES.apikey.circuitBreakerReset); + assert.equal(profile.transientCooldown, PROVIDER_PROFILES.apikey.transientCooldown); + assert.equal( + profile.rateLimitCooldown, + profile.useUpstreamRetryHints ? 0 : profile.baseCooldownMs + ); + assert.equal(profile.maxBackoffLevel, PROVIDER_PROFILES.apikey.maxBackoffLevel); + assert.equal(profile.circuitBreakerThreshold, PROVIDER_PROFILES.apikey.circuitBreakerThreshold); + assert.equal(profile.circuitBreakerReset, PROVIDER_PROFILES.apikey.circuitBreakerReset); }); test("getProviderProfile: profiles have different thresholds", () => { @@ -64,7 +88,7 @@ test("getProviderProfile: profiles have different thresholds", () => { // ─── Exponential Backoff for Transient Errors ─────────────────────────────── -test("502 transient: exponential backoff 5s → 10s → 20s → 40s → 60s (capped)", () => { +test("502 transient: exponential backoff doubles until the configured max backoff step", () => { const cooldowns = []; for (let level = 0; level < 6; level++) { const result = checkFallbackError(502, "", level, null, null); @@ -73,14 +97,14 @@ test("502 transient: exponential backoff 5s → 10s → 20s → 40s → 60s (cap assert.equal(result.newBackoffLevel, level + 1); assert.equal(result.reason, RateLimitReason.SERVER_ERROR); } - // Without provider: uses COOLDOWN_MS.transientInitial (5s) as base - assert.equal(cooldowns[0], COOLDOWN_MS.transientInitial); // 5s - assert.equal(cooldowns[1], COOLDOWN_MS.transientInitial * 2); // 10s - assert.equal(cooldowns[2], COOLDOWN_MS.transientInitial * 4); // 20s - assert.equal(cooldowns[3], COOLDOWN_MS.transientInitial * 8); // 40s - // Level 4: 5s * 16 = 80s → capped at 60s - assert.equal(cooldowns[4], COOLDOWN_MS.transientMax); // 60s - assert.equal(cooldowns[5], COOLDOWN_MS.transientMax); // 60s (stays capped) + assert.deepEqual(cooldowns, [ + COOLDOWN_MS.transientInitial, + COOLDOWN_MS.transientInitial * 2, + COOLDOWN_MS.transientInitial * 4, + COOLDOWN_MS.transientInitial * 8, + COOLDOWN_MS.transientInitial * 16, + COOLDOWN_MS.transientInitial * 32, + ]); }); test("502 with OAuth provider: uses oauth profile transientCooldown", () => { @@ -116,11 +140,13 @@ test("429 rate limit: still uses quota-based exponential backoff", () => { assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED); }); -test("401 auth error: still uses flat cooldown, no backoff", () => { +test("401 auth error: returns terminal auth semantics without connection cooldown", () => { const result = checkFallbackError(401, "", 0, null, "groq"); assert.equal(result.shouldFallback, true); - assert.equal(result.cooldownMs, COOLDOWN_MS.unauthorized); + assert.equal(result.cooldownMs, 0); + assert.equal(result.baseCooldownMs, 0); assert.equal(result.newBackoffLevel, undefined); + assert.equal(result.reason, RateLimitReason.AUTH_ERROR); }); test("400 bad request: still returns shouldFallback false", () => { @@ -162,7 +188,7 @@ test("parseRetryFromErrorText: parses will reset after variant", () => { // ─── T06: Keyword Matching for Long Cooldowns ──────────────────────────────── -test("quota will reset keyword triggers long cooldown from body", () => { +test("quota reset text is ignored when upstream retry hints are disabled", () => { const result = checkFallbackError( 429, "Your quota will reset after 27h41m36s", @@ -172,19 +198,31 @@ test("quota will reset keyword triggers long cooldown from body", () => { null ); assert.equal(result.shouldFallback, true); - assert.ok(result.cooldownMs > 60_000, "cooldownMs should be > 60s"); - assert.equal(result.newBackoffLevel, 0, "backoffLevel should reset to 0"); + assert.equal(result.cooldownMs, PROVIDER_PROFILES.oauth.transientCooldown); + assert.equal(result.newBackoffLevel, 1); + assert.equal(result.usedUpstreamRetryHint, false); }); -test("exhausted your capacity keyword triggers long cooldown", () => { +test("quota reset text is honored when upstream retry hints are enabled", () => { const result = checkFallbackError( 429, "You have exhausted your capacity. Your quota will reset after 2h", 0, null, - "antigravity", + "groq", null ); assert.equal(result.shouldFallback, true); - assert.ok(result.cooldownMs > 60_000); + assert.equal(result.cooldownMs, 2 * 60 * 60 * 1000); + assert.equal(result.newBackoffLevel, 0); + assert.equal(result.usedUpstreamRetryHint, true); +}); + +test("high transient backoff levels clamp to the configured maxBackoffSteps", () => { + const result = checkFallbackError(502, "", BACKOFF_CONFIG.maxLevel + 5, null, null); + assert.equal(result.newBackoffLevel, BACKOFF_CONFIG.maxLevel); + assert.equal( + result.cooldownMs, + COOLDOWN_MS.transientInitial * Math.pow(2, BACKOFF_CONFIG.maxLevel) + ); }); diff --git a/tests/unit/rate-limit-manager.test.ts b/tests/unit/rate-limit-manager.test.ts index cb1aa96b6a..e61d523103 100644 --- a/tests/unit/rate-limit-manager.test.ts +++ b/tests/unit/rate-limit-manager.test.ts @@ -157,9 +157,14 @@ test("rate limit manager parses retry hints from response bodies and locks model "gpt-4o" ); - const lockout = accountFallback.getModelLockoutInfo("openai", "conn-body", "gpt-4o"); - assert.equal(lockout.reason, "rate_limit_exceeded"); - assert.ok(lockout.remainingMs > 0); + assert.equal(accountFallback.getModelLockoutInfo("openai", "conn-body", "gpt-4o"), null); + const limiterState = await rateLimitManager.__getLimiterStateForTests( + "openai", + "conn-body", + "gpt-4o" + ); + assert.equal(limiterState?.key, "openai:conn-body"); + assert.equal(rateLimitManager.getRateLimitStatus("openai", "conn-body").active, true); rateLimitManager.updateFromResponseBody( "openai", diff --git a/tests/unit/sse-auth.test.ts b/tests/unit/sse-auth.test.ts index 98b31f54c8..04d558a7c2 100644 --- a/tests/unit/sse-auth.test.ts +++ b/tests/unit/sse-auth.test.ts @@ -14,6 +14,7 @@ const settingsDb = await import("../../src/lib/db/settings.ts"); const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); const auth = await import("../../src/sse/services/auth.ts"); const quotaCache = await import("../../src/domain/quotaCache.ts"); +const { COOLDOWN_MS } = await import("../../open-sse/config/constants.ts"); async function resetStorage() { core.resetDbInstance(); @@ -760,7 +761,7 @@ test("getProviderCredentials exposes copilotToken when present in providerSpecif assert.equal(selected.copilotToken, "copilot-token-value"); }); -test("markAccountUnavailable uses configured cooldowns for local 404 model lockouts", async () => { +test("markAccountUnavailable keeps local 404 failures model-scoped with the local not-found cooldown", async () => { await settingsDb.updateSettings({ providerProfiles: { apikey: { @@ -790,7 +791,7 @@ test("markAccountUnavailable uses configured cooldowns for local 404 model locko const updated = await providersDb.getProviderConnectionById(connection.id); assert.equal(result.shouldFallback, true); - assert.equal(result.cooldownMs, 250); + assert.equal(result.cooldownMs, COOLDOWN_MS.notFoundLocal); assert.equal(updated.testStatus, "active"); assert.equal(updated.rateLimitedUntil, undefined); assert.equal(updated.lastErrorType, "not_found"); @@ -843,7 +844,7 @@ test("markAccountUnavailable applies a model-only lockout for compatible provide assert.equal(Number(updated.errorCode), 429); }); -test("markAccountUnavailable honors configured api-key rate-limit cooldowns", async () => { +test("markAccountUnavailable uses the unified configured api-key connection cooldown", async () => { await settingsDb.updateSettings({ providerProfiles: { apikey: { @@ -869,7 +870,7 @@ test("markAccountUnavailable honors configured api-key rate-limit cooldowns", as ); assert.equal(result.shouldFallback, true); - assert.equal(result.cooldownMs, 125); + assert.equal(result.cooldownMs, 200); }); test("markAccountUnavailable stores Codex scope-specific cooldowns without a global rate limit", async () => { @@ -1021,7 +1022,7 @@ test("markAccountUnavailable auto-disables permanently banned accounts when the assert.equal(result.shouldFallback, true); assert.equal(updated.isActive, false); - assert.equal(updated.testStatus, "unavailable"); + assert.equal(updated.testStatus, "banned"); }); test("markAccountUnavailable leaves permanently banned accounts active when auto-disable is disabled", async () => { @@ -1041,7 +1042,7 @@ test("markAccountUnavailable leaves permanently banned accounts active when auto assert.equal(result.shouldFallback, true); assert.equal(updated.isActive, true); - assert.equal(updated.testStatus, "unavailable"); + assert.equal(updated.testStatus, "banned"); }); test("markAccountUnavailable swallows auto-disable persistence errors", async () => { @@ -1085,7 +1086,7 @@ test("markAccountUnavailable swallows auto-disable persistence errors", async () assert.equal(result.shouldFallback, true); assert.equal(updated.isActive, true); - assert.equal(updated.testStatus, "unavailable"); + assert.equal(updated.testStatus, "banned"); } finally { db.prepare = originalPrepare; } diff --git a/tests/unit/thundering-herd.test.ts b/tests/unit/thundering-herd.test.ts index aa1c28b5c2..e3af4f81f1 100644 --- a/tests/unit/thundering-herd.test.ts +++ b/tests/unit/thundering-herd.test.ts @@ -9,7 +9,7 @@ import assert from "node:assert/strict"; const { checkFallbackError, getProviderProfile } = await import("../../open-sse/services/accountFallback.ts"); -const { PROVIDER_PROFILES, DEFAULT_API_LIMITS, COOLDOWN_MS, RateLimitReason } = +const { BACKOFF_CONFIG, PROVIDER_PROFILES, DEFAULT_API_LIMITS, COOLDOWN_MS, RateLimitReason } = await import("../../open-sse/config/constants.ts"); const { getProviderCategory } = await import("../../open-sse/config/providerRegistry.ts"); @@ -36,10 +36,13 @@ test("API profile has shorter transient cooldown", () => { // ─── Backoff Ceiling Tests (prevents infinite growth) ─────────────────────── -test("Exponential backoff is capped at transientMax for high backoff levels", () => { - // Level 20 → 5s * 2^20 = 5.2M ms, but capped at 60s +test("Exponential backoff clamps to the configured maxBackoffLevel", () => { const result = checkFallbackError(502, "", 20, null, null); - assert.equal(result.cooldownMs, COOLDOWN_MS.transientMax); + assert.equal(result.newBackoffLevel, BACKOFF_CONFIG.maxLevel); + assert.equal( + result.cooldownMs, + COOLDOWN_MS.transientInitial * Math.pow(2, BACKOFF_CONFIG.maxLevel) + ); }); test("API provider backoff level caps at profile maxBackoffLevel", () => { From 1d3d99bb9efe12e43a96e6acb545475eee1d6db4 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 21 Apr 2026 04:36:16 -0300 Subject: [PATCH 011/281] fix(combo): resolve cross-provider thinking 400 and http clipboard (#1444) Integrated into release/v3.7.0 --- open-sse/translator/helpers/claudeHelper.ts | 8 +++++++- src/shared/utils/clipboard.ts | 11 ++++------- tests/unit/translator-helper-branches.test.ts | 3 ++- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/open-sse/translator/helpers/claudeHelper.ts b/open-sse/translator/helpers/claudeHelper.ts index 2936b60240..bf4e589d0f 100644 --- a/open-sse/translator/helpers/claudeHelper.ts +++ b/open-sse/translator/helpers/claudeHelper.ts @@ -247,10 +247,16 @@ export function prepareClaudeRequest( let hasToolUse = false; let hasThinking = false; - // Always replace signature for all thinking blocks + // Convert thinking blocks to redacted_thinking and replace signature. + // When requests cross provider boundaries (e.g., combo fallback), the + // original thinking signature is invalid for the new provider, causing + // "Invalid signature in thinking block" 400 errors. redacted_thinking + // blocks are accepted without signature validation. for (const block of content) { if (block.type === "thinking" || block.type === "redacted_thinking") { + block.type = "redacted_thinking"; block.signature = DEFAULT_THINKING_CLAUDE_SIGNATURE; + delete block.thinking; hasThinking = true; } if (block.type === "tool_use") hasToolUse = true; diff --git a/src/shared/utils/clipboard.ts b/src/shared/utils/clipboard.ts index ee6d014dfb..50f6671dc9 100644 --- a/src/shared/utils/clipboard.ts +++ b/src/shared/utils/clipboard.ts @@ -11,13 +11,10 @@ * @returns true if copy succeeded, false otherwise */ export async function copyToClipboard(text: string): Promise { - // Method 1: Clipboard API (requires HTTPS / secure context) - if ( - typeof navigator !== "undefined" && - navigator.clipboard && - typeof window !== "undefined" && - window.isSecureContext - ) { + // Method 1: Clipboard API + // Works on HTTPS, localhost (treated as secure context), and some browsers + // even on HTTP. Try unconditionally — the catch handles failures. + if (typeof navigator !== "undefined" && navigator.clipboard) { try { await navigator.clipboard.writeText(text); return true; diff --git a/tests/unit/translator-helper-branches.test.ts b/tests/unit/translator-helper-branches.test.ts index e31c393352..33a59a0302 100644 --- a/tests/unit/translator-helper-branches.test.ts +++ b/tests/unit/translator-helper-branches.test.ts @@ -306,9 +306,10 @@ test("claudeHelper validates content, ordering and request preparation branches" assert.equal(prepared.messages[4].content[0].type, "tool_result"); assert.deepEqual( prepared.messages[5].content.map((block) => block.type), - ["thinking", "text"] + ["redacted_thinking", "text"] ); assert.ok(prepared.messages[5].content[0].signature); + assert.equal(prepared.messages[5].content[0].thinking, undefined); assert.equal(prepared.tools.length, 2); assert.equal(prepared.tools[0].cache_control, undefined); assert.deepEqual(prepared.tools[1].cache_control, { type: "ephemeral", ttl: "1h" }); From 20f04574e57ce13d990237e99a65853973eaa1e3 Mon Sep 17 00:00:00 2001 From: Paijo <14921983+oyi77@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:48:32 +0700 Subject: [PATCH 012/281] fix: resolve skills, memory, and encryption system issues (#1456) Integrated into release/v3.7.0 --- open-sse/config/credentialLoader.ts | 29 +++++++--------- open-sse/services/autoCombo/persistence.ts | 2 +- package-lock.json | 1 + src/app/api/skills/marketplace/route.ts | 22 +++++++++++- src/lib/db/encryption.ts | 12 ++++++- .../db/encryption-error-handling.test.mjs | 34 +++++++++++++++++++ 6 files changed, 80 insertions(+), 20 deletions(-) create mode 100644 tests/unit/db/encryption-error-handling.test.mjs diff --git a/open-sse/config/credentialLoader.ts b/open-sse/config/credentialLoader.ts index cc740a48ae..2350abce21 100644 --- a/open-sse/config/credentialLoader.ts +++ b/open-sse/config/credentialLoader.ts @@ -16,7 +16,6 @@ import { readFileSync, existsSync } from "fs"; import { join } from "path"; -import { resolveDataDir } from "../../src/lib/dataPaths"; // Fields that can be overridden per provider const CREDENTIAL_FIELDS = [ @@ -41,27 +40,23 @@ function credGlobals(): CredGlobals { return globalThis as CredGlobals; } -/** - * Resolves the path to provider-credentials.json using the application's - * data directory. Delegates to resolveDataDir() which handles DATA_DIR env, - * platform-specific defaults, and fallback logic. - * - * previous: Priority: DATA_DIR env → ./data (project root) - */ function resolveCredentialsPath(): string { + let resolveDataDir: (options?: { isCloud?: boolean }) => string; + + try { + resolveDataDir = require("@/lib/dataPaths").resolveDataDir; + } catch (err) { + const fallbackDataDir = process.env.DATA_DIR || join(process.cwd(), "data"); + console.warn( + `[CREDENTIALS] Could not load dataPaths module, using fallback: ${fallbackDataDir}` + ); + return join(fallbackDataDir, "provider-credentials.json"); + } + return join(resolveDataDir(), "provider-credentials.json"); } -/** - * Load and merge external credentials into the PROVIDERS object. - * Uses TTL-based caching (60s) so credential file changes are picked up - * without requiring a server restart. - * - * @param {object} providers - The PROVIDERS object from constants.js - * @returns {object} The same PROVIDERS object (mutated in place) - */ export function loadProviderCredentials>(providers: T): T { - // Return cached result if within TTL if (cachedProviders && Date.now() - lastLoadTime < CONFIG_TTL_MS) { return cachedProviders as T; } diff --git a/open-sse/services/autoCombo/persistence.ts b/open-sse/services/autoCombo/persistence.ts index 7dae3521a9..e445ed5766 100644 --- a/open-sse/services/autoCombo/persistence.ts +++ b/open-sse/services/autoCombo/persistence.ts @@ -7,7 +7,7 @@ import fs from "fs"; import path from "path"; -import { resolveDataDir } from "../../../src/lib/dataPaths"; +import { resolveDataDir } from "@/lib/dataPaths"; export interface AdaptationState { comboId: string; diff --git a/package-lock.json b/package-lock.json index 5da6f07344..85fda8cc74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11187,6 +11187,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/src/app/api/skills/marketplace/route.ts b/src/app/api/skills/marketplace/route.ts index 4c623e3a45..e0ae1fa146 100644 --- a/src/app/api/skills/marketplace/route.ts +++ b/src/app/api/skills/marketplace/route.ts @@ -1,6 +1,12 @@ import { NextResponse } from "next/server"; import { getSettings } from "@/lib/db/settings"; import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { getSkillsProviderSetting } from "@/lib/skills/providerSettings"; + +const POPULAR_BY_PROVIDER = { + skillsmp: ["web-search", "file-reader", "sql-assistant", "devops-helper", "docs-assistant"], + skillssh: ["git", "terminal", "postgres", "kubernetes", "playwright"], +} as const; export async function GET(request: Request) { if (!(await isAuthenticated(request))) { @@ -8,7 +14,21 @@ export async function GET(request: Request) { } try { const { searchParams } = new URL(request.url); - const q = searchParams.get("q") || ""; + const q = searchParams.get("q")?.trim() || ""; + const provider = await getSkillsProviderSetting(); + + // Return popular skills when query is empty + if (!q) { + const popularList = POPULAR_BY_PROVIDER[provider]; + const skills = popularList.map((name) => ({ + name, + description: `Popular skill: ${name}`, + installCount: 0, + })); + return NextResponse.json({ skills }); + } + + // Search SkillsMP for non-empty queries const settings = await getSettings(); const apiKey = (settings as Record).skillsmpApiKey; diff --git a/src/lib/db/encryption.ts b/src/lib/db/encryption.ts index c26cd10624..bff219c1ef 100644 --- a/src/lib/db/encryption.ts +++ b/src/lib/db/encryption.ts @@ -129,7 +129,17 @@ export function decrypt(ciphertext: string | null | undefined): string | null | decipher.setAuthTag(authTag); let decrypted = decipher.update(encryptedHex, "hex", "utf8"); - decrypted += decipher.final("utf8"); + try { + decrypted += decipher.final("utf8"); + } catch (finalErr: unknown) { + const finalMessage = finalErr instanceof Error ? finalErr.message : String(finalErr); + console.error( + `[Encryption] Decryption final() failed: ${finalMessage}. ` + + `Ciphertext prefix: ${ciphertext.slice(0, 30)}... ` + + `Auth tag validation likely failed.` + ); + return ciphertext; + } return decrypted; } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); diff --git a/tests/unit/db/encryption-error-handling.test.mjs b/tests/unit/db/encryption-error-handling.test.mjs new file mode 100644 index 0000000000..e40e531ed7 --- /dev/null +++ b/tests/unit/db/encryption-error-handling.test.mjs @@ -0,0 +1,34 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { decrypt } from "../../../src/lib/db/encryption.ts"; + +test("decrypt() with invalid auth tag should not crash and return ciphertext", () => { + const invalidCiphertext = "enc:v1:0000:0000:0000"; + const result = decrypt(invalidCiphertext); + + assert.strictEqual(result, invalidCiphertext, "Should return ciphertext unchanged on error"); + assert.strictEqual(typeof result, "string", "Result should be a string"); +}); + +test("decrypt() with malformed ciphertext should not crash", () => { + const malformed = "enc:v1:invalid"; + const result = decrypt(malformed); + + assert.strictEqual(result, malformed, "Should return malformed ciphertext unchanged"); +}); + +test("decrypt() with null should return null", () => { + const result = decrypt(null); + assert.strictEqual(result, null, "Should return null for null input"); +}); + +test("decrypt() with undefined should return undefined", () => { + const result = decrypt(undefined); + assert.strictEqual(result, undefined, "Should return undefined for undefined input"); +}); + +test("decrypt() with non-encrypted string should return as-is", () => { + const plaintext = "this-is-not-encrypted"; + const result = decrypt(plaintext); + assert.strictEqual(result, plaintext, "Should return plaintext unchanged"); +}); From ceca26ae8780dbfe442e8ac44e88c150a4e523fe Mon Sep 17 00:00:00 2001 From: Paijo <14921983+oyi77@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:49:57 +0700 Subject: [PATCH 013/281] fix(encryption): return null on decryption failure to prevent sending encrypted tokens to providers (#1462) Integrated into release/v3.7.0 --- src/lib/db/encryption.ts | 9 ++++++--- tests/unit/db-encryption.test.ts | 8 +++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/lib/db/encryption.ts b/src/lib/db/encryption.ts index bff219c1ef..b491a09a47 100644 --- a/src/lib/db/encryption.ts +++ b/src/lib/db/encryption.ts @@ -110,14 +110,16 @@ export function decrypt(ciphertext: string | null | undefined): string | null | console.warn( "[Encryption] Found encrypted data but STORAGE_ENCRYPTION_KEY is not set. Cannot decrypt." ); - return ciphertext; + // Return null instead of encrypted ciphertext to prevent sending encrypted tokens to providers + return null; } const body = ciphertext.slice(PREFIX.length); const parts = body.split(":"); if (parts.length !== 3) { console.error("[Encryption] Malformed encrypted value"); - return ciphertext; + // Return null instead of encrypted ciphertext to prevent sending malformed encrypted tokens to providers + return null; } const [ivHex, encryptedHex, authTagHex] = parts; @@ -144,7 +146,8 @@ export function decrypt(ciphertext: string | null | undefined): string | null | } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); console.error("[Encryption] Decryption failed:", message); - return ciphertext; + // Return null instead of encrypted ciphertext to prevent sending encrypted tokens to providers + return null; } } diff --git a/tests/unit/db-encryption.test.ts b/tests/unit/db-encryption.test.ts index 97b290f241..092743358f 100644 --- a/tests/unit/db-encryption.test.ts +++ b/tests/unit/db-encryption.test.ts @@ -66,7 +66,7 @@ test("connection field helpers encrypt and decrypt all supported credential fiel assert.deepEqual(decrypted, connection); }); -test("decrypt returns the original ciphertext when the value is malformed or the key is wrong", async () => { +test("decrypt returns null when the value is malformed or the key is wrong", async () => { process.env.STORAGE_ENCRYPTION_KEY = "task-304-secret-c"; const firstModule = await importFresh("src/lib/db/encryption.ts"); const encrypted = firstModule.encrypt("top-secret"); @@ -74,6 +74,8 @@ test("decrypt returns the original ciphertext when the value is malformed or the process.env.STORAGE_ENCRYPTION_KEY = "task-304-secret-d"; const secondModule = await importFresh("src/lib/db/encryption.ts"); - assert.equal(secondModule.decrypt(encrypted), encrypted); - assert.equal(secondModule.decrypt("enc:v1:not-valid"), "enc:v1:not-valid"); + // When decryption fails with wrong key, return null (not encrypted ciphertext) + // This prevents sending encrypted tokens to APIs + assert.equal(secondModule.decrypt(encrypted), null); + assert.equal(secondModule.decrypt("enc:v1:not-valid"), null); }); From 496114ae8b5ea959942952a1972f01c3ae34af68 Mon Sep 17 00:00:00 2001 From: Marcus Bearden Date: Tue, 21 Apr 2026 08:50:00 +0100 Subject: [PATCH 014/281] fix(sse): enable tool calling for GPT OSS and DeepSeek Reasoner models (#1455) Integrated into release/v3.7.0 --- open-sse/config/providerRegistry.ts | 6 +++--- src/lib/modelCapabilities.ts | 2 +- tests/unit/model-capabilities-registry.test.ts | 17 +++++++++++++++++ tests/unit/services-branch-hardening.test.ts | 4 ++-- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index ede6e0cdff..d797e21087 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -1199,9 +1199,9 @@ export const REGISTRY: Record = { authType: "apikey", authHeader: "bearer", models: [ - { id: "gpt-oss-120b", name: "GPT OSS 120B", toolCalling: false }, - { id: "openai/gpt-oss-120b", name: "GPT OSS 120B (OpenAI Prefix)", toolCalling: false }, - { id: "openai/gpt-oss-20b", name: "GPT OSS 20B", toolCalling: false }, + { id: "gpt-oss-120b", name: "GPT OSS 120B" }, + { id: "openai/gpt-oss-120b", name: "GPT OSS 120B (OpenAI Prefix)" }, + { id: "openai/gpt-oss-20b", name: "GPT OSS 20B" }, { id: "meta/llama-3.3-70b-instruct", name: "Llama 3.3 70B" }, { id: "nvidia/llama-3.3-70b-instruct", name: "Llama 3.3 70B (NVIDIA Prefix)" }, { id: "meta/llama-4-maverick-17b-128e-instruct", name: "Llama 4 Maverick" }, diff --git a/src/lib/modelCapabilities.ts b/src/lib/modelCapabilities.ts index 7ee0354550..45f1aa7c3d 100644 --- a/src/lib/modelCapabilities.ts +++ b/src/lib/modelCapabilities.ts @@ -6,7 +6,7 @@ import { parseModel, resolveCanonicalProviderModel } from "@omniroute/open-sse/s import { MODEL_SPECS, getModelSpec, type ModelSpec } from "@/shared/constants/modelSpecs"; import { getSyncedCapability } from "@/lib/modelsDevSync"; -const TOOL_CALLING_UNSUPPORTED_PATTERNS = ["gpt-oss-120b", "deepseek-reasoner"]; +const TOOL_CALLING_UNSUPPORTED_PATTERNS: string[] = []; const REASONING_UNSUPPORTED_PATTERNS = [ "antigravity/claude-sonnet-4-6", "antigravity/claude-sonnet-4-5", diff --git a/tests/unit/model-capabilities-registry.test.ts b/tests/unit/model-capabilities-registry.test.ts index d862cc54bc..1dbc1bd240 100644 --- a/tests/unit/model-capabilities-registry.test.ts +++ b/tests/unit/model-capabilities-registry.test.ts @@ -103,3 +103,20 @@ test("canonical model capability resolver merges models.dev data and keeps stati 32768 ); }); + +test("GPT OSS and DeepSeek Reasoner models support tool calling", () => { + // GPT OSS models should not be blocked by the heuristic + assert.equal(modelCapabilities.supportsToolCalling("nvidia/gpt-oss-120b"), true); + assert.equal(modelCapabilities.supportsToolCalling("gpt-oss-120b"), true); + assert.equal(modelCapabilities.supportsToolCalling("openai/gpt-oss-20b"), true); + + // DeepSeek Reasoner supports tool calling + assert.equal(modelCapabilities.supportsToolCalling("deepseek-reasoner"), true); + assert.equal(modelCapabilities.supportsToolCalling("deepseek/deepseek-r1"), true); + + // Full capability resolution + const gptOss = modelCapabilities.getResolvedModelCapabilities("nvidia/gpt-oss-120b"); + assert.equal(gptOss.toolCalling, true); + const deepseek = modelCapabilities.getResolvedModelCapabilities("deepseek/deepseek-reasoner"); + assert.equal(deepseek.toolCalling, true); +}); diff --git a/tests/unit/services-branch-hardening.test.ts b/tests/unit/services-branch-hardening.test.ts index 16467867d4..6adef33e1f 100644 --- a/tests/unit/services-branch-hardening.test.ts +++ b/tests/unit/services-branch-hardening.test.ts @@ -79,8 +79,8 @@ test("model capability helpers cover denylist, empty input and default-safe path assert.equal(modelCapabilities.supportsReasoning("missing-provider/tool"), true); assert.equal(modelCapabilities.supportsToolCalling(""), false); - assert.equal(modelCapabilities.supportsToolCalling("openai/gpt-oss-120b"), false); - assert.equal(modelCapabilities.supportsToolCalling("deepseek-reasoner"), false); + assert.equal(modelCapabilities.supportsToolCalling("openai/gpt-oss-120b"), true); + assert.equal(modelCapabilities.supportsToolCalling("deepseek-reasoner"), true); assert.equal( modelCapabilities.supportsToolCalling("openai/nonexistent-default-safe-model"), true From d19b8d83e83b28e6fdcfda91e6bc889b840e7d52 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 04:50:04 -0300 Subject: [PATCH 015/281] deps: bump the production group with 4 updates (#1463) Integrated into release/v3.7.0 --- package-lock.json | 117 +++++++++++++++++++++++++--------------------- package.json | 2 +- 2 files changed, 65 insertions(+), 54 deletions(-) diff --git a/package-lock.json b/package-lock.json index 85fda8cc74..4d58e6c758 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,7 @@ "selfsigned": "^5.5.0", "tsx": "^4.21.0", "undici": "^8.1.0", - "uuid": "^13.0.0", + "uuid": "^14.0.0", "wreq-js": "^2.0.1", "xxhash-wasm": "^1.1.0", "yazl": "^3.3.1", @@ -2635,6 +2635,20 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@lobehub/ui/node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "peer": true, + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/@mdx-js/mdx": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", @@ -2778,9 +2792,9 @@ } }, "node_modules/@next/env": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.3.tgz", - "integrity": "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz", + "integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -2794,9 +2808,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.3.tgz", - "integrity": "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz", + "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==", "cpu": [ "arm64" ], @@ -2810,9 +2824,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.3.tgz", - "integrity": "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz", + "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==", "cpu": [ "x64" ], @@ -2826,9 +2840,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.3.tgz", - "integrity": "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz", + "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==", "cpu": [ "arm64" ], @@ -2842,9 +2856,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.3.tgz", - "integrity": "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz", + "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==", "cpu": [ "arm64" ], @@ -2858,9 +2872,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.3.tgz", - "integrity": "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz", + "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==", "cpu": [ "x64" ], @@ -2874,9 +2888,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.3.tgz", - "integrity": "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz", + "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==", "cpu": [ "x64" ], @@ -2890,9 +2904,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.3.tgz", - "integrity": "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz", + "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==", "cpu": [ "arm64" ], @@ -2906,9 +2920,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.3.tgz", - "integrity": "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz", + "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==", "cpu": [ "x64" ], @@ -7799,9 +7813,9 @@ } }, "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.1.tgz", + "integrity": "sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", @@ -15520,12 +15534,12 @@ } }, "node_modules/next": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.3.tgz", - "integrity": "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz", + "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==", "license": "MIT", "dependencies": { - "@next/env": "16.2.3", + "@next/env": "16.2.4", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -15539,14 +15553,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.3", - "@next/swc-darwin-x64": "16.2.3", - "@next/swc-linux-arm64-gnu": "16.2.3", - "@next/swc-linux-arm64-musl": "16.2.3", - "@next/swc-linux-x64-gnu": "16.2.3", - "@next/swc-linux-x64-musl": "16.2.3", - "@next/swc-win32-arm64-msvc": "16.2.3", - "@next/swc-win32-x64-msvc": "16.2.3", + "@next/swc-darwin-arm64": "16.2.4", + "@next/swc-darwin-x64": "16.2.4", + "@next/swc-linux-arm64-gnu": "16.2.4", + "@next/swc-linux-arm64-musl": "16.2.4", + "@next/swc-linux-x64-gnu": "16.2.4", + "@next/swc-linux-x64-musl": "16.2.4", + "@next/swc-win32-arm64-msvc": "16.2.4", + "@next/swc-win32-x64-msvc": "16.2.4", "sharp": "^0.34.5" }, "peerDependencies": { @@ -20028,9 +20042,9 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -20680,9 +20694,9 @@ "license": "ISC" }, "node_modules/wreq-js": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/wreq-js/-/wreq-js-2.2.2.tgz", - "integrity": "sha512-iNcPyvVg14nWtHMzN595GDH1ELB1CDfVUV4s+AfSrP2go01/LYVBCkx4AdyMNAup4myQEiNBBmRI2Co2MKsFPQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/wreq-js/-/wreq-js-2.3.0.tgz", + "integrity": "sha512-jROBpTUhVH40qG+cAvBhdduKPGDSx55jK1dIDmVuu29sux9i8KY0u0wb5tHkMzXKDy9hrfHP+bpvaSq25rGwEA==", "cpu": [ "x64", "arm64" @@ -20692,10 +20706,7 @@ "darwin", "linux", "win32" - ], - "engines": { - "node": ">=20.0.0" - } + ] }, "node_modules/wsl-utils": { "version": "0.3.1", diff --git a/package.json b/package.json index fcc76e04a9..f192ec46da 100644 --- a/package.json +++ b/package.json @@ -135,7 +135,7 @@ "selfsigned": "^5.5.0", "tsx": "^4.21.0", "undici": "^8.1.0", - "uuid": "^13.0.0", + "uuid": "^14.0.0", "wreq-js": "^2.0.1", "xxhash-wasm": "^1.1.0", "yazl": "^3.3.1", From 9754b04bd659a04507247175c2b42d81dbb9c1dd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 04:50:07 -0300 Subject: [PATCH 016/281] deps: bump the development group with 4 updates (#1464) Integrated into release/v3.7.0 --- package-lock.json | 150 +++++++++++++++++++++++----------------------- package.json | 2 +- 2 files changed, 76 insertions(+), 76 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4d58e6c758..a05f478aa7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,7 +70,7 @@ "concurrently": "^9.2.1", "cross-env": "^10.1.0", "eslint": "^9.39.2", - "eslint-config-next": "16.2.3", + "eslint-config-next": "16.2.4", "husky": "^9.1.7", "jsdom": "^29.0.1", "lint-staged": "^16.2.7", @@ -2798,9 +2798,9 @@ "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.3.tgz", - "integrity": "sha512-nE/b9mht28XJxjTwKs/yk7w4XTaU3t40UHVAky6cjiijdP/SEy3hGsnQMPxmXPTpC7W4/97okm6fngKnvCqVaA==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.4.tgz", + "integrity": "sha512-tOX826JJ96gYK/go18sPUgMq9FK1tqxBFfUCEufJb5XIkWFFmpgU7mahJANKGkHs7F41ir3tReJ3Lv5La0RvhA==", "dev": true, "license": "MIT", "dependencies": { @@ -6551,17 +6551,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", - "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz", + "integrity": "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.58.2", - "@typescript-eslint/type-utils": "8.58.2", - "@typescript-eslint/utils": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/type-utils": "8.59.0", + "@typescript-eslint/utils": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -6574,7 +6574,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.58.2", + "@typescript-eslint/parser": "^8.59.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -6590,16 +6590,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz", - "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz", + "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.58.2", - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3" }, "engines": { @@ -6615,14 +6615,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", - "integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz", + "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.58.2", - "@typescript-eslint/types": "^8.58.2", + "@typescript-eslint/tsconfig-utils": "^8.59.0", + "@typescript-eslint/types": "^8.59.0", "debug": "^4.4.3" }, "engines": { @@ -6637,14 +6637,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz", - "integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", + "integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2" + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6655,9 +6655,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", - "integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz", + "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==", "dev": true, "license": "MIT", "engines": { @@ -6672,15 +6672,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", - "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz", + "integrity": "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2", - "@typescript-eslint/utils": "8.58.2", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/utils": "8.59.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -6697,9 +6697,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", - "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz", + "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==", "dev": true, "license": "MIT", "engines": { @@ -6711,16 +6711,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", - "integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz", + "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.58.2", - "@typescript-eslint/tsconfig-utils": "8.58.2", - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2", + "@typescript-eslint/project-service": "8.59.0", + "@typescript-eslint/tsconfig-utils": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -6791,16 +6791,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz", - "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz", + "integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.58.2", - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2" + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6815,13 +6815,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", - "integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", + "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/types": "8.59.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -10154,13 +10154,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.3.tgz", - "integrity": "sha512-Dnkrylzjof/Az7iNoIQJqD18zTxQZcngir19KJaiRsMnnjpQSVoa6aEg/1Q4hQC+cW90uTlgQYadwL1CYNwFWA==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.4.tgz", + "integrity": "sha512-A6ekXYFj/YQxBPMl45g3e+U8zJo+X2+ZQwcz34pPKjpc/3S4roBA2Rd9xWB4FKuSxhofo1/95WjzmUY+wHrOhg==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.2.3", + "@next/eslint-plugin-next": "16.2.4", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -16532,9 +16532,9 @@ } }, "node_modules/prettier": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz", - "integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", "bin": { @@ -19686,9 +19686,9 @@ } }, "node_modules/typescript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", - "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -19700,16 +19700,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.2.tgz", - "integrity": "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.0.tgz", + "integrity": "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.58.2", - "@typescript-eslint/parser": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2", - "@typescript-eslint/utils": "8.58.2" + "@typescript-eslint/eslint-plugin": "8.59.0", + "@typescript-eslint/parser": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/utils": "8.59.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" diff --git a/package.json b/package.json index f192ec46da..36ab8342b5 100644 --- a/package.json +++ b/package.json @@ -161,7 +161,7 @@ "concurrently": "^9.2.1", "cross-env": "^10.1.0", "eslint": "^9.39.2", - "eslint-config-next": "16.2.3", + "eslint-config-next": "16.2.4", "husky": "^9.1.7", "jsdom": "^29.0.1", "lint-staged": "^16.2.7", From 0c24ab45af2ec1e19ae73d5e6c29ea6683491d40 Mon Sep 17 00:00:00 2001 From: clousky Date: Mon, 20 Apr 2026 10:11:51 +0800 Subject: [PATCH 017/281] =?UTF-8?q?=E2=9C=85=20test(settings-api):=20add?= =?UTF-8?q?=20test=20harness=20for=20proper=20isolation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - create createSettingsApiHarness function with temp directory setup - add beforeEach/afterEach hooks for storage reset between tests - add after hook for cleanup - use dynamic imports after env setup to ensure proper initialization --- tests/unit/settings-api.test.ts | 86 +++++++++++++++++++++++++++------ 1 file changed, 70 insertions(+), 16 deletions(-) diff --git a/tests/unit/settings-api.test.ts b/tests/unit/settings-api.test.ts index 27f7160d4e..1767dcc794 100644 --- a/tests/unit/settings-api.test.ts +++ b/tests/unit/settings-api.test.ts @@ -1,34 +1,88 @@ -import { describe, test } from "node:test"; +import { describe, test, beforeEach, afterEach, after } from "node:test"; import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// --- Create harness function (similar to _chatPipelineHarness pattern) --- +async function createSettingsApiHarness() { + const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-settings-api-")); + process.env.DATA_DIR = testDataDir; + process.env.REQUIRE_API_KEY = "false"; + if (!process.env.API_KEY_SECRET) { + process.env.API_KEY_SECRET = "test-settings-api-secret-" + Date.now(); + } + + // --- Dynamic imports AFTER env setup --- + const core = await import("../../src/lib/db/core.ts"); + const { getSettings, updateSettings } = await import("../../src/lib/db/settings.ts"); + const settingsRoute = await import("../../src/app/api/settings/route.ts"); + + async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(testDataDir, { recursive: true, force: true }); + fs.mkdirSync(testDataDir, { recursive: true }); + } + + function cleanup() { + core.resetDbInstance(); + fs.rmSync(testDataDir, { recursive: true, force: true }); + } + + return { + testDataDir, + core, + getSettings, + updateSettings, + settingsRoute, + resetStorage, + cleanup, + }; +} + +// --- Initialize harness --- +const harness = await createSettingsApiHarness(); + +// --- Static import for helper (doesn't depend on DB) --- import { makeManagementSessionRequest } from "../helpers/managementSession.ts"; -import { getSettings, updateSettings } from "../../src/lib/db/settings.ts"; -const settingsRoute = await import("../../src/app/api/settings/route.ts"); + +beforeEach(async () => { + await harness.resetStorage(); +}); + +afterEach(async () => { + await harness.resetStorage(); +}); + +after(() => { + harness.cleanup(); +}); describe("Settings API - debugMode and hiddenSidebarItems", () => { describe("debugMode", () => { test("updateSettings with debugMode=true succeeds", async () => { - const result = await updateSettings({ debugMode: true }); + const result = await harness.updateSettings({ debugMode: true }); assert.ok(result, "updateSettings should return truthy result"); - const settings = await getSettings(); + const settings = await harness.getSettings(); assert.strictEqual(settings.debugMode, true, "debugMode should be true"); }); test("updateSettings with debugMode=false succeeds", async () => { - const result = await updateSettings({ debugMode: false }); + const result = await harness.updateSettings({ debugMode: false }); assert.ok(result, "updateSettings should return truthy result"); - const settings = await getSettings(); + const settings = await harness.getSettings(); assert.strictEqual(settings.debugMode, false, "debugMode should be false"); }); }); describe("hiddenSidebarItems", () => { test("updateSettings with hiddenSidebarItems=['translator'] succeeds", async () => { - const result = await updateSettings({ hiddenSidebarItems: ["translator"] }); + const result = await harness.updateSettings({ hiddenSidebarItems: ["translator"] }); assert.ok(result, "updateSettings should return truthy result"); - const settings = await getSettings(); + const settings = await harness.getSettings(); assert.deepStrictEqual( settings.hiddenSidebarItems, ["translator"], @@ -37,10 +91,10 @@ describe("Settings API - debugMode and hiddenSidebarItems", () => { }); test("updateSettings with empty hiddenSidebarItems succeeds", async () => { - const result = await updateSettings({ hiddenSidebarItems: [] }); + const result = await harness.updateSettings({ hiddenSidebarItems: [] }); assert.ok(result, "updateSettings should return truthy result"); - const settings = await getSettings(); + const settings = await harness.getSettings(); assert.deepStrictEqual( settings.hiddenSidebarItems, [], @@ -51,13 +105,13 @@ describe("Settings API - debugMode and hiddenSidebarItems", () => { describe("combined updates", () => { test("updateSettings with both debugMode and hiddenSidebarItems succeeds", async () => { - const result = await updateSettings({ + const result = await harness.updateSettings({ debugMode: true, hiddenSidebarItems: ["translator"], }); assert.ok(result, "updateSettings should return truthy result"); - const settings = await getSettings(); + const settings = await harness.getSettings(); assert.strictEqual(settings.debugMode, true, "debugMode should be true"); assert.deepStrictEqual( settings.hiddenSidebarItems, @@ -67,12 +121,12 @@ describe("Settings API - debugMode and hiddenSidebarItems", () => { }); test("updateSettings persists antigravitySignatureCacheMode", async () => { - const result = await updateSettings({ + const result = await harness.updateSettings({ antigravitySignatureCacheMode: "bypass-strict", }); assert.ok(result, "updateSettings should return truthy result"); - const settings = await getSettings(); + const settings = await harness.getSettings(); assert.strictEqual( settings.antigravitySignatureCacheMode, "bypass-strict", @@ -81,7 +135,7 @@ describe("Settings API - debugMode and hiddenSidebarItems", () => { }); test("PUT /api/settings reuses the PATCH update flow", async () => { - const response = await settingsRoute.PUT( + const response = await harness.settingsRoute.PUT( await makeManagementSessionRequest("http://localhost/api/settings", { method: "PUT", body: { antigravitySignatureCacheMode: "bypass" }, From 3478dea5debb2312b26d53b3e9a50c4ccd860d19 Mon Sep 17 00:00:00 2001 From: clousky Date: Mon, 20 Apr 2026 10:12:49 +0800 Subject: [PATCH 018/281] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(fallback)?= =?UTF-8?q?:=20make=20provider=20failure=20thresholds=20configurable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add provider-level circuit breaker config to PROVIDER_PROFILES - remove hardcoded threshold constants in favor of profile-based config - use getProviderProfile() to read thresholds with fallback defaults - support different failure tolerance per provider type --- open-sse/config/constants.ts | 12 +++++ open-sse/services/accountFallback.ts | 81 ++++++++++++++++++++++++---- 2 files changed, 83 insertions(+), 10 deletions(-) diff --git a/open-sse/config/constants.ts b/open-sse/config/constants.ts index be34098c09..ca428764f6 100644 --- a/open-sse/config/constants.ts +++ b/open-sse/config/constants.ts @@ -170,6 +170,10 @@ export const PROVIDER_PROFILES = { maxBackoffLevel: 8, // Higher ceiling (sessions may stay bad longer) circuitBreakerThreshold: 3, // Opens fast (low limit providers) circuitBreakerReset: 60000, // 1min reset + // Provider-level circuit breaker (entire provider cooldown after repeated failures) + providerFailureThreshold: 3, // 3 transient failures trigger provider cooldown + providerFailureWindowMs: 600000, // 10min window for counting failures + providerCooldownMs: 300000, // 5min cooldown when threshold reached }, apikey: { transientCooldown: 3000, // 3s (API providers recover faster) @@ -177,6 +181,10 @@ export const PROVIDER_PROFILES = { maxBackoffLevel: 5, // Lower ceiling (API quotas reset at known intervals) circuitBreakerThreshold: 5, // More tolerant (occasional 502 is normal) circuitBreakerReset: 30000, // 30s reset + // Provider-level circuit breaker (entire provider cooldown after repeated failures) + providerFailureThreshold: 5, // 5 transient failures trigger provider cooldown + providerFailureWindowMs: 1200000, // 20min window for counting failures + providerCooldownMs: 600000, // 10min cooldown when threshold reached }, // Local providers (localhost inference backends like Ollama, LM Studio, oMLX). // Not yet wired into getProviderProfile() — will be used when local provider_nodes @@ -187,6 +195,10 @@ export const PROVIDER_PROFILES = { maxBackoffLevel: 3, // Low ceiling (local either works or doesn't) circuitBreakerThreshold: 2, // Opens fast (if local is down, it's down) circuitBreakerReset: 15000, // 15s reset (check again quickly) + // Provider-level circuit breaker (entire provider cooldown after repeated failures) + providerFailureThreshold: 2, // 2 failures trigger provider cooldown + providerFailureWindowMs: 300000, // 5min window for counting failures + providerCooldownMs: 60000, // 1min cooldown when threshold reached }, }; diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index d14a3122f0..749a5e9bf6 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -43,10 +43,24 @@ type ModelFailureState = { resetAfterMs: number; }; -// Error codes that count toward provider-level failure threshold. -// Connection-scoped 429 rate limits stay in connection cooldown handling and -// do not contribute to the shared provider breaker. -const PROVIDER_FAILURE_ERROR_CODES = new Set([408, 500, 502, 503, 504]); +// Provider-level failure tracking for circuit breaker behavior +type ProviderFailureEntry = { + failureCount: number; + lastFailureAt: number; + resetAfterMs: number; + cooldownUntil: number | null; +}; + +// Error codes that count toward provider-level failure threshold +const PROVIDER_FAILURE_ERROR_CODES = new Set([429, 408, 500, 502, 503, 504]); + +// Provider-level failure state map: providerId -> failure entry +const providerFailureState = new Map(); +// Guard against synchronous re-entrant calls within the same event-loop tick. +// NOT a true mutex — Node.js is single-threaded, so different SSE streams +// can interleave across ticks. This Set prevents a single call from recursively +// re-entering recordProviderFailure within the same synchronous call stack. +const providerFailureLocks = new Set(); // T06 (sub2api PR #1037): Signals that indicate permanent account deactivation. // When a 401 body contains these strings, the account is permanently dead @@ -511,12 +525,59 @@ export function recordProviderFailure( provider: string | null | undefined, log?: { warn?: (...args: unknown[]) => void } ): void { - const breaker = getProviderBreaker(provider); - if (!breaker || !provider) return; - breaker._onFailure(); - const status = breaker.getStatus(); - if (status.state === STATE.OPEN) { - log?.warn?.(`[ProviderBreaker] ${provider}: OPEN after ${status.failureCount} final failures`); + if (!provider) return; + + // Guard against concurrent re-entrant calls within the same tick + if (providerFailureLocks.has(provider)) return; + providerFailureLocks.add(provider); + + try { + const now = Date.now(); + const entry = providerFailureState.get(provider); + + // Check if we're in cooldown period + if (entry && entry.cooldownUntil !== null && now < entry.cooldownUntil) { + return; // Already in cooldown, don't record + } + + // Check if failure window has expired + if (entry && now - entry.lastFailureAt > entry.resetAfterMs) { + // Window expired, reset count + providerFailureState.set(provider, { + failureCount: 1, + lastFailureAt: now, + resetAfterMs: PROVIDER_FAILURE_WINDOW_MS, + cooldownUntil: null, + }); + return; + } + + // Increment failure count + const newCount = entry ? entry.failureCount + 1 : 1; + + if (newCount >= PROVIDER_FAILURE_THRESHOLD) { + // Threshold reached, enter cooldown + const cooldownUntil = now + PROVIDER_COOLDOWN_MS; + providerFailureState.set(provider, { + failureCount: newCount, + lastFailureAt: now, + resetAfterMs: PROVIDER_FAILURE_WINDOW_MS, + cooldownUntil, + }); + log?.warn?.( + `[ProviderFailure] ${provider}: ${newCount} failures in ${PROVIDER_FAILURE_WINDOW_MS / 1000}s — entering ${PROVIDER_COOLDOWN_MS / 1000}s cooldown` + ); + } else { + // Just increment counter + providerFailureState.set(provider, { + failureCount: newCount, + lastFailureAt: now, + resetAfterMs: PROVIDER_FAILURE_WINDOW_MS, + cooldownUntil: null, + }); + } + } finally { + providerFailureLocks.delete(provider); } } From a389f2699afafa4198ca6b3e0fdba04406ea7eae Mon Sep 17 00:00:00 2001 From: clousky Date: Mon, 20 Apr 2026 12:57:27 +0800 Subject: [PATCH 019/281] fix(fallback): merge new provider failure threshold fields in profile The mergeProviderProfile function was missing the three new fields added to PROVIDER_PROFILES (providerFailureThreshold, providerFailureWindowMs, providerCooldownMs). This caused tests to fail because the profile returned by getRuntimeProviderProfile did not include these fields. Co-Authored-By: Claude Opus 4.6 --- open-sse/services/accountFallback.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 749a5e9bf6..fc9c45d1da 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -2,6 +2,7 @@ import { COOLDOWN_MS, BACKOFF_CONFIG, BACKOFF_STEPS_MS, + PROVIDER_PROFILES, RateLimitReason, HTTP_STATUS, } from "../config/constants.ts"; @@ -27,6 +28,10 @@ type ProviderProfile = { maxBackoffLevel: number; circuitBreakerThreshold: number; circuitBreakerReset: number; + // Provider-level circuit breaker fields + providerFailureThreshold: number; + providerFailureWindowMs: number; + providerCooldownMs: number; }; type JsonRecord = Record; type ModelLockoutEntry = { @@ -187,6 +192,10 @@ function buildProviderProfile( maxBackoffLevel: connectionCooldown.maxBackoffSteps, circuitBreakerThreshold: providerBreaker.failureThreshold, circuitBreakerReset: providerBreaker.resetTimeoutMs, + // Provider-level circuit breaker fields (not configurable via settings, use PROVIDER_PROFILES defaults) + providerFailureThreshold: PROVIDER_PROFILES[category].providerFailureThreshold, + providerFailureWindowMs: PROVIDER_PROFILES[category].providerFailureWindowMs, + providerCooldownMs: PROVIDER_PROFILES[category].providerCooldownMs, } satisfies ProviderProfile; } From 0d86244c90346b43573b194843ffc54ced222a3f Mon Sep 17 00:00:00 2001 From: clousky Date: Mon, 20 Apr 2026 13:25:37 +0800 Subject: [PATCH 020/281] fix: address PR review comments 1. Remove 429 from PROVIDER_FAILURE_ERROR_CODES - 429 (rate limit) is already handled by model-level and account-level locks - Including it in provider-wide circuit breaker causes premature cooldown 2. Fix reference counting in ModelStatusContext - Changed registeredModels from Set to Map - Prevents polling stop when one component unmounts while others still track the model 3. Fix model ID parsing for providers with slashes in model names - Use indexOf/substring instead of split to handle models like "modelscope/moonshotai/Kimi-K2.5" Co-Authored-By: Claude Opus 4.6 --- open-sse/services/accountFallback.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index fc9c45d1da..fcaecd09f6 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -57,7 +57,7 @@ type ProviderFailureEntry = { }; // Error codes that count toward provider-level failure threshold -const PROVIDER_FAILURE_ERROR_CODES = new Set([429, 408, 500, 502, 503, 504]); +const PROVIDER_FAILURE_ERROR_CODES = new Set([408, 500, 502, 503, 504]); // Provider-level failure state map: providerId -> failure entry const providerFailureState = new Map(); From 3834768b720ff1a0138a74dd8e3de9cc19ca721c Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 21 Apr 2026 08:27:48 -0300 Subject: [PATCH 021/281] feat(providers): add lm studio and grok 4.3 support Register LM Studio as an OpenAI-compatible local provider and map the new grok-4.3 thinking model for web executor requests. This update also hardens related platform behavior by switching backup archive creation to execFileSync, validating ACP agent ids, expanding shared CORS handling, and making prompt injection guard failures return an explicit 500 response. To preserve existing stored credentials, encryption now derives new keys from a secret-based salt while still falling back to the legacy static-salt key during decryption. --- open-sse/config/providerRegistry.ts | 10 +++++ open-sse/executors/grok-web.ts | 5 +++ src/app/api/db-backups/exportAll/route.ts | 4 +- src/app/api/v1/chat/completions/route.ts | 10 +---- src/lib/acp/manager.ts | 5 +++ src/lib/db/encryption.ts | 48 +++++++++++++++++++++-- src/middleware/promptInjectionGuard.ts | 8 +++- src/shared/constants/providers.ts | 9 +++++ src/shared/utils/cors.ts | 3 +- 9 files changed, 85 insertions(+), 17 deletions(-) diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index ede6e0cdff..a3bce6d173 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -1151,6 +1151,16 @@ export const REGISTRY: Record = { { id: "qwen-3-32b", name: "Qwen3 32B" }, ], }, + "lm-studio": { + id: "lm-studio", + alias: "lmstudio", + format: "openai", + executor: "default", + baseUrl: "http://localhost:1234/v1", + authType: "apikey", // Some setups may use proxy with api key + authHeader: "bearer", + models: [], // Usually dynamic based on local models downloaded + }, "ollama-cloud": { id: "ollama-cloud", diff --git a/open-sse/executors/grok-web.ts b/open-sse/executors/grok-web.ts index 99996ee976..a87c51dc86 100644 --- a/open-sse/executors/grok-web.ts +++ b/open-sse/executors/grok-web.ts @@ -58,6 +58,11 @@ const MODEL_MAP: Record = { modelMode: "MODEL_MODE_GROK_4_THINKING", isThinking: true, }, + "grok-4.3": { + grokModel: "grok-4-3-thinking-1129", + modelMode: "MODEL_MODE_GROK_4_3_THINKING", + isThinking: true, + }, "grok-4-heavy": { grokModel: "grok-4", modelMode: "MODEL_MODE_HEAVY", isThinking: true }, "grok-4.1-mini": { grokModel: "grok-4-1-thinking-1129", diff --git a/src/app/api/db-backups/exportAll/route.ts b/src/app/api/db-backups/exportAll/route.ts index 4cb2e75144..5b6343c541 100644 --- a/src/app/api/db-backups/exportAll/route.ts +++ b/src/app/api/db-backups/exportAll/route.ts @@ -4,7 +4,7 @@ import { CALL_LOGS_DIR } from "@/lib/usage/callLogArtifacts"; import fs from "fs"; import path from "path"; import os from "os"; -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import { isAuthenticated } from "@/shared/utils/apiAuth"; /** @@ -115,7 +115,7 @@ export async function GET(request: NextRequest) { // Create ZIP using tar (available on all Linux/macOS, and the archiver npm package is not installed) // We'll use Node.js built-in zlib to create a simple tar.gz instead const tarPath = zipPath.replace(".zip", ".tar.gz"); - execSync(`tar -czf "${tarPath}" -C "${path.dirname(tempDir)}" "${path.basename(tempDir)}"`, { + execFileSync("tar", ["-czf", tarPath, "-C", path.dirname(tempDir), path.basename(tempDir)], { timeout: 30000, }); diff --git a/src/app/api/v1/chat/completions/route.ts b/src/app/api/v1/chat/completions/route.ts index e478e167d1..65bc9cf056 100644 --- a/src/app/api/v1/chat/completions/route.ts +++ b/src/app/api/v1/chat/completions/route.ts @@ -1,4 +1,4 @@ -import { CORS_ORIGIN, CORS_HEADERS } from "@/shared/utils/cors"; +import { CORS_ORIGIN, CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; import { callCloudWithMachineId } from "@/shared/utils/cloud"; import { handleChat } from "@/sse/handlers/chat"; import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; @@ -25,13 +25,7 @@ function ensureInitialized() { * Handle CORS preflight */ export async function OPTIONS() { - return new Response(null, { - headers: { - "Access-Control-Allow-Origin": CORS_ORIGIN, - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "*", - }, - }); + return handleCorsOptions(); } export async function POST(request) { diff --git a/src/lib/acp/manager.ts b/src/lib/acp/manager.ts index fb58ee13eb..055fc1e222 100644 --- a/src/lib/acp/manager.ts +++ b/src/lib/acp/manager.ts @@ -47,6 +47,11 @@ export class AcpManager extends EventEmitter { args: string[] = [], env: Record = {} ): AcpSession { + const ALLOWED_AGENTS = ["claude", "codex", "gemini", "qwen"]; + if (!ALLOWED_AGENTS.includes(agentId)) { + throw new Error(`Unknown agent: ${agentId}`); + } + const sessionId = `acp-${agentId}-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`; const child = spawn(binary, args, { diff --git a/src/lib/db/encryption.ts b/src/lib/db/encryption.ts index c26cd10624..07ab9db803 100644 --- a/src/lib/db/encryption.ts +++ b/src/lib/db/encryption.ts @@ -8,7 +8,7 @@ * (stores plaintext for development convenience). */ -import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto"; +import { createCipheriv, createDecipheriv, randomBytes, scryptSync, createHash } from "crypto"; const ALGORITHM = "aes-256-gcm"; const IV_LENGTH = 16; @@ -16,6 +16,7 @@ const KEY_LENGTH = 32; const PREFIX = "enc:v1:"; let _derivedKey: Buffer | null = null; +let _legacyDerivedKey: Buffer | null = null; /** Connection object with potentially encrypted credential fields. */ export interface ConnectionFields { @@ -44,8 +45,8 @@ function getKey(): Buffer | null { return null; } - // Fixed salt derived from app name — deterministic so same key always produces same derived key - const salt = "omniroute-field-encryption-v1"; + // Dynamic salt derived from key hash to prevent rainbow table attacks, while remaining deterministic + const salt = createHash("sha256").update(secret).digest().slice(0, 16); try { _derivedKey = scryptSync(secret, salt, KEY_LENGTH); } catch (err: unknown) { @@ -59,6 +60,25 @@ function getKey(): Buffer | null { return _derivedKey; } +/** + * Derive legacy 256-bit key from the env secret using the old static salt. + * Used exclusively for fallback decryption. + */ +function getLegacyKey(): Buffer | null { + if (_legacyDerivedKey !== null) return _legacyDerivedKey; + + const secret = process.env.STORAGE_ENCRYPTION_KEY; + if (!secret || typeof secret !== "string" || secret.trim().length === 0) return null; + + const legacySalt = "omniroute-field-encryption-v1"; + try { + _legacyDerivedKey = scryptSync(secret, legacySalt, KEY_LENGTH); + } catch { + return null; + } + return _legacyDerivedKey; +} + /** Check if encryption is enabled. */ export function isEncryptionEnabled(): boolean { return !!process.env.STORAGE_ENCRYPTION_KEY; @@ -72,7 +92,12 @@ export function encrypt(plaintext: string | null | undefined): string | null | u if (!plaintext || typeof plaintext !== "string") return plaintext; const key = getKey(); - if (!key) return plaintext; // passthrough mode + if (!key) { + console.warn( + "[Encryption] STORAGE_ENCRYPTION_KEY not set. Storing plaintext (passthrough mode)." + ); + return plaintext; // passthrough mode + } // Already encrypted — don't double-encrypt if (plaintext.startsWith(PREFIX)) return plaintext; @@ -132,6 +157,21 @@ export function decrypt(ciphertext: string | null | undefined): string | null | decrypted += decipher.final("utf8"); return decrypted; } catch (err: unknown) { + const legacyKey = getLegacyKey(); + if (legacyKey) { + try { + const iv = Buffer.from(ivHex, "hex"); + const authTag = Buffer.from(authTagHex, "hex"); + const legacyDecipher = createDecipheriv(ALGORITHM, legacyKey, iv); + legacyDecipher.setAuthTag(authTag); + + let legacyDecrypted = legacyDecipher.update(encryptedHex, "hex", "utf8"); + legacyDecrypted += legacyDecipher.final("utf8"); + return legacyDecrypted; + } catch (legacyErr) { + // Fallback failed as well + } + } const message = err instanceof Error ? err.message : String(err); console.error("[Encryption] Decryption failed:", message); return ciphertext; diff --git a/src/middleware/promptInjectionGuard.ts b/src/middleware/promptInjectionGuard.ts index 6aaef72e41..74543d9a2e 100644 --- a/src/middleware/promptInjectionGuard.ts +++ b/src/middleware/promptInjectionGuard.ts @@ -84,8 +84,12 @@ export function withInjectionGuard(handler: any, options: any = {}) { request.headers.set("X-Injection-Detections", String(result.detections.length)); } } - } catch { - // Don't block on guard errors — fail open + } catch (error) { + console.error("[SECURITY] Injection guard error:", error); + return new Response(JSON.stringify({ error: "Security check failed" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); } return handler(request, context); diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 535477becb..26afcc2ebc 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -364,6 +364,15 @@ export const APIKEY_PROVIDERS = { textIcon: "NB", website: "https://nanobananaapi.ai", }, + "lm-studio": { + id: "lm-studio", + alias: "lmstudio", + name: "LM Studio", + icon: "server", + color: "#4A148C", + textIcon: "LM", + website: "https://lmstudio.ai", + }, "ollama-cloud": { id: "ollama-cloud", alias: "ollamacloud", diff --git a/src/shared/utils/cors.ts b/src/shared/utils/cors.ts index 6e045c767d..848e4ae8d5 100644 --- a/src/shared/utils/cors.ts +++ b/src/shared/utils/cors.ts @@ -24,7 +24,8 @@ export const CORS_ORIGIN = process.env.CORS_ORIGIN || "*"; export const CORS_HEADERS = { "Access-Control-Allow-Origin": CORS_ORIGIN, "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, x-api-key, anthropic-version", + "Access-Control-Allow-Headers": + "Content-Type, Authorization, x-api-key, anthropic-version, x-omniroute-connection, x-internal-test, accept", }; /** From f651a27418c8ddcc8cfa1f2784ae17ce70931b5a Mon Sep 17 00:00:00 2001 From: clousky Date: Tue, 21 Apr 2026 19:31:46 +0800 Subject: [PATCH 022/281] =?UTF-8?q?=F0=9F=90=9B=20fix(providers):=20add=20?= =?UTF-8?q?optional=20chaining=20to=20connection=20object?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在访问 providerSpecificData 前对 connection 添加可选链操作符 - 防止 connection 为 null/undefined 时导致的运行时错误 --- src/app/(dashboard)/dashboard/providers/[id]/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index c0febc8234..9eec81ee67 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -5839,7 +5839,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec codexOpenaiStoreEnabled: false, consoleApiKey: "", ccCompatibleContext1m: false, - passthroughModels: connection.providerSpecificData?.passthroughModels === true, + passthroughModels: connection?.providerSpecificData?.passthroughModels === true, }); const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState(null); @@ -5907,7 +5907,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec codexOpenaiStoreEnabled: connection.providerSpecificData?.openaiStoreEnabled === true, consoleApiKey: existingConsoleApiKey, ccCompatibleContext1m: ccRequestDefaults.context1m, - passthroughModels: connection.providerSpecificData?.passthroughModels === true, + passthroughModels: connection?.providerSpecificData?.passthroughModels === true, }); // Load existing extra keys from providerSpecificData const existing = connection.providerSpecificData?.extraApiKeys; From 96d0f57558a54cbd7951755911727b28d2d5526e Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 21 Apr 2026 08:34:41 -0300 Subject: [PATCH 023/281] feat: auto-inject stream_options.include_usage for OpenAI format streams (fixes #1423) --- open-sse/executors/default.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 76367a5008..25327f7518 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -218,9 +218,18 @@ export class DefaultExecutor extends BaseExecutor { */ transformRequest(model, body, stream, credentials) { void model; - void stream; void credentials; const withDefaults = applyProviderRequestDefaults(body, this.config.requestDefaults); + + if (stream && this.config.format === "openai") { + if (typeof withDefaults === "object" && withDefaults !== null) { + withDefaults.stream_options = { + ...(withDefaults.stream_options || {}), + include_usage: true, + }; + } + } + if (this.provider === "qwen" && typeof body === "object" && body !== null) { return sanitizeQwenThinkingToolChoice(withDefaults, "QwenExecutor"); } From 25e22df2cf27e303e2f2f1ca928e81018b17bf75 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 21 Apr 2026 08:36:39 -0300 Subject: [PATCH 024/281] =?UTF-8?q?chore(release):=20v3.7.0=20=E2=80=94=20?= =?UTF-8?q?finalization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4008929740..262c2d1b7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,14 @@ --- -## [3.7.0] — 2026-04-19 +## [3.7.0] — 2026-04-21 ### ✨ New Features - **feat(providers):** Add ModelScope provider (Chinese AI marketplace) with Kimi K2.5, GLM-5, and Step-3.5-Flash integration. (#1430) - **feat(core):** Implement provider-level Circuit Breaker to prevent cascading failures across connections, enforcing a 10-minute cooldown after 5 consecutive transient failures. (#1430) - **feat(core):** Add daily quota exhaustion lock to detect "quota exceeded" signals and lock the specific model until midnight. (#1430) +- **feat(core):** Auto-inject `stream_options.include_usage = true` for OpenAI format streams to guarantee token usage is reported correctly during streaming. (#1423) - **feat(dashboard):** Introduce real-time model status badges with countdown timers in the provider detail and combo panel interfaces. (#1430) ### 🐛 Bug Fixes From 03769eb0ea5a405e7439a7bcabdc8673ab5fd0a0 Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Tue, 21 Apr 2026 03:22:52 +0200 Subject: [PATCH 025/281] feat: add support for OpenAI batch processing --- .gitignore | 3 + open-sse/services/batchProcessor.ts | 374 +++++++++++ src/app/api/v1/batches/[id]/cancel/route.ts | 71 ++ src/app/api/v1/batches/[id]/route.ts | 53 ++ src/app/api/v1/batches/route.ts | 100 +++ src/app/api/v1/files/[id]/content/route.ts | 35 + src/app/api/v1/files/[id]/route.ts | 46 ++ src/app/api/v1/files/route.ts | 100 +++ src/instrumentation-node.ts | 5 +- src/lib/db/apiKeys.ts | 19 + src/lib/db/batches.ts | 142 ++++ src/lib/db/files.ts | 115 ++++ .../027_create_files_and_batches.sql | 51 ++ src/lib/localDb.ts | 25 +- src/shared/validation/schemas.ts | 25 + tests/manual/batch.http | 101 +++ tests/unit/api-auth.test.ts | 31 +- tests/unit/batch_api.test.ts | 621 ++++++++++++++++++ 18 files changed, 1913 insertions(+), 4 deletions(-) create mode 100644 open-sse/services/batchProcessor.ts create mode 100644 src/app/api/v1/batches/[id]/cancel/route.ts create mode 100644 src/app/api/v1/batches/[id]/route.ts create mode 100644 src/app/api/v1/batches/route.ts create mode 100644 src/app/api/v1/files/[id]/content/route.ts create mode 100644 src/app/api/v1/files/[id]/route.ts create mode 100644 src/app/api/v1/files/route.ts create mode 100644 src/lib/db/batches.ts create mode 100644 src/lib/db/files.ts create mode 100644 src/lib/db/migrations/027_create_files_and_batches.sql create mode 100644 tests/manual/batch.http create mode 100644 tests/unit/batch_api.test.ts diff --git a/.gitignore b/.gitignore index 783740a7d7..cefb992363 100644 --- a/.gitignore +++ b/.gitignore @@ -176,3 +176,6 @@ bin/omniroute.mjs .omc/ audit-report.json bun.lock + +# Private environment variables for .http-client +http-client.private.env.json diff --git a/open-sse/services/batchProcessor.ts b/open-sse/services/batchProcessor.ts new file mode 100644 index 0000000000..5d98fd3c8e --- /dev/null +++ b/open-sse/services/batchProcessor.ts @@ -0,0 +1,374 @@ +import { v4 as uuidv4 } from "uuid"; +import { + getPendingBatches, + updateBatch, + getFileContent, + createFile, + getApiKeyById, + getBatch, + listBatches, + listFiles, + deleteFile, + updateFileStatus, +} from "@/lib/localDb"; +import { handleChat } from "@/sse/handlers/chat"; + +let isProcessing = false; +let pollInterval: NodeJS.Timeout | null = null; + +export function initBatchProcessor() { + if (pollInterval) return pollInterval; + console.log("[BATCH] Initializing batch processor polling..."); + + // Fail any batches that were in_progress when the server last shut down — + // we cannot safely resume mid-batch without re-processing from scratch. + recoverOrphanedBatches(); + + pollInterval = setInterval(async () => { + if (isProcessing) return; + try { + isProcessing = true; + await processPendingBatches(); + } catch (err) { + console.error("[BATCH] Polling error:", err); + } finally { + isProcessing = false; + } + }, 10000); // Poll every 10s + return pollInterval; +} + +export function stopBatchProcessor() { + if (pollInterval) { + clearInterval(pollInterval); + pollInterval = null; + console.log("[BATCH] Stopped batch processor polling."); + } +} + +/** + * Mark any in_progress batches as failed on startup. + * These were orphaned by a server crash or restart and cannot be safely resumed. + */ +function recoverOrphanedBatches() { + try { + const pending = getPendingBatches(); + for (const batch of pending) { + if (batch.status === "in_progress") { + console.warn(`[BATCH] Failing orphaned in_progress batch ${batch.id} (server restarted)`); + updateBatch(batch.id, { + status: "failed", + failedAt: Math.floor(Date.now() / 1000), + errors: [{ message: "Batch interrupted by server restart and cannot be resumed" }], + }); + if (batch.inputFileId) { + updateFileStatus(batch.inputFileId, "processed"); + } + } + } + } catch (err) { + console.error("[BATCH] Orphan recovery error:", err); + } +} + +export async function processPendingBatches() { + const pending = getPendingBatches(); + for (const batch of pending) { + if (batch.status === "validating") { + await startBatch(batch); + } else if (batch.status === "cancelling") { + await cancelBatch(batch); + } + // in_progress: currently being processed by processBatchItems running in background; + // orphaned in_progress batches are handled by recoverOrphanedBatches() at startup. + } + + // Cleanup task: delete files for batches completed more than completionWindow ago + await cleanupExpiredBatches(); +} + +async function cleanupExpiredBatches() { + try { + const now = Math.floor(Date.now() / 1000); + const batches = listBatches(undefined, 200); // Check last 200 batches + + const parseWindow = (window: string): number => { + if (!window) return 86400; + const match = new RegExp(/^(\d+)([hdm])$/).exec(window); + if (!match) return 86400; + const val = Number.parseInt(match[1]); + const unit = match[2]; + if (unit === "h") return val * 3600; + if (unit === "d") return val * 86400; + if (unit === "m") return val * 60; + return 86400; + }; + + for (const batch of batches) { + const windowSeconds = parseWindow(batch.completionWindow); + + // Cleanup completed/failed/cancelled batches after their completion window + const completionTime = + batch.completedAt || batch.failedAt || batch.cancelledAt || batch.expiredAt; + if (completionTime && now - completionTime > windowSeconds) { + if (batch.inputFileId) deleteFile(batch.inputFileId); + if (batch.outputFileId) deleteFile(batch.outputFileId); + if (batch.errorFileId) deleteFile(batch.errorFileId); + } + + // Expire pending batches that have exceeded their completion window + if (batch.status === "validating") { + if (now - batch.createdAt > windowSeconds) { + updateBatch(batch.id, { status: "expired", expiredAt: now }); + } + } + } + + // Cleanup orphan files (batch-purpose files stuck in validating after 48h) + const allFiles = listFiles(); + for (const file of allFiles) { + if ( + file.purpose === "batch" && + (file.status === "validating" || !file.status) && + now - file.createdAt > 172800 + ) { + deleteFile(file.id); + } + } + } catch (err) { + console.error("[BATCH] Cleanup error:", err); + } +} + +async function startBatch(batch: any) { + console.log(`[BATCH] Starting batch ${batch.id}`); + + const content = getFileContent(batch.inputFileId); + if (!content) { + failBatch(batch.id, "Input file content not found"); + return; + } + + try { + const lines = content.toString().split("\n").filter((l) => l.trim()); + const total = lines.length; + + updateFileStatus(batch.inputFileId, "validating"); + updateBatch(batch.id, { + status: "in_progress", + inProgressAt: Math.floor(Date.now() / 1000), + requestCountsTotal: total, + }); + + // Fire-and-forget: process items in the background so the poll loop isn't blocked. + // isProcessing prevents a second poll tick from overlapping. + // noinspection ES6MissingAwait + processBatchItems(batch, lines); + } catch (err) { + console.error(`[BATCH] Error starting batch ${batch.id}:`, err); + failBatch(batch.id, err instanceof Error ? err.message : String(err)); + } +} + +async function processBatchItems(batch: any, lines: string[]) { + const results: any[] = []; + const errors: any[] = []; + let completedCount = 0; + let failedCount = 0; + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalReasoningTokens = 0; + let usedModel = batch.model || null; + + const apiKeyRow = batch.apiKeyId ? await getApiKeyById(batch.apiKeyId) : null; + + for (const line of lines) { + // Check if cancelled mid-process + const current = getBatch(batch.id); + if (!current || current.status === "cancelling" || current.status === "cancelled") { + break; + } + + try { + const item = JSON.parse(line); + const { custom_id: customId, url, body } = item; + + const headers = new Headers(); + if (apiKeyRow?.key) { + headers.set("Authorization", `Bearer ${apiKeyRow.key}`); + } + headers.set("Content-Type", "application/json"); + + // BATCH-SPECIFIC: Force stream: false — batches don't support SSE responses + const batchItemBody = { ...body, stream: false }; + + const response = await handleChat({ + json: async () => batchItemBody, + url: `http://localhost${url}`, + headers, + method: "POST", + } as any); + + let responseData: { error: any; id?: any; usage?: any; model?: any }; + let statusCode = 200; + + if (response instanceof Response) { + statusCode = response.status; + const contentType = response.headers.get("content-type") || ""; + if (contentType.includes("application/json")) { + responseData = await response.json(); + } else { + const text = await response.text(); + try { + responseData = JSON.parse(text); + } catch { + responseData = { error: { message: text || "Unknown error", type: "invalid_response" } }; + } + } + } else { + responseData = response; + } + + const hasError = responseData?.error; + const requestId = `batch_req_${uuidv4().replaceAll("-", "")}`; + + results.push({ + id: requestId, + custom_id: customId, + response: { + status_code: statusCode, + request_id: responseData?.id || "req_unknown", + body: responseData, + }, + error: null, + }); + + if (hasError || statusCode >= 400) { + failedCount++; + } else { + completedCount++; + if (responseData?.usage) { + totalInputTokens += responseData.usage.prompt_tokens || 0; + totalOutputTokens += responseData.usage.completion_tokens || 0; + totalReasoningTokens += + responseData.usage.completion_tokens_details?.reasoning_tokens || 0; + } + if (!usedModel && responseData?.model) { + usedModel = responseData.model; + } + } + } catch (err) { + console.error(`[BATCH] Item failed in ${batch.id}:`, err); + let customId = "unknown"; + try { + customId = JSON.parse(line).custom_id || "unknown"; + } catch { + // line was malformed JSON + } + errors.push({ + custom_id: customId, + error: err instanceof Error ? err.message : String(err), + }); + failedCount++; + } + + // Periodic progress update + updateBatch(batch.id, { + requestCountsCompleted: completedCount, + requestCountsFailed: failedCount, + model: usedModel, + usage: { + input_tokens: totalInputTokens, + output_tokens: totalOutputTokens, + total_tokens: totalInputTokens + totalOutputTokens, + input_tokens_details: { cached_tokens: 0 }, + output_tokens_details: { reasoning_tokens: totalReasoningTokens }, + }, + }); + } + + // Finalize + await finalizeBatch(batch.id, results, errors); +} + +async function finalizeBatch(batchId: string, results: any[], itemsWithErrors: any[]) { + const current = getBatch(batchId); + if (current?.status === "cancelling") { + updateBatch(batchId, { status: "cancelled", cancelledAt: Math.floor(Date.now() / 1000) }); + return; + } + + updateBatch(batchId, { status: "finalizing", finalizingAt: Math.floor(Date.now() / 1000) }); + + if (current?.inputFileId) { + updateFileStatus(current.inputFileId, "processed"); + } + + let outputFileId: string | null = null; + const successes = results.filter( + (r) => r.response.status_code < 400 && !r.response.body?.error + ); + if (successes.length > 0) { + const outputContent = successes.map((r) => JSON.stringify(r)).join("\n"); + const file = createFile({ + bytes: Buffer.byteLength(outputContent), + filename: `batch_${batchId}_output.jsonl`, + purpose: "batch_output", + content: Buffer.from(outputContent), + apiKeyId: current?.apiKeyId, + status: "completed", + }); + outputFileId = file.id; + } + + let errorFileId: string | null = null; + const failures = results.filter( + (r) => r.response.status_code >= 400 || r.response.body?.error + ); + const allFailures = [ + ...failures, + ...itemsWithErrors.map((e) => ({ + id: `batch_req_${uuidv4().replaceAll("-", "")}`, + custom_id: e.custom_id, + response: null, + error: { message: e.error, type: "batch_process_error" }, + })), + ]; + + if (allFailures.length > 0) { + const errorContent = allFailures.map((e) => JSON.stringify(e)).join("\n"); + const file = createFile({ + bytes: Buffer.byteLength(errorContent), + filename: `batch_${batchId}_error.jsonl`, + purpose: "batch_output", + content: Buffer.from(errorContent), + apiKeyId: current?.apiKeyId, + status: "completed", + }); + errorFileId = file.id; + } + + updateBatch(batchId, { + status: "completed", + completedAt: Math.floor(Date.now() / 1000), + outputFileId, + errorFileId, + }); + console.log(`[BATCH] Completed batch ${batchId}`); +} + +async function cancelBatch(batch: any) { + updateBatch(batch.id, { + status: "cancelled", + cancelledAt: Math.floor(Date.now() / 1000), + }); + console.log(`[BATCH] Cancelled batch ${batch.id}`); +} + +function failBatch(batchId: string, reason: string) { + updateBatch(batchId, { + status: "failed", + failedAt: Math.floor(Date.now() / 1000), + errors: [{ message: reason }], + }); +} diff --git a/src/app/api/v1/batches/[id]/cancel/route.ts b/src/app/api/v1/batches/[id]/cancel/route.ts new file mode 100644 index 0000000000..a83e406e1d --- /dev/null +++ b/src/app/api/v1/batches/[id]/cancel/route.ts @@ -0,0 +1,71 @@ +import { CORS_HEADERS } from "@/shared/utils/cors"; +import { extractApiKey } from "@/sse/services/auth"; +import { getBatch, updateBatch, getApiKeyMetadata } from "@/lib/localDb"; +import { NextResponse } from "next/server"; + +function formatBatchResponse(batch: any) { + return { + id: batch.id, + object: "batch", + endpoint: batch.endpoint, + errors: batch.errors || null, + input_file_id: batch.inputFileId, + completion_window: batch.completionWindow, + status: batch.status, + output_file_id: batch.outputFileId || null, + error_file_id: batch.errorFileId || null, + created_at: batch.createdAt, + in_progress_at: batch.inProgressAt || null, + expires_at: batch.expiresAt || null, + finalizing_at: batch.finalizingAt || null, + completed_at: batch.completedAt || null, + failed_at: batch.failedAt || null, + expired_at: batch.expiredAt || null, + cancelling_at: batch.cancellingAt || null, + cancelled_at: batch.cancelledAt || null, + request_counts: { + total: batch.requestCountsTotal || 0, + completed: batch.requestCountsCompleted || 0, + failed: batch.requestCountsFailed || 0, + }, + metadata: batch.metadata || null, + model: batch.model || null, + usage: batch.usage || null, + }; +} + +export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { + const apiKey = extractApiKey(request); + const apiKeyMetadata = await getApiKeyMetadata(apiKey); + const apiKeyId = apiKeyMetadata?.id || null; + + const { id } = await params; + const batch = getBatch(id); + + if (!batch || (batch.apiKeyId !== null && batch.apiKeyId !== apiKeyId)) { + return NextResponse.json( + { error: { message: "Batch not found", type: "invalid_request_error" } }, + { status: 404, headers: CORS_HEADERS } + ); + } + + if (["completed", "failed", "cancelled", "expired"].includes(batch.status)) { + return NextResponse.json( + { error: { message: `Batch ${id} is already ${batch.status}`, type: "invalid_request_error" } }, + { status: 400, headers: CORS_HEADERS } + ); + } + + if (batch.status === "cancelling") { + return NextResponse.json(formatBatchResponse(batch), { headers: CORS_HEADERS }); + } + + updateBatch(id, { + status: "cancelling", + cancellingAt: Math.floor(Date.now() / 1000) + }); + + const updatedBatch = getBatch(id); + + return NextResponse.json(formatBatchResponse(updatedBatch), { headers: CORS_HEADERS }); +} diff --git a/src/app/api/v1/batches/[id]/route.ts b/src/app/api/v1/batches/[id]/route.ts new file mode 100644 index 0000000000..db1cc119fb --- /dev/null +++ b/src/app/api/v1/batches/[id]/route.ts @@ -0,0 +1,53 @@ +import { CORS_HEADERS } from "@/shared/utils/cors"; +import { extractApiKey } from "@/sse/services/auth"; +import { getBatch, getApiKeyMetadata } from "@/lib/localDb"; +import { NextResponse } from "next/server"; + +function formatBatchResponse(batch: any) { + return { + id: batch.id, + object: "batch", + endpoint: batch.endpoint, + errors: batch.errors || null, + input_file_id: batch.inputFileId, + completion_window: batch.completionWindow, + status: batch.status, + output_file_id: batch.outputFileId || null, + error_file_id: batch.errorFileId || null, + created_at: batch.createdAt, + in_progress_at: batch.inProgressAt || null, + expires_at: batch.expiresAt || null, + finalizing_at: batch.finalizingAt || null, + completed_at: batch.completedAt || null, + failed_at: batch.failedAt || null, + expired_at: batch.expiredAt || null, + cancelling_at: batch.cancellingAt || null, + cancelled_at: batch.cancelledAt || null, + request_counts: { + total: batch.requestCountsTotal || 0, + completed: batch.requestCountsCompleted || 0, + failed: batch.requestCountsFailed || 0, + }, + metadata: batch.metadata || null, + model: batch.model || null, + usage: batch.usage || null, + }; +} + +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const apiKey = extractApiKey(request); + const apiKeyMetadata = await getApiKeyMetadata(apiKey); + const apiKeyId = apiKeyMetadata?.id || null; + + const { id } = await params; + const batch = getBatch(id); + + if (!batch || (batch.apiKeyId !== null && batch.apiKeyId !== apiKeyId)) { + return NextResponse.json( + { error: { message: "Batch not found", type: "invalid_request_error" } }, + { status: 404, headers: CORS_HEADERS } + ); + } + + return NextResponse.json(formatBatchResponse(batch), { headers: CORS_HEADERS }); +} diff --git a/src/app/api/v1/batches/route.ts b/src/app/api/v1/batches/route.ts new file mode 100644 index 0000000000..14734a7d69 --- /dev/null +++ b/src/app/api/v1/batches/route.ts @@ -0,0 +1,100 @@ +import { CORS_HEADERS } from "@/shared/utils/cors"; +import { extractApiKey } from "@/sse/services/auth"; +import { createBatch, getApiKeyMetadata, getFile, listBatches } from "@/lib/localDb"; +import { v1BatchCreateSchema } from "@/shared/validation/schemas"; +import { NextResponse } from "next/server"; + +function formatBatchResponse(batch: any) { + return { + id: batch.id, + object: "batch", + endpoint: batch.endpoint, + errors: batch.errors || null, + input_file_id: batch.inputFileId, + completion_window: batch.completionWindow, + status: batch.status, + output_file_id: batch.outputFileId || null, + error_file_id: batch.errorFileId || null, + created_at: batch.createdAt, + in_progress_at: batch.inProgressAt || null, + expires_at: batch.expiresAt || null, + finalizing_at: batch.finalizingAt || null, + completed_at: batch.completedAt || null, + failed_at: batch.failedAt || null, + expired_at: batch.expiredAt || null, + cancelling_at: batch.cancellingAt || null, + cancelled_at: batch.cancelledAt || null, + request_counts: { + total: batch.requestCountsTotal || 0, + completed: batch.requestCountsCompleted || 0, + failed: batch.requestCountsFailed || 0, + }, + metadata: batch.metadata || null, + model: batch.model || null, + usage: batch.usage || null, + }; +} + +export async function POST(request: Request) { + const apiKey = extractApiKey(request); + const apiKeyMetadata = await getApiKeyMetadata(apiKey); + const apiKeyId = apiKeyMetadata?.id || null; + + try { + const body = await request.json(); + const validated = v1BatchCreateSchema.parse(body); + + const inputFile = getFile(validated.input_file_id); + if (!inputFile || (inputFile.apiKeyId !== null && inputFile.apiKeyId !== apiKeyId)) { + return NextResponse.json( + { error: { message: "Input file not found", type: "invalid_request_error" } }, + { status: 400, headers: CORS_HEADERS } + ); + } + + const batch = createBatch({ + endpoint: validated.endpoint as any, + completionWindow: validated.completion_window, + inputFileId: validated.input_file_id, + metadata: validated.metadata, + apiKeyId, + outputExpiresAfterSeconds: validated.output_expires_after?.seconds || null, + outputExpiresAfterAnchor: validated.output_expires_after?.anchor || null, + }); + + return NextResponse.json(formatBatchResponse(batch), { headers: CORS_HEADERS }); + } catch (error) { + console.error("[BATCHES] Create failed:", error); + return NextResponse.json( + { error: { message: error instanceof Error ? error.message : "Create failed", type: "invalid_request_error" } }, + { status: 400, headers: CORS_HEADERS } + ); + } +} + +export async function GET(request: Request) { + const apiKey = extractApiKey(request); + const apiKeyMetadata = await getApiKeyMetadata(apiKey); + const apiKeyId = apiKeyMetadata?.id || null; + + const url = new URL(request.url); + const limit = Number.parseInt(url.searchParams.get("limit") || "20"); + const after = url.searchParams.get("after") || undefined; + + const batches = listBatches(apiKeyId || undefined, limit + 1, after); + const hasMore = batches.length > limit; + const data = hasMore ? batches.slice(0, limit) : batches; + + const formattedData = data.map(b => formatBatchResponse(b)); + + return NextResponse.json( + { + object: "list", + data: formattedData, + first_id: formattedData.length > 0 ? formattedData[0].id : null, + last_id: formattedData.length > 0 ? formattedData.at(-1).id : null, + has_more: hasMore, + }, + { headers: CORS_HEADERS } + ); +} diff --git a/src/app/api/v1/files/[id]/content/route.ts b/src/app/api/v1/files/[id]/content/route.ts new file mode 100644 index 0000000000..b7a150bb75 --- /dev/null +++ b/src/app/api/v1/files/[id]/content/route.ts @@ -0,0 +1,35 @@ +import { CORS_HEADERS } from "@/shared/utils/cors"; +import { extractApiKey } from "@/sse/services/auth"; +import { getFile, getFileContent, getApiKeyMetadata } from "@/lib/localDb"; +import { NextResponse } from "next/server"; + +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const apiKey = extractApiKey(request); + const apiKeyMetadata = await getApiKeyMetadata(apiKey); + const apiKeyId = apiKeyMetadata?.id || null; + + const { id } = await params; + const file = getFile(id); + + if (!file || (file.apiKeyId !== null && file.apiKeyId !== apiKeyId)) { + return NextResponse.json( + { error: { message: "File not found", type: "invalid_request_error" } }, + { status: 404, headers: CORS_HEADERS } + ); + } + + const content = getFileContent(id); + if (!content) { + return NextResponse.json( + { error: { message: "File content not found", type: "invalid_request_error" } }, + { status: 404, headers: CORS_HEADERS } + ); + } + + return new Response(content, { + headers: { + ...CORS_HEADERS, + "Content-Type": file.mimeType || "application/octet-stream", + }, + }); +} diff --git a/src/app/api/v1/files/[id]/route.ts b/src/app/api/v1/files/[id]/route.ts new file mode 100644 index 0000000000..00baab6617 --- /dev/null +++ b/src/app/api/v1/files/[id]/route.ts @@ -0,0 +1,46 @@ +import { CORS_HEADERS } from "@/shared/utils/cors"; +import { extractApiKey } from "@/sse/services/auth"; +import { getFile, deleteFile, getApiKeyMetadata, formatFileResponse } from "@/lib/localDb"; +import { NextResponse } from "next/server"; + +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const apiKey = extractApiKey(request); + const apiKeyMetadata = await getApiKeyMetadata(apiKey); + const apiKeyId = apiKeyMetadata?.id || null; + + const { id } = await params; + const file = getFile(id); + + if (!file || (file.apiKeyId !== null && file.apiKeyId !== apiKeyId)) { + return NextResponse.json( + { error: { message: "File not found", type: "invalid_request_error" } }, + { status: 404, headers: CORS_HEADERS } + ); + } + + return NextResponse.json(formatFileResponse(file), { headers: CORS_HEADERS }); +} + +export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { + const apiKey = extractApiKey(request); + const apiKeyMetadata = await getApiKeyMetadata(apiKey); + const apiKeyId = apiKeyMetadata?.id || null; + + const { id } = await params; + const file = getFile(id); + + if (!file || (file.apiKeyId !== null && file.apiKeyId !== apiKeyId)) { + return NextResponse.json( + { error: { message: "File not found", type: "invalid_request_error" } }, + { status: 404, headers: CORS_HEADERS } + ); + } + + deleteFile(id); + + return NextResponse.json({ + id, + object: "file", + deleted: true + }, { headers: CORS_HEADERS }); +} diff --git a/src/app/api/v1/files/route.ts b/src/app/api/v1/files/route.ts new file mode 100644 index 0000000000..4b5aa73355 --- /dev/null +++ b/src/app/api/v1/files/route.ts @@ -0,0 +1,100 @@ +import { CORS_HEADERS } from "@/shared/utils/cors"; +import { extractApiKey } from "@/sse/services/auth"; +import { createFile, listFiles, getApiKeyMetadata, formatFileResponse } from "@/lib/localDb"; +import { NextResponse } from "next/server"; + +export async function POST(request: Request) { + const apiKey = extractApiKey(request); + const apiKeyMetadata = await getApiKeyMetadata(apiKey); + const apiKeyId = apiKeyMetadata?.id || null; + + try { + const formData = await request.formData(); + const file = formData.get("file") as File; + const purpose = formData.get("purpose") as string; + const expiresAfterAnchor = formData.get("expires_after[anchor]") as string; + const expiresAfterSeconds = formData.get("expires_after[seconds]") as string; + + if (!file || !purpose) { + return NextResponse.json( + { error: { message: "Missing file or purpose", type: "invalid_request_error" } }, + { status: 400, headers: CORS_HEADERS } + ); + } + + const MAX_FILE_BYTES = 512 * 1024 * 1024; // 512 MB + if (file.size > MAX_FILE_BYTES) { + return NextResponse.json( + { error: { message: "File exceeds maximum allowed size of 512 MB", type: "invalid_request_error" } }, + { status: 400, headers: CORS_HEADERS } + ); + } + + const bytes = file.size; + const filename = file.name; + const arrayBuffer = await file.arrayBuffer(); + const content = Buffer.from(arrayBuffer); + const mimeType = file.type; + + let expiresAt: number | undefined; + if (expiresAfterAnchor === "created_at" && expiresAfterSeconds) { + const seconds = Number.parseInt(expiresAfterSeconds); + if (!Number.isNaN(seconds)) { + expiresAt = Math.floor(Date.now() / 1000) + seconds; + } + } + + const record = createFile({ + bytes, + filename, + purpose, + content, + mimeType, + apiKeyId, + expiresAt, + }); + + return NextResponse.json(formatFileResponse(record), { headers: CORS_HEADERS }); + } catch (error) { + console.error("[FILES] Upload failed:", error); + return NextResponse.json( + { error: { message: "Upload failed", type: "server_error" } }, + { status: 500, headers: CORS_HEADERS } + ); + } +} + +export async function GET(request: Request) { + const apiKey = extractApiKey(request); + const apiKeyMetadata = await getApiKeyMetadata(apiKey); + const apiKeyId = apiKeyMetadata?.id || null; + + const { searchParams } = new URL(request.url); + const limit = Math.min(Number.parseInt(searchParams.get("limit") || "20") || 20, 10000); + const after = searchParams.get("after") || undefined; + const order = (searchParams.get("order") as "asc" | "desc") || "desc"; + const purpose = searchParams.get("purpose") || undefined; + + // We fetch limit + 1 to check if there are more items + const files = listFiles({ + apiKeyId: apiKeyId || undefined, + purpose, + limit: limit + 1, + after, + order + }); + + const hasMore = files.length > limit; + const data = files.slice(0, limit); + + return NextResponse.json( + { + object: "list", + data: data.map((f) => formatFileResponse(f)), + first_id: data.length > 0 ? data[0].id : null, + last_id: data.length > 0 ? data.at(-1).id : null, + has_more: hasMore, + }, + { headers: CORS_HEADERS } + ); +} diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index d67092f262..50cbd0e208 100755 --- a/src/instrumentation-node.ts +++ b/src/instrumentation-node.ts @@ -13,7 +13,7 @@ function getRandomBytes(byteLength: number): Uint8Array { } function toBase64(bytes: Uint8Array): string { - return btoa(String.fromCharCode(...bytes)); + return btoa(String.fromCodePoint(...bytes)); } function toHex(bytes: Uint8Array): string { @@ -130,6 +130,9 @@ export async function registerNodejs(): Promise { console.log( `[STARTUP] Cloud/model sync background bootstrap ${cloudSyncInitialized ? "initialized" : "skipped"}` ); + const { initBatchProcessor } = await import("@omniroute/open-sse/services/batchProcessor"); + initBatchProcessor(); + console.log("[STARTUP] Batch processor started"); } try { diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts index 34d7d3c5f1..3012ce7aa6 100644 --- a/src/lib/db/apiKeys.ts +++ b/src/lib/db/apiKeys.ts @@ -604,6 +604,25 @@ export async function getApiKeyMetadata( const now = Date.now(); + // persistent env-var key support (persistent passthrough keys) (#1350) + const envKey = process.env.OMNIROUTE_API_KEY || process.env.ROUTER_API_KEY; + if (envKey && key === envKey) { + return { + id: "env-key", + name: "Environment Key", + machineId: "server-env", + allowedModels: [], + allowedConnections: [], + noLog: false, + autoResolve: true, + isActive: true, + accessSchedule: null, + maxRequestsPerDay: null, + maxRequestsPerMinute: null, + maxSessions: 0, + }; + } + // Check cache first const cached = _keyMetadataCache.get(key); if (cached && now - cached.timestamp < CACHE_TTL) { diff --git a/src/lib/db/batches.ts b/src/lib/db/batches.ts new file mode 100644 index 0000000000..6df156b4c5 --- /dev/null +++ b/src/lib/db/batches.ts @@ -0,0 +1,142 @@ +import { getDbInstance, rowToCamel, objToSnake } from "./core"; +import { v4 as uuidv4 } from "uuid"; + +function parseBatchRow(row: any): BatchRecord { + const camel = rowToCamel(row) as any; + if (camel.metadata && typeof camel.metadata === "string") { + try { camel.metadata = JSON.parse(camel.metadata); } catch { camel.metadata = null; } + } + if (camel.errors && typeof camel.errors === "string") { + try { camel.errors = JSON.parse(camel.errors); } catch { camel.errors = null; } + } + if (camel.usage && typeof camel.usage === "string") { + try { camel.usage = JSON.parse(camel.usage); } catch { camel.usage = null; } + } + return camel as BatchRecord; +} + +export interface BatchRecord { + id: string; + endpoint: string; + completionWindow: string; + status: "validating" | "failed" | "in_progress" | "finalizing" | "completed" | "expired" | "cancelling" | "cancelled"; + inputFileId: string; + outputFileId?: string | null; + errorFileId?: string | null; + createdAt: number; + inProgressAt?: number | null; + expiresAt?: number | null; + finalizingAt?: number | null; + completedAt?: number | null; + failedAt?: number | null; + expiredAt?: number | null; + cancellingAt?: number | null; + cancelledAt?: number | null; + requestCountsTotal: number; + requestCountsCompleted: number; + requestCountsFailed: number; + metadata?: Record | null; + apiKeyId?: string | null; + errors?: any | null; + model?: string | null; + usage?: any | null; + outputExpiresAfterSeconds?: number | null; + outputExpiresAfterAnchor?: string | null; +} + +export function createBatch(batch: Omit): BatchRecord { + const db = getDbInstance(); + const id = "batch_" + uuidv4().replaceAll("-", "").substring(0, 24); + const createdAt = Math.floor(Date.now() / 1000); + const record: BatchRecord = { + ...batch, + id, + createdAt, + status: "validating", + requestCountsTotal: 0, + requestCountsCompleted: 0, + requestCountsFailed: 0, + errors: batch.errors || null, + model: batch.model || null, + usage: batch.usage || null, + outputExpiresAfterSeconds: batch.outputExpiresAfterSeconds || null, + outputExpiresAfterAnchor: batch.outputExpiresAfterAnchor || null, + }; + + const snakeRecord = objToSnake({ + ...record, + metadata: record.metadata ? JSON.stringify(record.metadata) : null, + errors: record.errors ? JSON.stringify(record.errors) : null, + usage: record.usage ? JSON.stringify(record.usage) : null + }) as any; + const keys = Object.keys(snakeRecord); + const values = Object.values(snakeRecord); + const placeholders = keys.map(() => "?").join(", "); + + db.prepare( + `INSERT INTO batches (${keys.join(", ")}) VALUES (${placeholders})` + ).run(...values); + + return record; +} + +export function getBatch(id: string): BatchRecord | null { + const db = getDbInstance(); + const row = db.prepare("SELECT * FROM batches WHERE id = ?").get(id); + if (!row) return null; + return parseBatchRow(row); +} + +export function updateBatch(id: string, updates: Partial): boolean { + const db = getDbInstance(); + const snakeUpdates = objToSnake(updates) as any; + if (snakeUpdates.metadata && typeof snakeUpdates.metadata !== "string") { + snakeUpdates.metadata = JSON.stringify(snakeUpdates.metadata); + } + if (snakeUpdates.errors && typeof snakeUpdates.errors !== "string") { + snakeUpdates.errors = JSON.stringify(snakeUpdates.errors); + } + if (snakeUpdates.usage && typeof snakeUpdates.usage !== "string") { + snakeUpdates.usage = JSON.stringify(snakeUpdates.usage); + } + + const keys = Object.keys(snakeUpdates); + if (keys.length === 0) return false; + + const setClause = keys.map(k => `${k} = ?`).join(", "); + const values = Object.values(snakeUpdates); + + const result = db.prepare(`UPDATE batches SET ${setClause} WHERE id = ?`).run(...values, id); + return result.changes > 0; +} + +export function listBatches(apiKeyId?: string, limit: number = 20, after?: string): BatchRecord[] { + const db = getDbInstance(); + let rows: any[]; + if (apiKeyId) { + if (after) { + rows = db + .prepare("SELECT * FROM batches WHERE api_key_id = ? AND id < ? ORDER BY id DESC LIMIT ?") + .all(apiKeyId, after, limit); + } else { + rows = db + .prepare("SELECT * FROM batches WHERE api_key_id = ? ORDER BY id DESC LIMIT ?") + .all(apiKeyId, limit); + } + } else if (after) { + rows = db + .prepare("SELECT * FROM batches WHERE id < ? ORDER BY id DESC LIMIT ?") + .all(after, limit); + } else { + rows = db.prepare("SELECT * FROM batches ORDER BY id DESC LIMIT ?").all(limit); + } + return rows.map(row => parseBatchRow(row)); +} + +export function getPendingBatches(): BatchRecord[] { + const db = getDbInstance(); + const rows = db.prepare( + "SELECT * FROM batches WHERE status IN ('validating', 'in_progress', 'cancelling')" + ).all(); + return rows.map(row => parseBatchRow(row)); +} diff --git a/src/lib/db/files.ts b/src/lib/db/files.ts new file mode 100644 index 0000000000..c6900fa5de --- /dev/null +++ b/src/lib/db/files.ts @@ -0,0 +1,115 @@ +import { getDbInstance, rowToCamel, objToSnake } from "./core"; +import { v4 as uuidv4 } from "uuid"; + +export interface FileRecord { + id: string; + bytes: number; + createdAt: number; + filename: string; + purpose: string; + content?: Buffer | null; + mimeType?: string | null; + apiKeyId?: string | null; + status?: string | null; + expiresAt?: number | null; + deletedAt?: number | null; +} + +export function createFile(file: Omit): FileRecord { + const db = getDbInstance(); + const id = "file-" + uuidv4().replaceAll("-", "").substring(0, 24); + const createdAt = Math.floor(Date.now() / 1000); + const record = { ...file, id, createdAt }; + + const snakeRecord = objToSnake(record) as any; + const keys = Object.keys(snakeRecord); + const values = Object.values(snakeRecord); + const placeholders = keys.map(() => "?").join(", "); + + db.prepare( + `INSERT INTO files (${keys.join(", ")}) VALUES (${placeholders})` + ).run(...values); + + return record; +} + +export function getFile(id: string): FileRecord | null { + const db = getDbInstance(); + const row = db.prepare("SELECT * FROM files WHERE id = ? AND deleted_at IS NULL").get(id); + return row ? (rowToCamel(row) as unknown as FileRecord) : null; +} + +export function getFileContent(id: string): Buffer | null { + const db = getDbInstance(); + const row = db.prepare("SELECT content FROM files WHERE id = ? AND deleted_at IS NULL").get(id) as { content: Buffer } | undefined; + return row?.content || null; +} + +export function listFiles(options: { + apiKeyId?: string, + purpose?: string, + limit?: number, + after?: string, + order?: "asc" | "desc" +} = {}): FileRecord[] { + const db = getDbInstance(); + const { apiKeyId, purpose, limit = 20, after, order = "desc" } = options; + + let query = "SELECT * FROM files WHERE deleted_at IS NULL"; + const params: any[] = []; + + if (apiKeyId) { + query += " AND api_key_id = ?"; + params.push(apiKeyId); + } + + if (purpose) { + query += " AND purpose = ?"; + params.push(purpose); + } + + if (after) { + // Get the creation time of the 'after' file to use for pagination + const afterFile = getFile(after); + if (afterFile) { + if (order === "desc") { + query += " AND (created_at < ? OR (created_at = ? AND id < ?))"; + } else { + query += " AND (created_at > ? OR (created_at = ? AND id > ?))"; + } + params.push(afterFile.createdAt, afterFile.createdAt, after); + } + } + + query += ` ORDER BY created_at ${order === "asc" ? "ASC" : "DESC"}, id ${order === "asc" ? "ASC" : "DESC"}`; + query += " LIMIT ?"; + params.push(limit); + + const rows = db.prepare(query).all(...params); + return rows.map(row => rowToCamel(row) as unknown as FileRecord); +} + +export function updateFileStatus(id: string, status: string): boolean { + const db = getDbInstance(); + const result = db.prepare("UPDATE files SET status = ? WHERE id = ?").run(status, id); + return result.changes > 0; +} + +export function formatFileResponse(file: FileRecord) { + return { + id: file.id, + bytes: file.bytes, + created_at: file.createdAt, + filename: file.filename, + object: "file", + purpose: file.purpose, + status: file.status || "validating", + expires_at: file.expiresAt || null, + }; +} + +export function deleteFile(id: string): boolean { + const db = getDbInstance(); + const result = db.prepare("UPDATE files SET deleted_at = ?, content = NULL WHERE id = ?").run(Math.floor(Date.now() / 1000), id); + return result.changes > 0; +} diff --git a/src/lib/db/migrations/027_create_files_and_batches.sql b/src/lib/db/migrations/027_create_files_and_batches.sql new file mode 100644 index 0000000000..63f286e9d8 --- /dev/null +++ b/src/lib/db/migrations/027_create_files_and_batches.sql @@ -0,0 +1,51 @@ +-- 027_create_files_and_batches.sql +-- Creates the files and batches tables with their complete final schema. + +CREATE TABLE IF NOT EXISTS files ( + id TEXT PRIMARY KEY, + bytes INTEGER NOT NULL, + created_at INTEGER NOT NULL, + filename TEXT NOT NULL, + purpose TEXT NOT NULL, + content BLOB, + mime_type TEXT, + api_key_id TEXT, + deleted_at INTEGER, + status TEXT DEFAULT 'validating', + expires_at INTEGER +); +CREATE INDEX IF NOT EXISTS idx_files_api_key ON files(api_key_id); + +CREATE TABLE IF NOT EXISTS batches ( + id TEXT PRIMARY KEY, + endpoint TEXT NOT NULL, + completion_window TEXT NOT NULL, + status TEXT NOT NULL, + input_file_id TEXT NOT NULL, + output_file_id TEXT, + error_file_id TEXT, + created_at INTEGER NOT NULL, + in_progress_at INTEGER, + expires_at INTEGER, + finalizing_at INTEGER, + completed_at INTEGER, + failed_at INTEGER, + expired_at INTEGER, + cancelling_at INTEGER, + cancelled_at INTEGER, + request_counts_total INTEGER DEFAULT 0, + request_counts_completed INTEGER DEFAULT 0, + request_counts_failed INTEGER DEFAULT 0, + metadata TEXT, + api_key_id TEXT, + errors TEXT, + model TEXT, + usage TEXT, + output_expires_after_seconds INTEGER, + output_expires_after_anchor TEXT, + FOREIGN KEY(input_file_id) REFERENCES files(id), + FOREIGN KEY(output_file_id) REFERENCES files(id), + FOREIGN KEY(error_file_id) REFERENCES files(id) +); +CREATE INDEX IF NOT EXISTS idx_batches_api_key ON batches(api_key_id); +CREATE INDEX IF NOT EXISTS idx_batches_status ON batches(status); diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index eebbc80604..08f809a737 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -159,7 +159,7 @@ export { } from "./db/backup"; export { - // Read Cache (cached wrappers for hot read paths) + // Read Cache (cached wrappers for hot-read paths) getCachedSettings, getCachedPricing, getCachedProviderConnections, @@ -202,6 +202,29 @@ export { resolveComboForModel, } from "./db/modelComboMappings"; +export { + // Files + createFile, + getFile, + getFileContent, + listFiles, + updateFileStatus, + formatFileResponse, + deleteFile, +} from "./db/files"; + +export { + // Batches + createBatch, + getBatch, + updateBatch, + listBatches, + getPendingBatches, +} from "./db/batches"; + +export type { FileRecord } from "./db/files"; +export type { BatchRecord } from "./db/batches"; + export type { ModelComboMapping } from "./db/modelComboMappings"; export { diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index ae120f3da7..8909229c78 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -1744,3 +1744,28 @@ export const versionManagerToolSchema = z.object({ export const versionManagerInstallSchema = versionManagerToolSchema.extend({ version: z.string().trim().optional(), }); + +export const v1BatchCreateSchema = z.object({ + input_file_id: z.string().min(1), + endpoint: z.enum([ + "/v1/responses", + "/v1/chat/completions", + "/v1/embeddings", + "/v1/completions", + "/v1/moderations", + "/v1/images/generations", + "/v1/images/edits", + "/v1/videos", + ]), + completion_window: z.enum(["24h"]), + metadata: z + .record(z.string().max(64), z.string().max(512)) + .refine((m) => Object.keys(m).length <= 16, { message: "metadata may have at most 16 keys" }) + .optional(), + output_expires_after: z + .object({ + anchor: z.enum(["created_at"]), + seconds: z.number().int().min(3600).max(2592000), + }) + .optional(), +}); diff --git a/tests/manual/batch.http b/tests/manual/batch.http new file mode 100644 index 0000000000..131ae14f05 --- /dev/null +++ b/tests/manual/batch.http @@ -0,0 +1,101 @@ +### +# @name Upload a JSONL file for batch processing +POST {{omniroute-address}}/v1/files +Authorization: Bearer {{OMNIROUTE_API_KEY}} +Content-Type: multipart/form-data; boundary=boundary + +--boundary +Content-Disposition: form-data; name="file"; filename="batch_input.jsonl" +Content-Type: application/jsonl + +{"custom_id": "request-1", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "gpt-4o-mini", "messages": [{"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "Hello world!"}], "max_tokens": 50}} +{"custom_id": "request-2", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "gemini/gemma-4-31b-it", "messages": [{"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "What is 2+2?"}], "max_tokens": 50}} + +--boundary +Content-Disposition: form-data; name="purpose" + +batch +--boundary-- + +> {% + client.global.set("file_id", response.body.id); +%} + +### +# @name List all uploaded files +GET {{omniroute-address}}/v1/files +Authorization: Bearer {{OMNIROUTE_API_KEY}} + +> {% + client.global.set("file_id", response.body.data[0].id); +%} + +### +# @name Get file metadata of the uploaded file +GET {{omniroute-address}}/v1/files/{{file_id}} +Authorization: Bearer {{OMNIROUTE_API_KEY}} + +### +# @name Create a new batch job +POST {{omniroute-address}}/v1/batches +Authorization: Bearer {{OMNIROUTE_API_KEY}} +Content-Type: application/json + +{ + "input_file_id": "{{file_id}}", + "endpoint": "/v1/chat/completions", + "completion_window": "24h" +} + +> {% + client.global.set("batch_id", response.body.id); +%} + +### +# @name List all batch jobs +GET {{omniroute-address}}/v1/batches +Authorization: Bearer {{OMNIROUTE_API_KEY}} + +### +# @name Get status of batch job +GET {{omniroute-address}}/v1/batches/{{batch_id}} +Authorization: Bearer {{OMNIROUTE_API_KEY}} + +> {% + if (response.body.output_file_id) { + client.global.set("output_file_id", response.body.output_file_id); + } + if (response.body.error_file_id) { + client.global.set("error_file_id", response.body.error_file_id); + } +%} + +### +# @name Cancel batch job +POST {{omniroute-address}}/v1/batches/{{batch_id}}/cancel +Authorization: Bearer {{OMNIROUTE_API_KEY}} + +### +# @name Get outputfile contents +GET {{omniroute-address}}/v1/files/{{output_file_id}}/content +Authorization: Bearer {{OMNIROUTE_API_KEY}} + +### +# @name Get outputfile metadata +GET {{omniroute-address}}/v1/files/{{output_file_id}} +Authorization: Bearer {{OMNIROUTE_API_KEY}} + +### +# @name Get errorfile contents +GET {{omniroute-address}}/v1/files/{{error_file_id}}/content +Authorization: Bearer {{OMNIROUTE_API_KEY}} + +### +# @name Get errorfile metadata +GET {{omniroute-address}}/v1/files/{{error_file_id}} +Authorization: Bearer {{OMNIROUTE_API_KEY}} + +### +# @name Delete File +DELETE {{omniroute-address}}/v1/files/{{file_id}} +Authorization: Bearer {{OMNIROUTE_API_KEY}} diff --git a/tests/unit/api-auth.test.ts b/tests/unit/api-auth.test.ts index 4b1ec7540b..748de02bfb 100644 --- a/tests/unit/api-auth.test.ts +++ b/tests/unit/api-auth.test.ts @@ -26,10 +26,10 @@ async function resetStorage() { delete process.env.INITIAL_PASSWORD; } -function makeCookieRequest(token) { +function makeCookieRequest(token: string) { return { cookies: { - get(name) { + get(name: string) { return name === "auth_token" && token ? { value: token } : undefined; }, }, @@ -195,4 +195,31 @@ test("isAuthRequired stays enabled when INITIAL_PASSWORD is present", async () = const result = await apiAuth.isAuthRequired(); assert.equal(result, true); + + delete process.env.INITIAL_PASSWORD; +}); + +test("getApiKeyMetadata recognizes OMNIROUTE_API_KEY environment variable", async () => { + const envKey = "sk-test-env-key-" + Date.now(); + process.env.OMNIROUTE_API_KEY = envKey; + + const metadata = await apiKeysDb.getApiKeyMetadata(envKey); + + assert.ok(metadata); + assert.equal(metadata.id, "env-key"); + assert.equal(metadata.name, "Environment Key"); + + delete process.env.OMNIROUTE_API_KEY; +}); + +test("getApiKeyMetadata recognizes ROUTER_API_KEY environment variable", async () => { + const envKey = "sk-test-router-key-" + Date.now(); + process.env.ROUTER_API_KEY = envKey; + + const metadata = await apiKeysDb.getApiKeyMetadata(envKey); + + assert.ok(metadata); + assert.equal(metadata.id, "env-key"); + + delete process.env.ROUTER_API_KEY; }); diff --git a/tests/unit/batch_api.test.ts b/tests/unit/batch_api.test.ts new file mode 100644 index 0000000000..7b9920b643 --- /dev/null +++ b/tests/unit/batch_api.test.ts @@ -0,0 +1,621 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { createFile, createBatch, getBatch, getFileContent, updateBatch, createProviderConnection, createApiKey, getFile, listFiles, formatFileResponse, deleteFile } from "../../src/lib/localDb"; +import { getDbInstance } from "@/lib/db/core.ts"; +import { initBatchProcessor, stopBatchProcessor } from "../../open-sse/services/batchProcessor"; + +test("Batch API and Processing", async () => { + // 0. Setup environment, mock provider and API key + process.env.API_KEY_SECRET = "test-secret-123"; + + await createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "Mock OpenAI", + apiKey: "sk-mock-key", + isActive: true + }); + + const apiKey = await createApiKey("Test Key", "test-machine"); + + // 1. Create a file with batch items + const batchItems = [ + JSON.stringify({ + custom_id: "request-1", + method: "POST", + url: "/v1/chat/completions", + body: { model: "gpt-4o-mini", messages: [{ role: "user", content: "Hello world" }] } + }), + JSON.stringify({ + custom_id: "request-2", + method: "POST", + url: "/v1/chat/completions", + body: { model: "gpt-4o-mini", messages: [{ role: "user", content: "Goodbye world" }] } + }) + ].join("\n"); + + const file = createFile({ + bytes: Buffer.byteLength(batchItems), + filename: "test_batch.jsonl", + purpose: "batch", + content: Buffer.from(batchItems), + apiKeyId: apiKey.id + }); + + assert.ok(file.id.startsWith("file-"), "File ID should start with file-"); + assert.ok(file.status === "validating" || !file.status, "File status should be 'validating' by default or null"); + + // 2. Create a batch + const batch = createBatch({ + endpoint: "/v1/chat/completions", + completionWindow: "24h", + inputFileId: file.id, + apiKeyId: apiKey.id + }); + + assert.ok(batch.id.startsWith("batch_"), "Batch ID should start with batch_"); + assert.strictEqual(batch.status, "validating"); + + // 3. Start the processor manually for one tick (or wait if we used initBatchProcessor) + // For testing, we might want to expose the processing functions or just wait. + // We'll use a shorter interval in the processor if we want to test polling. + // Here we'll just call processPendingBatches if it was exported, but it's not. + + // Instead of polling, let's just wait a bit if we started the processor + initBatchProcessor(); + + console.log("Waiting for batch processing..."); + + // Poll for status change + let maxAttempts = 30; + let currentBatch = getBatch(batch.id); + while (maxAttempts > 0 && currentBatch?.status !== "completed" && currentBatch?.status !== "failed" && currentBatch?.status !== "cancelled") { + await new Promise(resolve => setTimeout(resolve, 2000)); + currentBatch = getBatch(batch.id); + const progress = currentBatch?.requestCountsTotal ? `${currentBatch.requestCountsCompleted}/${currentBatch.requestCountsTotal}` : "not started"; + console.log(`[TEST] Current status: ${currentBatch?.status}, completed: ${progress}, failed: ${currentBatch?.requestCountsFailed || 0}`); + maxAttempts--; + } + + // Stop the processor so the test can exit + stopBatchProcessor(); + + if (maxAttempts === 0) { + console.error("[TEST] Polling timed out. Final batch state:", JSON.stringify(currentBatch, null, 2)); + } + + assert.ok(currentBatch?.status === "completed" || currentBatch?.status === "failed", "Batch should reach a terminal state"); + + // In test environment, the mock key might fail, which is fine for this test as long as it finishes + if (currentBatch?.status === "failed" || currentBatch?.requestCountsFailed > 0) { + console.warn("[TEST] Batch finished with failures (likely due to mock credentials). This is acceptable for this test."); + assert.strictEqual(currentBatch?.requestCountsTotal, 2, "Total requests should be 2"); + assert.strictEqual((currentBatch?.requestCountsCompleted || 0) + (currentBatch?.requestCountsFailed || 0), 2, "Total processed should be 2"); + return; + } + + assert.strictEqual(currentBatch?.status, "completed", "Batch should be completed"); + assert.strictEqual(currentBatch?.requestCountsTotal, 2); + assert.strictEqual(currentBatch?.requestCountsCompleted, 2); + assert.ok(currentBatch?.outputFileId, "Should have output file ID"); + + // Check file statuses + const inputFileAfter = getFile(file.id); + assert.strictEqual(inputFileAfter?.status, "processed", "Input file should be 'processed' after batch completion"); + + const outputFile = getFile(currentBatch.outputFileId!); + assert.strictEqual(outputFile?.status, "completed", "Output file should be 'completed'"); + + // 4. Check output file content + if (currentBatch?.outputFileId) { + const outputContent = getFileContent(currentBatch.outputFileId); + assert.ok(outputContent, "Output file content should exist"); + const lines = outputContent.toString().split("\n").filter(l => l.trim()); + assert.strictEqual(lines.length, 2, "Should have 2 result lines"); + const firstResult = JSON.parse(lines[0]); + assert.ok(firstResult.custom_id, "Result should have custom_id"); + assert.ok(firstResult.response, "Result should have response"); + } + + // 5. Check additional spec-compliant fields + assert.ok(currentBatch.usage, "Batch should have usage populated"); + assert.strictEqual(typeof currentBatch.usage.total_tokens, "number", "usage.total_tokens should be a number"); + assert.ok(currentBatch.model || currentBatch.requestCountsFailed > 0, "Batch should have model populated if at least one request succeeded"); +}); + +test("Batch handles and counts failures correctly", async () => { + initBatchProcessor(); + try { + // 1. Create a file with a request that will fail (invalid provider/model) + const batchItems = [ + JSON.stringify({ + custom_id: "fail-request", + method: "POST", + url: "/v1/chat/completions", + body: { model: "non-existent-provider/model", messages: [{ role: "user", content: "Fail me" }] } + }) + ].join("\n"); + + const file = createFile({ + bytes: Buffer.byteLength(batchItems), + filename: "fail_batch.jsonl", + purpose: "batch", + content: Buffer.from(batchItems), + apiKeyId: null + }); + + // 2. Create a batch + const batch = createBatch({ + endpoint: "/v1/chat/completions", + completionWindow: "24h", + inputFileId: file.id, + apiKeyId: null + }); + + // 3. Poll for completion + let maxAttempts = 20; + let currentBatch = getBatch(batch.id); + while (maxAttempts > 0 && currentBatch?.status !== "completed" && currentBatch?.status !== "failed") { + await new Promise(resolve => setTimeout(resolve, 1000)); + currentBatch = getBatch(batch.id); + maxAttempts--; + } + + // 4. Verify failure counts + assert.strictEqual(currentBatch?.requestCountsTotal, 1, "Total should be 1"); + assert.strictEqual(currentBatch?.requestCountsCompleted, 0, "Completed should be 0"); + assert.strictEqual(currentBatch?.requestCountsFailed, 1, "Failed should be 1"); + assert.ok(currentBatch?.errorFileId, "Should have error file for failures"); + assert.ok(!currentBatch?.outputFileId, "Should NOT have output file if no successes"); + + if (currentBatch?.errorFileId) { + const errorContent = getFileContent(currentBatch.errorFileId); + const result = JSON.parse(errorContent.toString()); + assert.ok(result.response.status_code >= 400, `Status code ${result.response.status_code} should be >= 400`); + assert.ok(result.response.body.error, "Should contain error in body"); + } + + // Check file statuses + const inputFile = getFile(file.id); + assert.strictEqual(inputFile?.status, "processed", "Input file should be 'processed'"); + + const errorFile = getFile(currentBatch.errorFileId!); + assert.strictEqual(errorFile?.status, "completed", "Error file should be 'completed'"); + } finally { + stopBatchProcessor(); + } +}); + +test("Batch forces stream: false for all requests", async () => { + initBatchProcessor(); + try { + const batchItems = [ + JSON.stringify({ + custom_id: "stream-request", + method: "POST", + url: "/v1/chat/completions", + body: { + model: "gpt-4o-mini", + messages: [{ role: "user", content: "Hello" }], + stream: true + } + }) + ].join("\n"); + + const file = createFile({ + bytes: Buffer.byteLength(batchItems), + filename: "stream_force_batch.jsonl", + purpose: "batch", + content: Buffer.from(batchItems), + apiKeyId: null + }); + + const batch = createBatch({ + endpoint: "/v1/chat/completions", + completionWindow: "24h", + inputFileId: file.id, + apiKeyId: null + }); + + let maxAttempts = 20; + let currentBatch = getBatch(batch.id); + while (maxAttempts > 0 && currentBatch?.status !== "completed" && currentBatch?.status !== "failed") { + await new Promise(resolve => setTimeout(resolve, 1000)); + currentBatch = getBatch(batch.id); + maxAttempts--; + } + + assert.strictEqual(currentBatch?.status, "completed", "Batch should be completed"); + const outputFileId = currentBatch?.outputFileId || currentBatch?.errorFileId; + assert.ok(outputFileId, "Should have output or error file ID"); + const outputContent = getFileContent(outputFileId!); + const result = JSON.parse(outputContent.toString()); + + // It shouldn't have "Unexpected token d" error which happens if it tries to parse SSE stream as JSON + assert.ok(result.response.status_code !== 200 || result.response.body.choices, "Should be a valid chat completion response"); + if (result.response.body.error) { + assert.ok(!result.response.body.error.message.includes("Unexpected token"), "Should not have JSON parsing error from SSE stream"); + } + } finally { + stopBatchProcessor(); + } +}); + +test("Batch API response format is spec-compliant", async () => { + // This test doesn't need the processor to run as it checks the object structure returned by endpoints + const apiKey = await createApiKey("Spec Test Key", "test-machine"); + + // Create a mock file first to satisfy foreign key constraint + const file = createFile({ + bytes: 10, + filename: "mock.jsonl", + purpose: "batch", + content: Buffer.from("{}"), + apiKeyId: apiKey.id + }); + + // Create a mock batch directly + const batch = createBatch({ + endpoint: "/v1/chat/completions", + completionWindow: "24h", + inputFileId: file.id, + apiKeyId: apiKey.id, + metadata: { "test": "meta" } + }); + + // Mock an update with some counts and usage + updateBatch(batch.id, { + status: "completed", + requestCountsTotal: 10, + requestCountsCompleted: 8, + requestCountsFailed: 2, + model: "gpt-4o-mini", + usage: { + input_tokens: 100, + output_tokens: 50, + total_tokens: 150, + input_tokens_details: { cached_tokens: 10 }, + output_tokens_details: { reasoning_tokens: 5 } + } + }); + + const updatedBatch = getBatch(batch.id)!; + + // Test the formatter used in API routes (simulate the route's response) + function formatBatchResponse(batch: any) { + return { + id: batch.id, + object: "batch", + endpoint: batch.endpoint, + errors: batch.errors || null, + input_file_id: batch.inputFileId, + completion_window: batch.completionWindow, + status: batch.status, + output_file_id: batch.outputFileId || null, + error_file_id: batch.errorFileId || null, + created_at: batch.createdAt, + in_progress_at: batch.inProgressAt || null, + expires_at: batch.expiresAt || null, + finalizing_at: batch.finalizingAt || null, + completed_at: batch.completedAt || null, + failed_at: batch.failedAt || null, + expired_at: batch.expiredAt || null, + cancelling_at: batch.cancellingAt || null, + cancelled_at: batch.cancelledAt || null, + request_counts: { + total: batch.requestCountsTotal || 0, + completed: batch.requestCountsCompleted || 0, + failed: batch.requestCountsFailed || 0, + }, + metadata: batch.metadata || null, + model: batch.model || null, + usage: batch.usage || null, + }; + } + + const response = formatBatchResponse(updatedBatch); + + // Verify all required spec fields are present and structured correctly + assert.strictEqual(response.id, batch.id); + assert.strictEqual(response.object, "batch"); + assert.strictEqual(response.endpoint, "/v1/chat/completions"); + assert.strictEqual(response.completion_window, "24h"); + assert.strictEqual(response.status, "completed"); + assert.ok(response.request_counts, "Should have request_counts"); + assert.strictEqual(response.request_counts.total, 10); + assert.strictEqual(response.request_counts.completed, 8); + assert.strictEqual(response.request_counts.failed, 2); + assert.ok(response.usage, "Should have usage"); + assert.strictEqual(response.usage.total_tokens, 150); + assert.strictEqual(response.usage.input_tokens_details.cached_tokens, 10); + assert.strictEqual(response.usage.output_tokens_details.reasoning_tokens, 5); + assert.strictEqual(response.model, "gpt-4o-mini"); + assert.deepStrictEqual(response.metadata, { "test": "meta" }); +}); + +test("List batches pagination and response format", async () => { + const apiKey = await createApiKey("List Test Key", "test-machine"); + + // 1. Create multiple batches + const file = createFile({ + bytes: 10, + filename: "list_mock.jsonl", + purpose: "batch", + content: Buffer.from("{}"), + apiKeyId: apiKey.id + }); + + const batchIds: string[] = []; + for (let i = 0; i < 5; i++) { + const b = createBatch({ + endpoint: "/v1/chat/completions", + completionWindow: "24h", + inputFileId: file.id, + apiKeyId: apiKey.id, + metadata: { index: i } + }); + batchIds.push(b.id); + } + + // Sort batchIds in descending order as listBatches returns them by ID DESC + batchIds.sort().reverse(); + + // 2. Test listBatches logic (direct DB call) + const { listBatches } = await import("../../src/lib/localDb"); + const allBatches = listBatches(apiKey.id, 10); + assert.strictEqual(allBatches.length, 5); + assert.strictEqual(allBatches[0].id, batchIds[0]); + + // 3. Test pagination logic (as implemented in the route) + const limit = 2; + const batchesPage1 = listBatches(apiKey.id, limit + 1); + const hasMore1 = batchesPage1.length > limit; + const data1 = hasMore1 ? batchesPage1.slice(0, limit) : batchesPage1; + + assert.strictEqual(data1.length, 2); + assert.strictEqual(hasMore1, true); + assert.strictEqual(data1[0].id, batchIds[0]); + assert.strictEqual(data1[1].id, batchIds[1]); + + const after = data1[1].id; + const batchesPage2 = listBatches(apiKey.id, limit + 1, after); + const hasMore2 = batchesPage2.length > limit; + const data2 = hasMore2 ? batchesPage2.slice(0, limit) : batchesPage2; + + assert.strictEqual(data2.length, 2); + assert.strictEqual(hasMore2, true); + assert.strictEqual(data2[0].id, batchIds[2]); + assert.strictEqual(data2[1].id, batchIds[3]); + + const after2 = data2[1].id; + const batchesPage3 = listBatches(apiKey.id, limit + 1, after2); + const hasMore3 = batchesPage3.length > limit; + const data3 = hasMore3 ? batchesPage3.slice(0, limit) : batchesPage3; + + assert.strictEqual(data3.length, 1); + assert.strictEqual(hasMore3, false); + assert.strictEqual(data3[0].id, batchIds[4]); +}); + +test("Batch Cancel API", async () => { + const apiKey = await createApiKey("Cancel Test Key", "test-machine"); + + const file = createFile({ + bytes: 10, + filename: "cancel_mock.jsonl", + purpose: "batch", + content: Buffer.from("{}"), + apiKeyId: apiKey.id + }); + + const batch = createBatch({ + endpoint: "/v1/chat/completions", + completionWindow: "24h", + inputFileId: file.id, + apiKeyId: apiKey.id + }); + + // 1. Initially validating + assert.strictEqual(batch.status, "validating"); + + // 2. Cancel it + const cancellingAt = Math.floor(Date.now() / 1000); + updateBatch(batch.id, { + status: "cancelling", + cancellingAt + }); + + const updatedBatch = getBatch(batch.id)!; + assert.strictEqual(updatedBatch.status, "cancelling"); + assert.strictEqual(updatedBatch.cancellingAt, cancellingAt); + + // 3. Test that it can't be cancelled if already terminal + updateBatch(batch.id, { status: "completed" }); + const terminalBatch = getBatch(batch.id)!; + assert.strictEqual(terminalBatch.status, "completed"); + + // In actual API this would return 400, here we just verify state logic + const canCancel = !["completed", "failed", "cancelled", "expired"].includes(terminalBatch.status); + assert.strictEqual(canCancel, false); +}); + +test("List files pagination and response format", async () => { + const apiKey = await createApiKey("File List Test Key", "test-machine"); + + // 1. Create multiple files + const fileIds: string[] = []; + for (let i = 0; i < 5; i++) { + const f = createFile({ + bytes: 10 + i, + filename: `file_${i}.jsonl`, + purpose: i % 2 === 0 ? "batch" : "fine-tune", + content: Buffer.from("{}"), + apiKeyId: apiKey.id + }); + fileIds.push(f.id); + } + + // Default order is DESC (by created_at, then ID) + const allFilesSorted = listFiles({ apiKeyId: apiKey.id, order: "desc" }); + const sortedFileIds = allFilesSorted.map(f => f.id); + + // 2. Test listFiles options + assert.strictEqual(allFilesSorted.length, 5); + assert.strictEqual(allFilesSorted[0].id, sortedFileIds[0]); + + // 3. Test filtering by purpose + const batchFiles = listFiles({ apiKeyId: apiKey.id, purpose: "batch" }); + assert.strictEqual(batchFiles.length, 3); // 0, 2, 4 + assert.ok(batchFiles.every(f => f.purpose === "batch")); + + // 4. Test pagination + const limit = 2; + const page1 = listFiles({ apiKeyId: apiKey.id, limit }); + assert.strictEqual(page1.length, 2); + assert.strictEqual(page1[0].id, sortedFileIds[0]); + assert.strictEqual(page1[1].id, sortedFileIds[1]); + + const after = page1[1].id; + const page2 = listFiles({ apiKeyId: apiKey.id, limit, after }); + assert.strictEqual(page2.length, 2); + assert.strictEqual(page2[0].id, sortedFileIds[2]); + assert.strictEqual(page2[1].id, sortedFileIds[3]); + + const after2 = page2[1].id; + const page3 = listFiles({ apiKeyId: apiKey.id, limit, after: after2 }); + assert.strictEqual(page3.length, 1); + assert.strictEqual(page3[0].id, sortedFileIds[4]); + + // 5. Test sorting + const ascFiles = listFiles({ apiKeyId: apiKey.id, order: "asc" }); + assert.strictEqual(ascFiles.length, 5); + assert.strictEqual(ascFiles[0].id, [...sortedFileIds].reverse()[0]); +}); + +test("File upload with expiration and spec-compliant response", async () => { + const apiKey = await createApiKey("File Upload Test Key", "test-machine"); + + // Simulate File object (Next.js File) + const content = Buffer.from("test content"); + const mockFile = { + size: content.length, + name: "test.txt", + type: "text/plain" + }; + + // We'll test the DB logic and the formatting logic separately + // as it's hard to call the Next.js route directly in this unit test. + + const expiresAfterSeconds = 3600; + const expiresAt = Math.floor(Date.now() / 1000) + expiresAfterSeconds; + + const record = createFile({ + bytes: mockFile.size, + filename: mockFile.name, + purpose: "batch", + content: content, + mimeType: mockFile.type, + apiKeyId: apiKey.id, + expiresAt: expiresAt + }); + + assert.strictEqual(record.expiresAt, expiresAt); + + const response = formatFileResponse(record); + assert.strictEqual(response.id, record.id); + assert.strictEqual(response.object, "file"); + assert.strictEqual(response.expires_at, expiresAt); + assert.strictEqual(response.status, "validating"); + assert.ok(!("content" in response), "Response should not contain content"); + assert.ok(!("apiKeyId" in response), "Response should not contain apiKeyId"); +}); + +test("Retrieve file spec compliance", async () => { + const apiKey = await createApiKey("File Retrieve Test Key", "test-machine"); + + const record = createFile({ + bytes: 123, + filename: "retrieve_test.jsonl", + purpose: "batch", + content: Buffer.from("{}"), + apiKeyId: apiKey.id, + status: "processed" + }); + + const response = formatFileResponse(record); + + // Check all required fields from the spec + assert.strictEqual(response.id, record.id); + assert.strictEqual(response.bytes, 123); + assert.strictEqual(typeof response.created_at, "number"); + assert.strictEqual(response.filename, "retrieve_test.jsonl"); + assert.strictEqual(response.object, "file"); + assert.strictEqual(response.purpose, "batch"); + assert.strictEqual(response.status, "processed"); + assert.strictEqual(response.expires_at, null); + + // Ensure no internal fields leak + assert.ok(!("content" in (response as any))); + assert.ok(!("apiKeyId" in (response as any))); + assert.ok(!("mimeType" in (response as any))); +}); + +test("File deletion", async () => { + const apiKey = await createApiKey("File Delete Test Key", "test-machine"); + + const record = createFile({ + bytes: 123, + filename: "delete_test.jsonl", + purpose: "batch", + content: Buffer.from("{}"), + apiKeyId: apiKey.id + }); + + const fileBefore = getFile(record.id); + assert.ok(fileBefore !== null); + assert.strictEqual(fileBefore.id, record.id); + + const deleted = deleteFile(record.id); + assert.ok(deleted); + + const fileAfter = getFile(record.id); + assert.strictEqual(fileAfter, null); + + // Verify deletion of content for security + const db = getDbInstance(); + const row = db.prepare("SELECT content, deleted_at FROM files WHERE id = ?").get(record.id) as any; + assert.ok(row !== undefined); + assert.strictEqual(row.content, null); + assert.ok(row.deleted_at !== null); +}); + +test("Retrieve file content spec compliance", async () => { + const apiKey = await createApiKey("File Content Test Key", "test-machine"); + const content = Buffer.from('{"id":"req_1","custom_id":"request-1","response":{"status_code":200,"body":{"choices":[{"message":{"content":"Hello"}}]}}}'); + + const record = createFile({ + bytes: content.length, + filename: "content_test.jsonl", + purpose: "batch", + content: content, + mimeType: "application/jsonl", + apiKeyId: apiKey.id + }); + + const retrievedContent = getFileContent(record.id); + assert.ok(retrievedContent !== null); + assert.deepStrictEqual(retrievedContent, content); + + // Verify ownership check logic (similar to route.ts) + const file = getFile(record.id); + assert.ok(file !== null); + assert.strictEqual(file.apiKeyId, apiKey.id); + + // Verify cross-key access failure + const otherApiKey = await createApiKey("Other Key", "other-machine"); + assert.ok(file.apiKeyId !== otherApiKey.id); + + // In the route, this would return 404 + const unauthorized = (file.apiKeyId !== null && file.apiKeyId !== otherApiKey.id); + assert.ok(unauthorized); +}); From 3502c5afd148952ce052619340d385b7f1d3d8cf Mon Sep 17 00:00:00 2001 From: clousky2020 <33016567+clousky2020@users.noreply.github.com> Date: Wed, 22 Apr 2026 04:18:23 +0800 Subject: [PATCH 026/281] fix(fallback): use shared CircuitBreaker instead of undefined constants (#1485) Integrated into release/v3.7.0 --- open-sse/services/accountFallback.ts | 77 +++++----------------------- 1 file changed, 14 insertions(+), 63 deletions(-) diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index fcaecd09f6..0714f979c9 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -49,24 +49,9 @@ type ModelFailureState = { }; // Provider-level failure tracking for circuit breaker behavior -type ProviderFailureEntry = { - failureCount: number; - lastFailureAt: number; - resetAfterMs: number; - cooldownUntil: number | null; -}; - // Error codes that count toward provider-level failure threshold const PROVIDER_FAILURE_ERROR_CODES = new Set([408, 500, 502, 503, 504]); -// Provider-level failure state map: providerId -> failure entry -const providerFailureState = new Map(); -// Guard against synchronous re-entrant calls within the same event-loop tick. -// NOT a true mutex — Node.js is single-threaded, so different SSE streams -// can interleave across ticks. This Set prevents a single call from recursively -// re-entering recordProviderFailure within the same synchronous call stack. -const providerFailureLocks = new Set(); - // T06 (sub2api PR #1037): Signals that indicate permanent account deactivation. // When a 401 body contains these strings, the account is permanently dead // and should NOT be retried after token refresh. @@ -529,6 +514,13 @@ export function getProviderCooldownRemainingMs(provider: string | null | undefin /** * Record a provider failure against the shared circuit breaker. + * Delegates to the existing CircuitBreaker utility which handles + * failure counting, threshold detection, and state transitions. + * + * IMPORTANT: If the breaker is already OPEN (in cooldown), we skip + * recording the failure to prevent resetting the cooldown timer. + * This matches the original behavior where failures during cooldown + * were ignored to avoid indefinite lockout. */ export function recordProviderFailure( provider: string | null | undefined, @@ -536,57 +528,16 @@ export function recordProviderFailure( ): void { if (!provider) return; - // Guard against concurrent re-entrant calls within the same tick - if (providerFailureLocks.has(provider)) return; - providerFailureLocks.add(provider); + const breaker = getProviderBreaker(provider); + if (!breaker) return; - try { - const now = Date.now(); - const entry = providerFailureState.get(provider); + // Skip if already in cooldown to prevent timer reset (indefinite lockout bug) + if (!breaker.canExecute()) return; - // Check if we're in cooldown period - if (entry && entry.cooldownUntil !== null && now < entry.cooldownUntil) { - return; // Already in cooldown, don't record - } + breaker._onFailure(); - // Check if failure window has expired - if (entry && now - entry.lastFailureAt > entry.resetAfterMs) { - // Window expired, reset count - providerFailureState.set(provider, { - failureCount: 1, - lastFailureAt: now, - resetAfterMs: PROVIDER_FAILURE_WINDOW_MS, - cooldownUntil: null, - }); - return; - } - - // Increment failure count - const newCount = entry ? entry.failureCount + 1 : 1; - - if (newCount >= PROVIDER_FAILURE_THRESHOLD) { - // Threshold reached, enter cooldown - const cooldownUntil = now + PROVIDER_COOLDOWN_MS; - providerFailureState.set(provider, { - failureCount: newCount, - lastFailureAt: now, - resetAfterMs: PROVIDER_FAILURE_WINDOW_MS, - cooldownUntil, - }); - log?.warn?.( - `[ProviderFailure] ${provider}: ${newCount} failures in ${PROVIDER_FAILURE_WINDOW_MS / 1000}s — entering ${PROVIDER_COOLDOWN_MS / 1000}s cooldown` - ); - } else { - // Just increment counter - providerFailureState.set(provider, { - failureCount: newCount, - lastFailureAt: now, - resetAfterMs: PROVIDER_FAILURE_WINDOW_MS, - cooldownUntil: null, - }); - } - } finally { - providerFailureLocks.delete(provider); + if (!breaker.canExecute()) { + log?.warn?.(`[ProviderFailure] ${provider}: circuit breaker opened after repeated failures`); } } From 9cd36af3e8bcd7c73feec83bf5c962efb30e6dad Mon Sep 17 00:00:00 2001 From: vanminhph Date: Wed, 22 Apr 2026 03:18:26 +0700 Subject: [PATCH 027/281] fix(codex): preserve namespace MCP tools forwarded to Codex Responses API (#1483) Integrated into release/v3.7.0 --- open-sse/executors/codex.ts | 14 ++++++++++++++ open-sse/translator/request/openai-responses.ts | 4 +++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index fcbcbab1cd..294c4e6ba5 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -336,6 +336,20 @@ function normalizeCodexTools(body: Record): void { } const tool = toolValue as Record; + + // Preserve namespace tools (MCP tool groups used by Codex/OpenAI Responses API). + // Codex API supports them natively; register sub-tool names for tool_choice validation. + if (tool.type === "namespace" && Array.isArray(tool.tools)) { + for (const st of tool.tools as unknown[]) { + if (st && typeof st === "object" && !Array.isArray(st)) { + const subTool = st as Record; + const name = typeof subTool.name === "string" ? subTool.name.trim() : ""; + if (name) validToolNames.add(name); + } + } + return true; + } + if (tool.type !== "function") { return false; } diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index e4f3a4b4c8..5cd5da520d 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -55,12 +55,14 @@ export function openaiResponsesToOpenAIRequest( for (const toolValue of tools) { const tool = toRecord(toolValue); const toolType = toString(tool.type); - // Allow: function tools, tools already in Chat format (have .function property), and CLI subagent tools + // Allow: function tools, tools already in Chat format (have .function property), CLI subagent tools, + // and namespace tools (MCP tool groups used by Codex/OpenAI Responses API). if ( toolType && toolType !== "function" && toolType !== "custom" && toolType !== "command" && + toolType !== "namespace" && !tool.function ) { throw unsupportedFeature( From de1bea8227cae011612fa9a872d8b2ba18502a20 Mon Sep 17 00:00:00 2001 From: cloudy <37777261+uwuclxdy@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:18:29 +0200 Subject: [PATCH 028/281] fix: deduplicate case-variant anthropic-version header in Claude Code patch (#1481) Integrated into release/v3.7.0 --- open-sse/executors/base.ts | 11 ++++++ tests/unit/executor-default-base.test.ts | 48 ++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 2857a087f9..38c9d62dfc 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -531,6 +531,17 @@ export class BaseExecutor { "x-client-request-id": randomUUID(), "X-Claude-Code-Session-Id": randomUUID(), }; + // Remove any existing case variants of ccHeaders keys before merging. + // The claude provider config sets "Anthropic-Version" (Title-Case) while + // ccHeaders uses all-lowercase keys. Both JS keys normalise to the same + // HTTP header name, so undici would combine them into "2023-06-01, 2023-06-01" + // causing a 400 from Anthropic (see issue #1454). + const ccKeysLower = new Set(Object.keys(ccHeaders).map((k) => k.toLowerCase())); + for (const key of Object.keys(headers)) { + if (ccKeysLower.has(key.toLowerCase())) { + delete headers[key]; + } + } Object.assign(headers, ccHeaders); delete headers["X-Stainless-Helper-Method"]; diff --git a/tests/unit/executor-default-base.test.ts b/tests/unit/executor-default-base.test.ts index a76ddfbafe..5d1ce319f9 100644 --- a/tests/unit/executor-default-base.test.ts +++ b/tests/unit/executor-default-base.test.ts @@ -789,3 +789,51 @@ test("BaseExecutor.execute clears the startup timeout after headers arrive", asy globalThis.fetch = originalFetch; } }); + +// Regression test for issue #1454: duplicate anthropic-version header when +// Claude Code CLI headers are detected on the native `claude` provider. +// The provider config seeds headers with Title-Case "Anthropic-Version" while +// the Claude-Code patch injects lowercase "anthropic-version". Before the fix, +// both keys coexisted in the JS object and undici combined their values into +// "2023-06-01, 2023-06-01", causing a 400 from Anthropic. +test("DefaultExecutor.execute does not produce duplicate anthropic-version header when Claude Code CLI headers are present", async () => { + const executor = new DefaultExecutor("claude"); + const originalFetch = globalThis.fetch; + let capturedHeaders: Record = {}; + + globalThis.fetch = async (_url, init = {}) => { + // Capture raw headers without normalisation so case-variant duplicate keys are visible. + capturedHeaders = (init.headers as Record) || {}; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + try { + await executor.execute({ + model: "claude-sonnet-4-6", + body: { + model: "claude-sonnet-4-6", + messages: [{ role: "user", content: "hi" }], + max_tokens: 1, + }, + stream: false, + credentials: { accessToken: "oauth-token" }, + clientHeaders: { + "x-app": "cli", + "user-agent": "claude-cli/2.1.116 (external, cli)", + "anthropic-beta": "oauth-2025-04-20", + }, + }); + } finally { + globalThis.fetch = originalFetch; + } + + // Must be exactly one key — not multiple case variants that undici would combine + const versionKeys = Object.keys(capturedHeaders).filter( + (k) => k.toLowerCase() === "anthropic-version" + ); + assert.equal(versionKeys.length, 1, "Duplicate anthropic-version header keys found"); + assert.equal(capturedHeaders[versionKeys[0]], "2023-06-01"); +}); From 495ca290e016c359d7f726e53a8580fba61e6eff Mon Sep 17 00:00:00 2001 From: Jason Landbridge Date: Tue, 21 Apr 2026 22:18:34 +0200 Subject: [PATCH 029/281] docs: add Arch Linux AUR install notes (#1478) Integrated into release/v3.7.0 --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 351e7e4785..ca3a177c2b 100644 --- a/README.md +++ b/README.md @@ -775,6 +775,15 @@ omniroute Dashboard opens at `http://localhost:20128` and API base URL is `http://localhost:20128/v1`. +#### Arch Linux (AUR) + +Arch Linux users can install the [AUR package](https://aur.archlinux.org/packages/omniroute-bin), which installs OmniRoute and provides a systemd user service: + +```bash +yay -S omniroute-bin +systemctl --user enable --now omniroute.service +``` + | Command | Description | | ----------------------- | ----------------------------------------------------------- | | `omniroute` | Start server (`PORT=20128`, API and dashboard on same port) | From 0d852ab3e052bc3eba83ea7d7f3aa096e4921135 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 21 Apr 2026 17:46:47 -0300 Subject: [PATCH 030/281] fix: restore local test fixes for encryption and resilience --- src/lib/db/encryption.ts | 54 ++++++++++----------- tests/e2e/resilience-plan-alignment.spec.ts | 4 +- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/src/lib/db/encryption.ts b/src/lib/db/encryption.ts index 80a27aba7b..f322ceb54a 100644 --- a/src/lib/db/encryption.ts +++ b/src/lib/db/encryption.ts @@ -149,41 +149,41 @@ export function decrypt(ciphertext: string | null | undefined): string | null | const [ivHex, encryptedHex, authTagHex] = parts; - try { - const iv = Buffer.from(ivHex, "hex"); - const authTag = Buffer.from(authTagHex, "hex"); - const decipher = createDecipheriv(ALGORITHM, key, iv); - decipher.setAuthTag(authTag); - - let decrypted = decipher.update(encryptedHex, "hex", "utf8"); + const tryDecryptWithKey = (candidateKey: Buffer): string | null => { try { + const iv = Buffer.from(ivHex, "hex"); + const authTag = Buffer.from(authTagHex, "hex"); + const decipher = createDecipheriv(ALGORITHM, candidateKey, iv); + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(encryptedHex, "hex", "utf8"); decrypted += decipher.final("utf8"); - } catch (finalErr: unknown) { - const finalMessage = finalErr instanceof Error ? finalErr.message : String(finalErr); - console.error( - `[Encryption] Decryption final() failed: ${finalMessage}. ` + - `Ciphertext prefix: ${ciphertext.slice(0, 30)}... ` + - `Auth tag validation likely failed.` - ); - return ciphertext; + return decrypted; + } catch { + return null; } - return decrypted; - } catch (err: unknown) { + }; + + try { + const decrypted = tryDecryptWithKey(key); + if (decrypted !== null) { + return decrypted; + } + const legacyKey = getLegacyKey(); if (legacyKey) { - try { - const iv = Buffer.from(ivHex, "hex"); - const authTag = Buffer.from(authTagHex, "hex"); - const legacyDecipher = createDecipheriv(ALGORITHM, legacyKey, iv); - legacyDecipher.setAuthTag(authTag); - - let legacyDecrypted = legacyDecipher.update(encryptedHex, "hex", "utf8"); - legacyDecrypted += legacyDecipher.final("utf8"); + const legacyDecrypted = tryDecryptWithKey(legacyKey); + if (legacyDecrypted !== null) { return legacyDecrypted; - } catch (legacyErr) { - // Fallback failed as well } } + + console.error( + `[Encryption] Decryption failed. Ciphertext prefix: ${ciphertext.slice(0, 30)}... ` + + `Auth tag validation likely failed.` + ); + return null; + } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); console.error("[Encryption] Decryption failed:", message); // Return null instead of encrypted ciphertext to prevent sending encrypted tokens to providers diff --git a/tests/e2e/resilience-plan-alignment.spec.ts b/tests/e2e/resilience-plan-alignment.spec.ts index 817ebcf5bc..a8ce031ba5 100644 --- a/tests/e2e/resilience-plan-alignment.spec.ts +++ b/tests/e2e/resilience-plan-alignment.spec.ts @@ -305,7 +305,9 @@ test.describe("Resilience Plan Alignment", () => { await mockResilienceSettings(page); await gotoDashboardRoute(page, "/dashboard/settings?tab=resilience"); - await expect(page.getByText("Connection Cooldown")).toBeVisible({ timeout: 15000 }); + await expect( + page.getByRole("heading", { name: "Connection Cooldown", exact: true }) + ).toBeVisible({ timeout: 15000 }); await expect(page.getByText("Base cooldown", { exact: true }).first()).toBeVisible(); await expect(page.getByText("Use upstream retry hints", { exact: true }).first()).toBeVisible(); await expect(page.getByText("Max backoff steps", { exact: true }).first()).toBeVisible(); From 2e36599d40227c25b42bf4d0bd734a6623be53a8 Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Tue, 21 Apr 2026 22:53:19 +0200 Subject: [PATCH 031/281] fix: resolve code rebase issues --- open-sse/services/batchProcessor.ts | 63 +++++++++++++++++++---------- src/app/api/v1/files/route.ts | 12 +++++- src/lib/db/batches.ts | 8 ++++ src/lib/localDb.ts | 1 + tests/unit/batch_api.test.ts | 54 ++++++++++++++++++++++++- 5 files changed, 114 insertions(+), 24 deletions(-) diff --git a/open-sse/services/batchProcessor.ts b/open-sse/services/batchProcessor.ts index 5d98fd3c8e..315ba94a26 100644 --- a/open-sse/services/batchProcessor.ts +++ b/open-sse/services/batchProcessor.ts @@ -1,12 +1,12 @@ import { v4 as uuidv4 } from "uuid"; import { getPendingBatches, + getTerminalBatches, updateBatch, getFileContent, createFile, getApiKeyById, getBatch, - listBatches, listFiles, deleteFile, updateFileStatus, @@ -90,7 +90,7 @@ export async function processPendingBatches() { async function cleanupExpiredBatches() { try { const now = Math.floor(Date.now() / 1000); - const batches = listBatches(undefined, 200); // Check last 200 batches + const batches = getTerminalBatches(); const parseWindow = (window: string): number => { if (!window) return 86400; @@ -104,10 +104,9 @@ async function cleanupExpiredBatches() { return 86400; }; + // Delete files for terminal batches that have exceeded their completion window for (const batch of batches) { const windowSeconds = parseWindow(batch.completionWindow); - - // Cleanup completed/failed/cancelled batches after their completion window const completionTime = batch.completedAt || batch.failedAt || batch.cancelledAt || batch.expiredAt; if (completionTime && now - completionTime > windowSeconds) { @@ -115,9 +114,12 @@ async function cleanupExpiredBatches() { if (batch.outputFileId) deleteFile(batch.outputFileId); if (batch.errorFileId) deleteFile(batch.errorFileId); } + } - // Expire pending batches that have exceeded their completion window + // Expire validating batches that have exceeded their completion window + for (const batch of getPendingBatches()) { if (batch.status === "validating") { + const windowSeconds = parseWindow(batch.completionWindow); if (now - batch.createdAt > windowSeconds) { updateBatch(batch.id, { status: "expired", expiredAt: now }); } @@ -125,7 +127,8 @@ async function cleanupExpiredBatches() { } // Cleanup orphan files (batch-purpose files stuck in validating after 48h) - const allFiles = listFiles(); + // Use asc order so oldest files are processed first; use a high limit to avoid missing old orphans. + const allFiles = listFiles({ order: "asc", limit: 100 }); for (const file of allFiles) { if ( file.purpose === "batch" && @@ -162,8 +165,10 @@ async function startBatch(batch: any) { // Fire-and-forget: process items in the background so the poll loop isn't blocked. // isProcessing prevents a second poll tick from overlapping. - // noinspection ES6MissingAwait - processBatchItems(batch, lines); + processBatchItems(batch, lines).catch((err) => { + console.error(`[BATCH] Critical error in processBatchItems for ${batch.id}:`, err); + failBatch(batch.id, String(err)); + }); } catch (err) { console.error(`[BATCH] Error starting batch ${batch.id}:`, err); failBatch(batch.id, err instanceof Error ? err.message : String(err)); @@ -272,21 +277,37 @@ async function processBatchItems(batch: any, lines: string[]) { failedCount++; } - // Periodic progress update - updateBatch(batch.id, { - requestCountsCompleted: completedCount, - requestCountsFailed: failedCount, - model: usedModel, - usage: { - input_tokens: totalInputTokens, - output_tokens: totalOutputTokens, - total_tokens: totalInputTokens + totalOutputTokens, - input_tokens_details: { cached_tokens: 0 }, - output_tokens_details: { reasoning_tokens: totalReasoningTokens }, - }, - }); + // Throttle progress updates to every 50 items to reduce DB contention + if ((completedCount + failedCount) % 50 === 0) { + updateBatch(batch.id, { + requestCountsCompleted: completedCount, + requestCountsFailed: failedCount, + model: usedModel, + usage: { + input_tokens: totalInputTokens, + output_tokens: totalOutputTokens, + total_tokens: totalInputTokens + totalOutputTokens, + input_tokens_details: { cached_tokens: 0 }, + output_tokens_details: { reasoning_tokens: totalReasoningTokens }, + }, + }); + } } + // Final progress update to capture accurate counts before finalization + updateBatch(batch.id, { + requestCountsCompleted: completedCount, + requestCountsFailed: failedCount, + model: usedModel, + usage: { + input_tokens: totalInputTokens, + output_tokens: totalOutputTokens, + total_tokens: totalInputTokens + totalOutputTokens, + input_tokens_details: { cached_tokens: 0 }, + output_tokens_details: { reasoning_tokens: totalReasoningTokens }, + }, + }); + // Finalize await finalizeBatch(batch.id, results, errors); } diff --git a/src/app/api/v1/files/route.ts b/src/app/api/v1/files/route.ts index 4b5aa73355..8fe4f36565 100644 --- a/src/app/api/v1/files/route.ts +++ b/src/app/api/v1/files/route.ts @@ -32,10 +32,18 @@ export async function POST(request: Request) { const bytes = file.size; const filename = file.name; - const arrayBuffer = await file.arrayBuffer(); - const content = Buffer.from(arrayBuffer); const mimeType = file.type; + // Stream the upload into memory in chunks to avoid allocating a large contiguous ArrayBuffer + const reader = file.stream().getReader(); + const chunks: Uint8Array[] = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + const content = Buffer.concat(chunks.map((c) => Buffer.from(c))); + let expiresAt: number | undefined; if (expiresAfterAnchor === "created_at" && expiresAfterSeconds) { const seconds = Number.parseInt(expiresAfterSeconds); diff --git a/src/lib/db/batches.ts b/src/lib/db/batches.ts index 6df156b4c5..039ae0136e 100644 --- a/src/lib/db/batches.ts +++ b/src/lib/db/batches.ts @@ -140,3 +140,11 @@ export function getPendingBatches(): BatchRecord[] { ).all(); return rows.map(row => parseBatchRow(row)); } + +export function getTerminalBatches(): BatchRecord[] { + const db = getDbInstance(); + const rows = db.prepare( + "SELECT * FROM batches WHERE status IN ('completed', 'failed', 'cancelled', 'expired') ORDER BY created_at ASC" + ).all(); + return rows.map(row => parseBatchRow(row)); +} diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 08f809a737..d64857f4e3 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -220,6 +220,7 @@ export { updateBatch, listBatches, getPendingBatches, + getTerminalBatches, } from "./db/batches"; export type { FileRecord } from "./db/files"; diff --git a/tests/unit/batch_api.test.ts b/tests/unit/batch_api.test.ts index 7b9920b643..f40fee7b94 100644 --- a/tests/unit/batch_api.test.ts +++ b/tests/unit/batch_api.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert"; -import { createFile, createBatch, getBatch, getFileContent, updateBatch, createProviderConnection, createApiKey, getFile, listFiles, formatFileResponse, deleteFile } from "../../src/lib/localDb"; +import { createFile, createBatch, getBatch, getFileContent, updateBatch, createProviderConnection, createApiKey, getFile, listFiles, formatFileResponse, deleteFile, getTerminalBatches } from "../../src/lib/localDb"; import { getDbInstance } from "@/lib/db/core.ts"; import { initBatchProcessor, stopBatchProcessor } from "../../open-sse/services/batchProcessor"; @@ -619,3 +619,55 @@ test("Retrieve file content spec compliance", async () => { const unauthorized = (file.apiKeyId !== null && file.apiKeyId !== otherApiKey.id); assert.ok(unauthorized); }); + +test("getTerminalBatches returns only terminal statuses ordered oldest first", async () => { + const apiKey = await createApiKey("Terminal Batches Test Key", "test-machine"); + + const file = createFile({ + bytes: 10, + filename: "terminal_mock.jsonl", + purpose: "batch", + content: Buffer.from("{}"), + apiKeyId: apiKey.id + }); + + // Create batches in different terminal and non-terminal states + const completedBatch = createBatch({ endpoint: "/v1/chat/completions", completionWindow: "24h", inputFileId: file.id, apiKeyId: apiKey.id }); + updateBatch(completedBatch.id, { status: "completed", completedAt: Math.floor(Date.now() / 1000) }); + + const failedBatch = createBatch({ endpoint: "/v1/chat/completions", completionWindow: "24h", inputFileId: file.id, apiKeyId: apiKey.id }); + updateBatch(failedBatch.id, { status: "failed", failedAt: Math.floor(Date.now() / 1000) }); + + const cancelledBatch = createBatch({ endpoint: "/v1/chat/completions", completionWindow: "24h", inputFileId: file.id, apiKeyId: apiKey.id }); + updateBatch(cancelledBatch.id, { status: "cancelled", cancelledAt: Math.floor(Date.now() / 1000) }); + + const expiredBatch = createBatch({ endpoint: "/v1/chat/completions", completionWindow: "24h", inputFileId: file.id, apiKeyId: apiKey.id }); + updateBatch(expiredBatch.id, { status: "expired", expiredAt: Math.floor(Date.now() / 1000) }); + + // This one should NOT appear in terminal batches + const pendingBatch = createBatch({ endpoint: "/v1/chat/completions", completionWindow: "24h", inputFileId: file.id, apiKeyId: apiKey.id }); + + const terminalIds = new Set([completedBatch.id, failedBatch.id, cancelledBatch.id, expiredBatch.id]); + const terminal = getTerminalBatches(); + + // All returned batches must be terminal + for (const b of terminal) { + assert.ok( + ["completed", "failed", "cancelled", "expired"].includes(b.status), + `Unexpected status: ${b.status}` + ); + } + + // Our four terminal batches must all be present + for (const id of terminalIds) { + assert.ok(terminal.some(b => b.id === id), `Missing terminal batch ${id}`); + } + + // The pending batch must not appear + assert.ok(!terminal.some(b => b.id === pendingBatch.id), "Pending batch should not be in terminal list"); + + // Results must be ordered oldest first (created_at ASC) + for (let i = 1; i < terminal.length; i++) { + assert.ok(terminal[i].createdAt >= terminal[i - 1].createdAt, "Results should be ordered oldest first"); + } +}); From b925be27581cf0326c791a38382c04c6d1d6a388 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 21 Apr 2026 17:54:37 -0300 Subject: [PATCH 032/281] fix: update prompt injection test for fail-closed policy --- tests/unit/prompt-injection-guard.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/prompt-injection-guard.test.ts b/tests/unit/prompt-injection-guard.test.ts index af78a263df..ea3b41bcad 100644 --- a/tests/unit/prompt-injection-guard.test.ts +++ b/tests/unit/prompt-injection-guard.test.ts @@ -253,7 +253,7 @@ test("promptInjectionGuard: withInjectionGuard skips non-mutating methods", asyn assert.equal(response.status, 200); }); -test("promptInjectionGuard: withInjectionGuard fails open when the guard throws", async () => { +test("promptInjectionGuard: withInjectionGuard fails closed when the guard throws", async () => { const wrapped = withInjectionGuard(async () => new Response("passed", { status: 202 }), { mode: "block", }); @@ -267,5 +267,5 @@ test("promptInjectionGuard: withInjectionGuard fails open when the guard throws" const response = await wrapped(request, {}); - assert.equal(response.status, 202); + assert.equal(response.status, 500); }); From b709dca2d6d816fc518d37ab8fa535f0f011586e Mon Sep 17 00:00:00 2001 From: Hernan Javier Ardila Sanchez Date: Tue, 21 Apr 2026 23:14:24 +0200 Subject: [PATCH 033/281] feat(vision-bridge): add automatic image description fallback for non-vision models (#1476) * feat(vision-bridge): add automatic image description fallback for non-vision models Implements VisionBridgeGuardrail (priority 5) that intercepts image-bearing requests to non-vision models, extracts descriptions via a configurable vision model (default: gpt-4o-mini), and replaces images with text before forwarding. Fails open on any error. - Add VisionBridgeGuardrail class extending BaseGuardrail - Add visionBridgeHelpers: extractImageParts, callVisionModel, replaceImageParts, resolveImageAsDataUri - Add visionBridgeDefaults with configurable settings - Register VisionBridgeGuardrail in GuardrailRegistry at priority 5 - Add 51 unit tests covering all spec scenarios (VB-S01 through VB-S10) - Dependency injection for getSettings and callVisionModel (testable without SQLite) Closes diegosouzapw/OmniRoute#1424 * fix(vision-bridge): resolve Anthropic API, parallel processing, provider keys, structuredClone --- src/lib/guardrails/registry.ts | 2 + src/lib/guardrails/visionBridge.ts | 150 +++++ src/lib/guardrails/visionBridgeHelpers.ts | 330 +++++++++++ src/shared/constants/visionBridgeDefaults.ts | 55 ++ tests/unit/guardrails/visionBridge.test.ts | 515 ++++++++++++++++++ ...isionBridgeHelpers.callVisionModel.test.ts | 231 ++++++++ ...ionBridgeHelpers.extractImageParts.test.ts | 138 +++++ ...ionBridgeHelpers.replaceImageParts.test.ts | 254 +++++++++ ...ridgeHelpers.resolveImageAsDataUri.test.ts | 62 +++ tests/unit/visionBridgeDefaults.test.ts | 91 ++++ 10 files changed, 1828 insertions(+) create mode 100644 src/lib/guardrails/visionBridge.ts create mode 100644 src/lib/guardrails/visionBridgeHelpers.ts create mode 100644 src/shared/constants/visionBridgeDefaults.ts create mode 100644 tests/unit/guardrails/visionBridge.test.ts create mode 100644 tests/unit/guardrails/visionBridgeHelpers.callVisionModel.test.ts create mode 100644 tests/unit/guardrails/visionBridgeHelpers.extractImageParts.test.ts create mode 100644 tests/unit/guardrails/visionBridgeHelpers.replaceImageParts.test.ts create mode 100644 tests/unit/guardrails/visionBridgeHelpers.resolveImageAsDataUri.test.ts create mode 100644 tests/unit/visionBridgeDefaults.test.ts diff --git a/src/lib/guardrails/registry.ts b/src/lib/guardrails/registry.ts index 24ed1dc156..b90cd2147e 100644 --- a/src/lib/guardrails/registry.ts +++ b/src/lib/guardrails/registry.ts @@ -1,6 +1,7 @@ import { BaseGuardrail, type GuardrailContext, type GuardrailExecutionResult } from "./base"; import { PIIMaskerGuardrail } from "./piiMasker"; import { PromptInjectionGuardrail } from "./promptInjection"; +import { VisionBridgeGuardrail } from "./visionBridge"; type HeadersLike = Headers | Record | null | undefined; @@ -262,6 +263,7 @@ let defaultGuardrailsRegistered = false; export function registerDefaultGuardrails() { if (defaultGuardrailsRegistered) return guardrailRegistry; + guardrailRegistry.register(new VisionBridgeGuardrail()); guardrailRegistry.register(new PIIMaskerGuardrail()); guardrailRegistry.register(new PromptInjectionGuardrail()); defaultGuardrailsRegistered = true; diff --git a/src/lib/guardrails/visionBridge.ts b/src/lib/guardrails/visionBridge.ts new file mode 100644 index 0000000000..f9bcac35ca --- /dev/null +++ b/src/lib/guardrails/visionBridge.ts @@ -0,0 +1,150 @@ +/** + * Vision Bridge Guardrail. + * Intercepts image-bearing requests to non-vision models, + * extracts descriptions via vision model, and replaces images with text. + */ + +import { BaseGuardrail, type GuardrailContext, type GuardrailResult } from "./base"; +import { getSettings as defaultGetSettings } from "@/lib/db/settings"; +import { getResolvedModelCapabilities } from "@/lib/modelCapabilities"; +import { + extractImageParts, + callVisionModel as defaultCallVisionModel, + replaceImageParts, +} from "./visionBridgeHelpers"; +import { + VISION_BRIDGE_DEFAULTS, + getVisionBridgeConfig, +} from "@/shared/constants/visionBridgeDefaults"; + +export interface VisionBridgeDependencies { + getSettings?: () => Promise>; + callVisionModel?: ( + imageDataUri: string, + config: import("./visionBridgeHelpers").VisionModelConfig, + apiKey?: string + ) => Promise; +} + +export class VisionBridgeGuardrail extends BaseGuardrail { + name = "vision-bridge"; + priority = 5; + + private readonly deps: VisionBridgeDependencies; + + constructor(options?: { enabled?: boolean; deps?: VisionBridgeDependencies }) { + super("vision-bridge", { priority: 5, enabled: options?.enabled }); + this.deps = options?.deps ?? {}; + } + + async preCall( + payload: unknown, + context: GuardrailContext + ): Promise> { + // 1. Check if disabled at guardrail level + if (!this.enabled) { + return { block: false }; + } + + // 2. Check disabled via context (header, body, API key) + if (context.disabledGuardrails?.includes("vision-bridge")) { + return { block: false }; + } + + // 3. Get model from context or payload + const model = + context.model || (payload as Record)?.model as string | undefined; + if (!model) { + return { block: false }; + } + + // 4. Check if model supports vision + const capabilities = getResolvedModelCapabilities(model); + if (capabilities.supportsVision === true) { + return { block: false }; + } + + // 5. Get body and check for messages + const body = payload as Record; + const messages = body?.messages; + if (!Array.isArray(messages) || messages.length === 0) { + return { block: false }; + } + + // 6. Check for images using helper (extractImageParts returns empty if no images) + const imageParts = extractImageParts( + messages as Parameters[0] + ); + if (imageParts.length === 0) { + return { block: false }; + } + + // 7. Get settings (injectable for testing) + const getSettings = this.deps.getSettings ?? defaultGetSettings; + let settings: Record = {}; + try { + settings = await getSettings(); + } catch { + // If getSettings fails, use defaults + } + + // 8. Check if Vision Bridge is enabled in settings + const enabled = settings.visionBridgeEnabled ?? VISION_BRIDGE_DEFAULTS.enabled; + if (!enabled) { + return { block: false }; + } + + // 9. Get configuration + const config = getVisionBridgeConfig({ + visionBridgeEnabled: settings.visionBridgeEnabled as boolean | undefined, + visionBridgeModel: settings.visionBridgeModel as string | undefined, + visionBridgePrompt: settings.visionBridgePrompt as string | undefined, + visionBridgeTimeout: settings.visionBridgeTimeout as number | undefined, + visionBridgeMaxImages: settings.visionBridgeMaxImages as number | undefined, + }); + + // 10. Limit images + const limitedParts = imageParts.slice(0, config.maxImages); + + // 11. Call vision model for each image in parallel (injectable for testing) + const callVision = this.deps.callVisionModel ?? defaultCallVisionModel; + const logger = context.log; + const startTime = Date.now(); + + // Process all images in parallel using Promise.allSettled for fail-partial behavior + const results = await Promise.allSettled( + limitedParts.map(async (imagePart, i) => { + const description = await callVision(imagePart.imageUrl, config); + return `[Image ${i + 1}]: ${description}`; + }) + ); + + // Collect descriptions maintaining original order + const descriptions = results.map((result, i) => { + if (result.status === "fulfilled") { + return result.value; + } + const message = result.reason instanceof Error ? result.reason.message : String(result.reason); + logger?.warn?.("VISION-BRIDGE", `Failed to get description for image ${i + 1}: ${message}`); + return `[Image ${i + 1}]: (unavailable)`; + }); + + // 12. Replace image parts with text descriptions + const modifiedBody = replaceImageParts( + body as Parameters[0], + descriptions + ); + const processingTime = Date.now() - startTime; + + return { + block: false, + modifiedPayload: modifiedBody, + meta: { + imagesProcessed: descriptions.length, + descriptions, + processingTimeMs: processingTime, + visionModel: config.model, + }, + }; + } +} \ No newline at end of file diff --git a/src/lib/guardrails/visionBridgeHelpers.ts b/src/lib/guardrails/visionBridgeHelpers.ts new file mode 100644 index 0000000000..c0d28376b6 --- /dev/null +++ b/src/lib/guardrails/visionBridgeHelpers.ts @@ -0,0 +1,330 @@ +/** + * Vision Bridge helper functions for image processing. + */ + +/** + * Provider to environment variable mapping for API key resolution. + */ +const PROVIDER_API_KEY_MAP: Record = { + anthropic: "ANTHROPIC_API_KEY", + google: "GOOGLE_API_KEY", + openai: "OPENAI_API_KEY", +}; + +/** + * Resolve API key based on model provider. + * @param model - Model identifier (e.g., "anthropic/claude-3-haiku", "openai/gpt-4o-mini") + * @param explicitKey - Explicit API key passed as argument (takes precedence) + * @returns Resolved API key string + */ +export function resolveProviderApiKey(model: string, explicitKey?: string): string { + if (explicitKey) return explicitKey; + const provider = model.includes("/") ? model.split("/")[0] : ""; + const envVar = PROVIDER_API_KEY_MAP[provider] || "OPENAI_API_KEY"; + return process.env[envVar] || ""; +} + +export interface ImagePart { + messageIndex: number; + partIndex: number; + imageUrl: string; + imageType: "image_url" | "image"; +} + +export interface RequestMessage { + role?: string; + content?: string | RequestContentPart[]; +} + +export type RequestContentPart = + | { type: "text"; text: string } + | { type: "image_url"; image_url: { url: string; detail?: string } } + | { type: "image"; source: { type: "base64"; media_type: string; data: string } }; + +/** + * Extract image parts from messages array. + * Supports both OpenAI image_url format and base64 image format. + */ +export function extractImageParts(messages: RequestMessage[]): ImagePart[] { + const results: ImagePart[] = []; + + if (!Array.isArray(messages)) { + return results; + } + + for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) { + const message = messages[msgIdx]; + if (!message || !Array.isArray(message.content)) { + continue; + } + + for (let partIdx = 0; partIdx < message.content.length; partIdx++) { + const part = message.content[partIdx]; + + if (part?.type === "image_url" && part.image_url?.url) { + results.push({ + messageIndex: msgIdx, + partIndex: partIdx, + imageUrl: part.image_url.url, + imageType: "image_url", + }); + } else if (part?.type === "image" && part.source?.type === "base64") { + const { media_type, data } = part.source; + const dataUri = `data:${media_type};base64,${data}`; + results.push({ + messageIndex: msgIdx, + partIndex: partIdx, + imageUrl: dataUri, + imageType: "image", + }); + } + } + } + + return results; +} + +/** + * Resolve image URL to data URI format for vision model. + * - HTTP/HTTPS URLs: passed through as-is + * - Data URIs: passed through as-is + * - Base64 without media type: assumed PNG + */ +export function resolveImageAsDataUri(imageUrl: string): string { + if (!imageUrl || typeof imageUrl !== "string") { + throw new Error("Invalid image URL: must be a non-empty string"); + } + + // Already a data URI + if (imageUrl.startsWith("data:")) { + return imageUrl; + } + + // HTTP/HTTPS URL - vision API will fetch it + if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) { + return imageUrl; + } + + // Assume it's a base64 string without prefix + // Add PNG as default media type + return `data:image/png;base64,${imageUrl}`; +} + +export interface VisionModelConfig { + model: string; + prompt: string; + timeoutMs: number; + maxImages: number; +} + +/** + * Call the vision model to get an image description. + * Supports both OpenAI-compatible and Anthropic API formats. + */ +export async function callVisionModel( + imageDataUri: string, + config: VisionModelConfig, + apiKey?: string +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), config.timeoutMs); + + // Resolve API key based on provider + const resolvedApiKey = resolveProviderApiKey(config.model, apiKey); + + // Detect provider from model identifier + const isAnthropic = config.model.startsWith("anthropic/"); + + try { + // Extract model name from provider/model format + const modelName = config.model.includes("/") + ? config.model.split("/")[1] + : config.model; + + let response: Response; + + if (isAnthropic) { + // Anthropic API path + const anthropicBaseUrl = process.env.ANTHROPIC_API_URL || "https://api.anthropic.com"; + + // Parse data URI to extract media type and base64 data + const matches = imageDataUri.match(/^data:([^;]+);base64,(.+)$/); + let mediaType = "image/png"; + let base64Data = imageDataUri; + + if (matches) { + mediaType = matches[1]; + base64Data = matches[2]; + } + + response = await fetch(`${anthropicBaseUrl}/v1/messages`, { + method: "POST", + signal: controller.signal, + headers: { + "x-api-key": resolvedApiKey, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }, + body: JSON.stringify({ + model: modelName, + messages: [ + { + role: "user", + content: [ + { + type: "image", + source: { + type: "base64", + media_type: mediaType, + data: base64Data, + }, + }, + { + type: "text", + text: config.prompt, + }, + ], + }, + ], + max_tokens: 300, + }), + }); + } else { + // OpenAI-compatible path (default) + const baseUrl = process.env.OPENAI_API_URL || "https://api.openai.com/v1"; + + response = await fetch(`${baseUrl}/chat/completions`, { + method: "POST", + signal: controller.signal, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${resolvedApiKey}`, + }, + body: JSON.stringify({ + model: modelName, + messages: [ + { + role: "user", + content: [ + { + type: "image_url", + image_url: { + url: imageDataUri, + detail: "low", + }, + }, + { type: "text", text: config.prompt }, + ], + }, + ], + max_tokens: 300, + }), + }); + } + + clearTimeout(timeoutId); + + if (!response.ok) { + const errorText = await response.text().catch(() => "Unknown error"); + throw new Error(`Vision API error ${response.status}: ${errorText}`); + } + + const data = await response.json(); + + if (isAnthropic) { + // Anthropic response format: { content: [{ type: "text", text: "..." }] } + const anthropicData = data as { + content?: Array<{ type?: string; text?: string }>; + error?: { message?: string }; + }; + + if (anthropicData.error) { + throw new Error(`Vision API error: ${anthropicData.error.message || JSON.stringify(anthropicData.error)}`); + } + + const textContent = anthropicData.content?.find((c) => c.type === "text"); + const content = textContent?.text; + if (!content || typeof content !== "string") { + throw new Error("Vision API returned empty or invalid response"); + } + + return content.trim(); + } else { + // OpenAI-compatible response format: { choices: [{ message: { content: "..." } }] } + const openaiData = data as { + choices?: Array<{ message?: { content?: string } }>; + error?: { message?: string }; + }; + + if (openaiData.error) { + throw new Error(`Vision API error: ${openaiData.error.message || JSON.stringify(openaiData.error)}`); + } + + const content = openaiData.choices?.[0]?.message?.content; + if (!content || typeof content !== "string") { + throw new Error("Vision API returned empty or invalid response"); + } + + return content.trim(); + } + } catch (error) { + clearTimeout(timeoutId); + + if (error instanceof Error && error.name === "AbortError") { + throw new Error("Vision model call timed out"); + } + + throw error; + } +} + +export interface RequestBody { + model?: string; + messages?: RequestMessage[]; + [key: string]: unknown; +} + +/** + * Replace image content parts with text descriptions. + * Concatenates descriptions with labels: "[Image 1]: ..." + */ +export function replaceImageParts(body: RequestBody, descriptions: string[]): RequestBody { + if (!descriptions || descriptions.length === 0) { + return body; + } + + const result = structuredClone(body) as RequestBody; + + if (!Array.isArray(result.messages)) { + return result; + } + + let descriptionIndex = 0; + + for (let msgIdx = 0; msgIdx < result.messages.length; msgIdx++) { + const message = result.messages[msgIdx]; + if (!message || !Array.isArray(message.content)) { + continue; + } + + const newContent: RequestContentPart[] = []; + + for (const part of message.content) { + if (part?.type === "image_url" || part?.type === "image") { + if (descriptionIndex < descriptions.length) { + newContent.push({ + type: "text", + text: descriptions[descriptionIndex], + }); + descriptionIndex++; + } + } else { + newContent.push(part as RequestContentPart); + } + } + + message.content = newContent; + } + + return result; +} diff --git a/src/shared/constants/visionBridgeDefaults.ts b/src/shared/constants/visionBridgeDefaults.ts new file mode 100644 index 0000000000..18e069d6d3 --- /dev/null +++ b/src/shared/constants/visionBridgeDefaults.ts @@ -0,0 +1,55 @@ +/** + * Vision Bridge default configuration values. + */ + +export const VISION_BRIDGE_DEFAULTS = { + enabled: true, + model: "openai/gpt-4o-mini", + prompt: + "Describe this image concisely in 2-3 sentences. Focus on the most relevant visual details.", + timeoutMs: 30000, + maxImagesPerRequest: 10, +} as const; + +/** + * Settings keys for Vision Bridge (to be stored in key_value table). + */ +export const VISION_BRIDGE_SETTINGS_KEYS = [ + "visionBridgeEnabled", + "visionBridgeModel", + "visionBridgePrompt", + "visionBridgeTimeout", + "visionBridgeMaxImages", +] as const; + +export type VisionBridgeSettings = { + visionBridgeEnabled?: boolean; + visionBridgeModel?: string; + visionBridgePrompt?: string; + visionBridgeTimeout?: number; + visionBridgeMaxImages?: number; +}; + +export type VisionBridgeConfig = { + enabled: boolean; + model: string; + prompt: string; + timeoutMs: number; + maxImages: number; +}; + +/** + * Merge settings with defaults to produce a complete config. + */ +export function getVisionBridgeConfig( + settings: VisionBridgeSettings | undefined | null = {} +): VisionBridgeConfig { + const s = settings ?? {}; + return { + enabled: s.visionBridgeEnabled ?? VISION_BRIDGE_DEFAULTS.enabled, + model: s.visionBridgeModel ?? VISION_BRIDGE_DEFAULTS.model, + prompt: s.visionBridgePrompt ?? VISION_BRIDGE_DEFAULTS.prompt, + timeoutMs: s.visionBridgeTimeout ?? VISION_BRIDGE_DEFAULTS.timeoutMs, + maxImages: s.visionBridgeMaxImages ?? VISION_BRIDGE_DEFAULTS.maxImagesPerRequest, + }; +} \ No newline at end of file diff --git a/tests/unit/guardrails/visionBridge.test.ts b/tests/unit/guardrails/visionBridge.test.ts new file mode 100644 index 0000000000..550dc63469 --- /dev/null +++ b/tests/unit/guardrails/visionBridge.test.ts @@ -0,0 +1,515 @@ +/** + * Tests for VisionBridgeGuardrail. + * Uses dependency injection to avoid SQLite dependency. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +const { VisionBridgeGuardrail } = await import( + "../../../src/lib/guardrails/visionBridge.ts" +); +const { resetGuardrailsForTests } = await import( + "../../../src/lib/guardrails/registry.ts" +); +const { getResolvedModelCapabilities } = await import( + "../../../src/lib/modelCapabilities.ts" +); +import type { GuardrailContext } from "../../../src/lib/guardrails/base.ts"; +import type { VisionModelConfig } from "../../../src/lib/guardrails/visionBridgeHelpers.ts"; + +// ── Mock state ────────────────────────────────────────────────────────────── + +let mockSettings: Record = { + visionBridgeEnabled: true, + visionBridgeModel: "openai/gpt-4o-mini", + visionBridgePrompt: "Describe this image concisely.", + visionBridgeTimeout: 30000, + visionBridgeMaxImages: 10, +}; + +let mockVisionResponse = "A beautiful sunset over the ocean"; +let shouldVisionFail = false; +let visionCallCount = 0; + +function createGuardrail( + options?: Parameters[0] +) { + return new VisionBridgeGuardrail({ + ...options, + deps: { + getSettings: async () => mockSettings, + callVisionModel: async ( + _imageDataUri: string, + _config: VisionModelConfig + ) => { + visionCallCount++; + if (shouldVisionFail) { + throw new Error("Vision model failed"); + } + return mockVisionResponse; + }, + }, + }); +} + +test.beforeEach(() => { + resetGuardrailsForTests({ registerDefaults: false }); + visionCallCount = 0; + shouldVisionFail = false; + mockSettings = { + visionBridgeEnabled: true, + visionBridgeModel: "openai/gpt-4o-mini", + visionBridgePrompt: "Describe this image concisely.", + visionBridgeTimeout: 30000, + visionBridgeMaxImages: 10, + }; +}); + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function createContext( + overrides: Partial = {} +): GuardrailContext { + return { + model: "minimax/minimax-01", + log: console, + ...overrides, + }; +} + +function createPayload( + overrides: Record = {} +): Record { + return { + model: "minimax/minimax-01", + messages: [{ role: "user", content: "Hello" }], + ...overrides, + }; +} + +// ── Basic Properties ──────────────────────────────────────────────────────── + +test("VisionBridgeGuardrail has correct name and priority", () => { + const guardrail = createGuardrail(); + assert.strictEqual(guardrail.name, "vision-bridge"); + assert.strictEqual(guardrail.priority, 5); +}); + +test("VisionBridgeGuardrail is enabled by default", () => { + const guardrail = createGuardrail(); + assert.strictEqual(guardrail.enabled, true); +}); + +test("VisionBridgeGuardrail can be disabled via constructor", () => { + const guardrail = createGuardrail({ enabled: false }); + assert.strictEqual(guardrail.enabled, false); +}); + +// ── VB-S05: Vision Bridge disabled via settings ──────────────────────────── + +test("VB-S05: passthroughs when visionBridgeEnabled is false", async () => { + mockSettings.visionBridgeEnabled = false; + const guardrail = createGuardrail(); + + const payload = createPayload({ + messages: [ + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { + type: "image_url", + image_url: { url: "https://example.com/image.png" }, + }, + ], + }, + ], + }); + + const result = await guardrail.preCall(payload, createContext()); + assert.strictEqual(result.block, false); + assert.strictEqual(result.modifiedPayload, undefined); +}); + +// ── VB-S06: Disabled via context ──────────────────────────────────────────── + +test("VB-S06: skips when disabledGuardrails includes vision-bridge", async () => { + const guardrail = createGuardrail(); + const payload = createPayload(); + const context = createContext({ disabledGuardrails: ["vision-bridge"] }); + + const result = await guardrail.preCall(payload, context); + assert.strictEqual(result.block, false); + assert.strictEqual(result.modifiedPayload, undefined); +}); + +// ── VB-S02: Vision-capable model passthrough ──────────────────────────────── + +test("VB-S02: passthroughs for vision-capable model (gpt-4o)", async () => { + const guardrail = createGuardrail(); + const payload = createPayload({ + model: "openai/gpt-4o", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "What is this?" }, + { + type: "image_url", + image_url: { url: "https://example.com/image.png" }, + }, + ], + }, + ], + }); + + const result = await guardrail.preCall( + payload, + createContext({ model: "openai/gpt-4o" }) + ); + + // If supportsVision is true, it should passthrough (no modification) + // If supportsVision is null/undefined (no sync data), it will process — that's correct behavior + const capabilities = getResolvedModelCapabilities("openai/gpt-4o"); + if (capabilities.supportsVision === true) { + assert.strictEqual(result.block, false); + assert.strictEqual(result.modifiedPayload, undefined); + } else { + // Without sync data, supportsVision is null — guardrail processes the image + // This is correct fail-open behavior for unknown model capabilities + assert.strictEqual(result.block, false); + } +}); + +test("VB-S02: model capabilities returns supportsVision for known models", () => { + const gpt4oCaps = getResolvedModelCapabilities("openai/gpt-4o"); + // supportsVision may be true (if sync data exists) or null (if not synced) + assert.ok(gpt4oCaps.supportsVision === true || gpt4oCaps.supportsVision === null); +}); + +// ── VB-S04: No images passthrough ────────────────────────────────────────── + +test("VB-S04: passthroughs when no images in messages", async () => { + const guardrail = createGuardrail(); + const payload = createPayload({ + messages: [{ role: "user", content: "Hello, how are you?" }], + }); + + const result = await guardrail.preCall(payload, createContext()); + assert.strictEqual(result.block, false); + assert.strictEqual(result.modifiedPayload, undefined); +}); + +test("VB-S04: passthroughs when messages array is empty", async () => { + const guardrail = createGuardrail(); + const payload = createPayload({ messages: [] }); + const result = await guardrail.preCall(payload, createContext()); + assert.strictEqual(result.block, false); +}); + +// ── VB-S01: Single image processing ───────────────────────────────────────── + +test("VB-S01: replaces image with description for non-vision model", async () => { + mockVisionResponse = "A beautiful sunset over the ocean"; + const guardrail = createGuardrail(); + + const payload = createPayload({ + model: "minimax/minimax-01", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { + type: "image_url", + image_url: { url: "https://example.com/image.png" }, + }, + ], + }, + ], + }); + + const result = await guardrail.preCall( + payload, + createContext({ model: "minimax/minimax-01" }) + ); + + assert.strictEqual(result.block, false); + assert.ok(result.modifiedPayload); + + const modified = result.modifiedPayload as { + messages: Array<{ content: unknown[] }>; + }; + const content = modified.messages[0].content as Array<{ + type: string; + text?: string; + }>; + + const imagePart = content.find((p) => p.type === "image_url"); + assert.strictEqual(imagePart, undefined); + + const descriptionPart = content.find( + (p) => p.type === "text" && p.text?.includes("sunset") + ); + assert.ok(descriptionPart); +}); + +// ── VB-S04: Multiple images ───────────────────────────────────────────────── + +test("VB-S04: processes multiple images and concatenates descriptions", async () => { + let callIdx = 0; + const descriptions = ["A cute cat", "A playful dog", "A colorful bird"]; + + const guardrail = new VisionBridgeGuardrail({ + deps: { + getSettings: async () => mockSettings, + callVisionModel: async () => { + const desc = descriptions[callIdx] || "Unknown image"; + callIdx++; + return desc; + }, + }, + }); + + const payload = createPayload({ + model: "minimax/minimax-01", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "Describe these images" }, + { + type: "image_url", + image_url: { url: "https://example.com/cat.png" }, + }, + { + type: "image_url", + image_url: { url: "https://example.com/dog.png" }, + }, + { + type: "image_url", + image_url: { url: "https://example.com/bird.png" }, + }, + ], + }, + ], + }); + + const result = await guardrail.preCall( + payload, + createContext({ model: "minimax/minimax-01" }) + ); + + assert.strictEqual(result.block, false); + assert.ok(result.modifiedPayload); + assert.strictEqual(callIdx, 3); + + const modified = result.modifiedPayload as { + messages: Array<{ content: unknown[] }>; + }; + const content = modified.messages[0].content as Array<{ + type: string; + text?: string; + }>; + + assert.ok( + content.some((p) => p.type === "text" && p.text?.includes("[Image 1]")) + ); + assert.ok( + content.some((p) => p.type === "text" && p.text?.includes("[Image 2]")) + ); + assert.ok( + content.some((p) => p.type === "text" && p.text?.includes("[Image 3]")) + ); +}); + +// ── VB-S03: Fail-open on vision error ────────────────────────────────────── + +test("VB-S03: returns modified payload with unavailable text when vision API fails", async () => { + shouldVisionFail = true; + const guardrail = createGuardrail(); + + const payload = createPayload({ + model: "minimax/minimax-01", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "What is this?" }, + { + type: "image_url", + image_url: { url: "https://example.com/image.png" }, + }, + ], + }, + ], + }); + + const result = await guardrail.preCall( + payload, + createContext({ model: "minimax/minimax-01" }) + ); + + assert.strictEqual(result.block, false); + assert.ok(result.modifiedPayload); + + const modified = result.modifiedPayload as { + messages: Array<{ content: unknown[] }>; + }; + const content = modified.messages[0].content as Array<{ + type: string; + text?: string; + }>; + + // Should have "unavailable" text instead of image + const unavailPart = content.find( + (p) => p.type === "text" && p.text?.includes("unavailable") + ); + assert.ok(unavailPart); +}); + +test("VB-S03: logs warning when vision API fails", async () => { + shouldVisionFail = true; + let warningLogged = false; + const guardrail = createGuardrail(); + + const payload = createPayload({ + model: "minimax/minimax-01", + messages: [ + { + role: "user", + content: [ + { + type: "image_url", + image_url: { url: "https://example.com/image.png" }, + }, + ], + }, + ], + }); + + const mockLog = { + warn: (_tag: string, msg: string) => { + if (msg.includes("Failed to get description")) { + warningLogged = true; + } + }, + }; + + await guardrail.preCall( + payload, + createContext({ + model: "minimax/minimax-01", + log: mockLog as GuardrailContext["log"], + }) + ); + + assert.strictEqual(warningLogged, true); +}); + +// ── VB-S07: Base64 image format ───────────────────────────────────────────── + +test("VB-S07: handles base64 image format", async () => { + mockVisionResponse = "An image description"; + const guardrail = createGuardrail(); + + const payload = createPayload({ + model: "minimax/minimax-01", + messages: [ + { + role: "user", + content: [ + { + type: "image", + source: { + type: "base64", + media_type: "image/png", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + }, + }, + ], + }, + ], + }); + + const result = await guardrail.preCall( + payload, + createContext({ model: "minimax/minimax-01" }) + ); + + assert.strictEqual(result.block, false); + assert.ok(result.modifiedPayload); +}); + +// ── VB-S09: Image count limit ─────────────────────────────────────────────── + +test("VB-S09: respects maxImages setting", async () => { + mockSettings.visionBridgeMaxImages = 2; + const guardrail = createGuardrail(); + + const images = Array.from({ length: 5 }, (_, i) => ({ + type: "image_url" as const, + image_url: { url: `https://example.com/image${i}.png` }, + })); + + const payload = createPayload({ + model: "minimax/minimax-01", + messages: [ + { + role: "user", + content: [{ type: "text", text: "Describe these" }, ...images], + }, + ], + }); + + await guardrail.preCall( + payload, + createContext({ model: "minimax/minimax-01" }) + ); + + // Should only call vision API for 2 images (maxImages=2) + assert.strictEqual(visionCallCount, 2); +}); + +// ── VB-S10: Meta information returned ─────────────────────────────────────── + +test("VB-S10: returns meta with imagesProcessed count", async () => { + mockVisionResponse = "A test description"; + const guardrail = createGuardrail(); + + const payload = createPayload({ + model: "minimax/minimax-01", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "What is this?" }, + { + type: "image_url", + image_url: { url: "https://example.com/a.png" }, + }, + { + type: "image_url", + image_url: { url: "https://example.com/b.png" }, + }, + ], + }, + ], + }); + + const result = await guardrail.preCall( + payload, + createContext({ model: "minimax/minimax-01" }) + ); + + assert.ok(result.meta); + assert.ok(typeof result.meta === "object"); + + const meta = result.meta as Record; + assert.strictEqual(meta.imagesProcessed, 2); + assert.ok(Array.isArray(meta.descriptions)); + assert.strictEqual((meta.descriptions as string[]).length, 2); + assert.strictEqual(typeof meta.processingTimeMs, "number"); + assert.strictEqual(meta.visionModel, "openai/gpt-4o-mini"); +}); \ No newline at end of file diff --git a/tests/unit/guardrails/visionBridgeHelpers.callVisionModel.test.ts b/tests/unit/guardrails/visionBridgeHelpers.callVisionModel.test.ts new file mode 100644 index 0000000000..dd241214e1 --- /dev/null +++ b/tests/unit/guardrails/visionBridgeHelpers.callVisionModel.test.ts @@ -0,0 +1,231 @@ +/** + * Tests for callVisionModel helper function. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { callVisionModel, type VisionModelConfig } from "@/lib/guardrails/visionBridgeHelpers"; + +// Store original fetch +const originalFetch = globalThis.fetch; + +test("callVisionModel returns description on success", async () => { + // Mock global fetch + const mockResponse = { + ok: true, + json: async () => ({ + choices: [{ message: { content: "A beautiful sunset over the ocean" } }], + }), + }; + globalThis.fetch = async () => mockResponse as unknown as Response; + + try { + const config: VisionModelConfig = { + model: "openai/gpt-4o-mini", + prompt: "Describe this image", + timeoutMs: 30000, + maxImages: 10, + }; + + const result = await callVisionModel( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + config + ); + + assert.strictEqual(result, "A beautiful sunset over the ocean"); + } finally { + // Restore original fetch + globalThis.fetch = originalFetch; + } +}); + +test("callVisionModel throws on HTTP error", async () => { + const mockResponse = { + ok: false, + status: 500, + text: async () => "Internal Server Error", + }; + globalThis.fetch = async () => mockResponse as unknown as Response; + + try { + const config: VisionModelConfig = { + model: "openai/gpt-4o-mini", + prompt: "Describe this image", + timeoutMs: 30000, + maxImages: 10, + }; + + await assert.rejects( + async () => + await callVisionModel("data:image/png;base64,iVBORw0KGgo", config), + /Vision API error 500/ + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("callVisionModel throws on API error response", async () => { + const mockResponse = { + ok: true, + json: async () => ({ + error: { message: "Invalid API key" }, + }), + }; + globalThis.fetch = async () => mockResponse as unknown as Response; + + try { + const config: VisionModelConfig = { + model: "openai/gpt-4o-mini", + prompt: "Describe this image", + timeoutMs: 30000, + maxImages: 10, + }; + + await assert.rejects( + async () => await callVisionModel("data:image/png;base64,iVBORw0KGgo", config), + /Invalid API key/ + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("callVisionModel throws on empty response", async () => { + const mockResponse = { + ok: true, + json: async () => ({ + choices: [{}], + }), + }; + globalThis.fetch = async () => mockResponse as unknown as Response; + + try { + const config: VisionModelConfig = { + model: "openai/gpt-4o-mini", + prompt: "Describe this image", + timeoutMs: 30000, + maxImages: 10, + }; + + await assert.rejects( + async () => await callVisionModel("data:image/png;base64,iVBORw0KGgo", config), + /empty or invalid/ + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("callVisionModel trims whitespace from response", async () => { + const mockResponse = { + ok: true, + json: async () => ({ + choices: [{ message: { content: " A test description " } }], + }), + }; + globalThis.fetch = async () => mockResponse as unknown as Response; + + try { + const config: VisionModelConfig = { + model: "openai/gpt-4o-mini", + prompt: "Describe this image", + timeoutMs: 30000, + maxImages: 10, + }; + + const result = await callVisionModel("data:image/png;base64,iVBORw0KGgo", config); + assert.strictEqual(result, "A test description"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("callVisionModel passes custom API key", async () => { + let capturedHeaders: Record = {}; + + const mockResponse = { + ok: true, + json: async () => ({ + choices: [{ message: { content: "Description" } }], + }), + }; + + globalThis.fetch = async (url: URL | RequestInfo, init?: RequestInit) => { + if (init?.headers) { + capturedHeaders = init.headers as Record; + } + return mockResponse as unknown as Response; + }; + + try { + const config: VisionModelConfig = { + model: "openai/gpt-4o-mini", + prompt: "Describe this image", + timeoutMs: 30000, + maxImages: 10, + }; + + await callVisionModel("data:image/png;base64,iVBORw0KGgo", config, "sk-custom-key"); + + assert.strictEqual( + capturedHeaders["Authorization"], + "Bearer sk-custom-key" + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("callVisionModel uses correct request body format", async () => { + let capturedBody: Record = {}; + + const mockResponse = { + ok: true, + json: async () => ({ + choices: [{ message: { content: "Description" } }], + }), + }; + + globalThis.fetch = async (url: URL | RequestInfo, init?: RequestInit) => { + if (init?.body) { + capturedBody = JSON.parse(init.body as string); + } + return mockResponse as unknown as Response; + }; + + try { + const config: VisionModelConfig = { + model: "openai/gpt-4o-mini", + prompt: "What is in this image?", + timeoutMs: 30000, + maxImages: 10, + }; + + const imageUri = "data:image/png;base64,test123"; + await callVisionModel(imageUri, config); + + // Verify request structure + assert.strictEqual(capturedBody.model, "gpt-4o-mini"); + assert.ok(Array.isArray(capturedBody.messages)); + assert.strictEqual((capturedBody.messages as unknown[]).length, 1); + + const message = (capturedBody.messages as Array<{role: string; content: unknown[]}>)[0]; + assert.strictEqual(message.role, "user"); + assert.ok(Array.isArray(message.content)); + assert.strictEqual(message.content.length, 2); + + // First content is image_url + const imagePart = message.content[0] as {type: string; image_url: {url: string; detail: string}}; + assert.strictEqual(imagePart.type, "image_url"); + assert.strictEqual(imagePart.image_url.url, imageUri); + assert.strictEqual(imagePart.image_url.detail, "low"); + + // Second content is text prompt + const textPart = message.content[1] as {type: string; text: string}; + assert.strictEqual(textPart.type, "text"); + assert.strictEqual(textPart.text, "What is in this image?"); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/guardrails/visionBridgeHelpers.extractImageParts.test.ts b/tests/unit/guardrails/visionBridgeHelpers.extractImageParts.test.ts new file mode 100644 index 0000000000..e061548c45 --- /dev/null +++ b/tests/unit/guardrails/visionBridgeHelpers.extractImageParts.test.ts @@ -0,0 +1,138 @@ +/** + * Tests for extractImageParts helper function. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { extractImageParts } from "@/lib/guardrails/visionBridgeHelpers"; + +interface RequestMessage { + role?: string; + content?: string | RequestContentPart[]; +} + +type RequestContentPart = + | { type: "text"; text: string } + | { type: "image_url"; image_url: { url: string; detail?: string } } + | { type: "image"; source: { type: "base64"; media_type: string; data: string } }; + +test("extractImageParts returns empty array for messages without images", () => { + const messages: RequestMessage[] = [ + { role: "user", content: "Hello, how are you?" }, + ]; + const result = extractImageParts(messages); + assert.deepStrictEqual(result, []); +}); + +test("extractImageParts detects image_url format", () => { + const messages: RequestMessage[] = [ + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { type: "image_url", image_url: { url: "https://example.com/image.png" } }, + ], + }, + ]; + const result = extractImageParts(messages); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].messageIndex, 0); + assert.strictEqual(result[0].partIndex, 1); + assert.strictEqual(result[0].imageUrl, "https://example.com/image.png"); + assert.strictEqual(result[0].imageType, "image_url"); +}); + +test("extractImageParts detects base64 image format", () => { + const messages: RequestMessage[] = [ + { + role: "user", + content: [ + { + type: "image", + source: { + type: "base64", + media_type: "image/png", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + }, + }, + ], + }, + ]; + const result = extractImageParts(messages); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].imageType, "image"); + assert.ok(result[0].imageUrl.startsWith("data:image/png;base64,")); +}); + +test("extractImageParts handles multiple images in single message", () => { + const messages: RequestMessage[] = [ + { + role: "user", + content: [ + { type: "text", text: "Compare these images" }, + { type: "image_url", image_url: { url: "https://example.com/image1.png" } }, + { type: "image_url", image_url: { url: "https://example.com/image2.png" } }, + ], + }, + ]; + const result = extractImageParts(messages); + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].partIndex, 1); + assert.strictEqual(result[1].partIndex, 2); +}); + +test("extractImageParts handles images across multiple messages", () => { + const messages: RequestMessage[] = [ + { role: "user", content: [{ type: "image_url", image_url: { url: "https://example.com/image1.png" } }] }, + { role: "assistant", content: "Here is analysis of the first image." }, + { role: "user", content: [{ type: "image_url", image_url: { url: "https://example.com/image2.png" } }] }, + ]; + const result = extractImageParts(messages); + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].messageIndex, 0); + assert.strictEqual(result[1].messageIndex, 2); +}); + +test("extractImageParts handles empty messages array", () => { + const result = extractImageParts([]); + assert.deepStrictEqual(result, []); +}); + +test("extractImageParts handles messages with null/undefined content", () => { + const messages: RequestMessage[] = [ + { role: "user", content: null as unknown as RequestContentPart[] }, + { role: "user", content: undefined as unknown as RequestContentPart[] }, + ]; + const result = extractImageParts(messages); + assert.deepStrictEqual(result, []); +}); + +test("extractImageParts handles data URI image_url format", () => { + const dataUri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + const messages: RequestMessage[] = [ + { role: "user", content: [{ type: "image_url", image_url: { url: dataUri } }] }, + ]; + const result = extractImageParts(messages); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].imageUrl, dataUri); +}); + +test("extractImageParts preserves order of images", () => { + const messages: RequestMessage[] = [ + { + role: "user", + content: [ + { type: "text", text: "First" }, + { type: "image_url", image_url: { url: "https://example.com/A.png" } }, + { type: "text", text: "Second" }, + { type: "image_url", image_url: { url: "https://example.com/B.png" } }, + { type: "image_url", image_url: { url: "https://example.com/C.png" } }, + ], + }, + ]; + const result = extractImageParts(messages); + assert.strictEqual(result.length, 3); + assert.strictEqual(result[0].partIndex, 1); + assert.strictEqual(result[1].partIndex, 3); + assert.strictEqual(result[2].partIndex, 4); +}); diff --git a/tests/unit/guardrails/visionBridgeHelpers.replaceImageParts.test.ts b/tests/unit/guardrails/visionBridgeHelpers.replaceImageParts.test.ts new file mode 100644 index 0000000000..263e3dbfb3 --- /dev/null +++ b/tests/unit/guardrails/visionBridgeHelpers.replaceImageParts.test.ts @@ -0,0 +1,254 @@ +/** + * Tests for replaceImageParts helper function. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { replaceImageParts } from "@/lib/guardrails/visionBridgeHelpers"; + +test("replaceImageParts replaces single image with description", () => { + const body = { + model: "minimax/minimax-01", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { type: "image_url", image_url: { url: "https://example.com/image.png" } }, + ], + }, + ], + }; + + const descriptions = ["[Image 1]: A beautiful sunset"]; + + const result = replaceImageParts(body, descriptions); + + // Should have original text preserved + const content = result.messages[0].content as Array<{type: string; text?: string}>; + assert.strictEqual(content[0].type, "text"); + assert.strictEqual(content[0].text, "What is in this image?"); + + // Should have description instead of image + assert.strictEqual(content[1].type, "text"); + assert.strictEqual(content[1].text, "[Image 1]: A beautiful sunset"); +}); + +test("replaceImageParts replaces multiple images with descriptions", () => { + const body = { + model: "minimax/minimax-01", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "Compare these images" }, + { type: "image_url", image_url: { url: "https://example.com/A.png" } }, + { type: "image_url", image_url: { url: "https://example.com/B.png" } }, + { type: "image_url", image_url: { url: "https://example.com/C.png" } }, + ], + }, + ], + }; + + const descriptions = [ + "[Image 1]: A cat", + "[Image 2]: A dog", + "[Image 3]: A bird", + ]; + + const result = replaceImageParts(body, descriptions); + + const content = result.messages[0].content as Array<{type: string; text?: string}>; + assert.strictEqual(content[0].type, "text"); + assert.strictEqual(content[0].text, "Compare these images"); + assert.strictEqual(content[1].type, "text"); + assert.strictEqual(content[1].text, "[Image 1]: A cat"); + assert.strictEqual(content[2].type, "text"); + assert.strictEqual(content[2].text, "[Image 2]: A dog"); + assert.strictEqual(content[3].type, "text"); + assert.strictEqual(content[3].text, "[Image 3]: A bird"); +}); + +test("replaceImageParts handles empty descriptions array", () => { + const body = { + model: "minimax/minimax-01", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "Hello" }, + { type: "image_url", image_url: { url: "https://example.com/image.png" } }, + ], + }, + ], + }; + + const result = replaceImageParts(body, []); + + // Original should be unchanged + const content = result.messages[0].content as Array<{type: string}>; + assert.strictEqual(content[1].type, "image_url"); +}); + +test("replaceImageParts preserves non-image content", () => { + const body = { + model: "minimax/minimax-01", + messages: [ + { + role: "system", + content: "You are a helpful assistant.", + }, + { + role: "user", + content: [ + { type: "text", text: "Analyze this" }, + { type: "image_url", image_url: { url: "https://example.com/image.png" } }, + ], + }, + { + role: "assistant", + content: "I can see the image shows a sunset.", + }, + ], + }; + + const result = replaceImageParts(body, ["[Image 1]: A sunset over the ocean"]); + + // System message should be unchanged + assert.strictEqual(result.messages[0].content, "You are a helpful assistant."); + + // Assistant message should be unchanged + assert.strictEqual(result.messages[2].content, "I can see the image shows a sunset."); +}); + +test("replaceImageParts handles base64 images", () => { + const body = { + model: "minimax/minimax-01", + messages: [ + { + role: "user", + content: [ + { + type: "image", + source: { + type: "base64", + media_type: "image/png", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + }, + }, + ], + }, + ], + }; + + const descriptions = ["[Image 1]: A red circle"]; + + const result = replaceImageParts(body, descriptions); + + const content = result.messages[0].content as Array<{type: string; text?: string}>; + assert.strictEqual(content[0].type, "text"); + assert.strictEqual(content[0].text, "[Image 1]: A red circle"); +}); + +test("replaceImageParts handles undefined descriptions", () => { + const body = { + model: "test", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "Hello" }, + { type: "image_url", image_url: { url: "https://example.com/image.png" } }, + ], + }, + ], + }; + + const result = replaceImageParts(body, undefined as unknown as string[]); + + // Should return original body when descriptions is undefined + const content = result.messages[0].content as Array<{type: string}>; + assert.strictEqual(content[1].type, "image_url"); +}); + +test("replaceImageParts handles empty messages array", () => { + const body = { + model: "test", + messages: [], + }; + + const descriptions = ["[Image 1]: Description"]; + const result = replaceImageParts(body, descriptions); + + assert.deepStrictEqual(result.messages, []); +}); + +test("replaceImageParts handles messages without content array", () => { + const body = { + model: "test", + messages: [ + { role: "user", content: "Just a text message" }, + { role: "user", content: [{ type: "image_url", image_url: { url: "https://example.com/image.png" } }] }, + ], + }; + + const descriptions = ["[Image 1]: Description"]; + const result = replaceImageParts(body, descriptions); + + // First message (string content) should be unchanged + assert.strictEqual(result.messages[0].content, "Just a text message"); + + // Second message should have image replaced + const content = result.messages[1].content as Array<{type: string; text?: string}>; + assert.strictEqual(content[0].type, "text"); + assert.strictEqual(content[0].text, "[Image 1]: Description"); +}); + +test("replaceImageParts does not modify original body", () => { + const body = { + model: "minimax/minimax-01", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "Original" }, + { type: "image_url", image_url: { url: "https://example.com/image.png" } }, + ], + }, + ], + }; + + const descriptions = ["[Image 1]: Modified"]; + replaceImageParts(body, descriptions); + + // Original should be unchanged + const content = body.messages[0].content as Array<{type: string}>; + assert.strictEqual(content[1].type, "image_url"); +}); + +test("replaceImageParts handles mixed images and text", () => { + const body = { + model: "test", + messages: [ + { + role: "user", + content: [ + { type: "image_url", image_url: { url: "https://example.com/first.png" } }, + { type: "text", text: "between images" }, + { type: "image_url", image_url: { url: "https://example.com/second.png" } }, + ], + }, + ], + }; + + const descriptions = ["[Image 1]: First image", "[Image 2]: Second image"]; + const result = replaceImageParts(body, descriptions); + + const content = result.messages[0].content as Array<{type: string; text?: string}>; + assert.strictEqual(content[0].type, "text"); + assert.strictEqual(content[0].text, "[Image 1]: First image"); + assert.strictEqual(content[1].type, "text"); + assert.strictEqual(content[1].text, "between images"); + assert.strictEqual(content[2].type, "text"); + assert.strictEqual(content[2].text, "[Image 2]: Second image"); +}); diff --git a/tests/unit/guardrails/visionBridgeHelpers.resolveImageAsDataUri.test.ts b/tests/unit/guardrails/visionBridgeHelpers.resolveImageAsDataUri.test.ts new file mode 100644 index 0000000000..af47178c88 --- /dev/null +++ b/tests/unit/guardrails/visionBridgeHelpers.resolveImageAsDataUri.test.ts @@ -0,0 +1,62 @@ +/** + * Tests for resolveImageAsDataUri helper function. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { resolveImageAsDataUri } from "@/lib/guardrails/visionBridgeHelpers"; + +test("resolveImageAsDataUri passes through HTTPS URL as-is", () => { + const url = "https://example.com/image.png"; + const result = resolveImageAsDataUri(url); + assert.strictEqual(result, url); +}); + +test("resolveImageAsDataUri passes through HTTP URL as-is", () => { + const url = "http://example.com/image.png"; + const result = resolveImageAsDataUri(url); + assert.strictEqual(result, url); +}); + +test("resolveImageAsDataUri passes through data URI as-is", () => { + const dataUri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + const result = resolveImageAsDataUri(dataUri); + assert.strictEqual(result, dataUri); +}); + +test("resolveImageAsDataUri converts base64 string to PNG data URI", () => { + const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + const result = resolveImageAsDataUri(base64); + assert.strictEqual(result, `data:image/png;base64,${base64}`); +}); + +test("resolveImageAsDataUri throws for empty string", () => { + assert.throws(() => { + resolveImageAsDataUri(""); + }, /Invalid image URL/); +}); + +test("resolveImageAsDataUri throws for null", () => { + assert.throws(() => { + resolveImageAsDataUri(null as unknown as string); + }, /Invalid image URL/); +}); + +test("resolveImageAsDataUri throws for undefined", () => { + assert.throws(() => { + resolveImageAsDataUri(undefined as unknown as string); + }, /Invalid image URL/); +}); + +test("resolveImageAsDataUri handles data URI with different media types", () => { + const jpegUri = "data:image/jpeg;base64,/9j/4AAQSkZJRg=="; + const result = resolveImageAsDataUri(jpegUri); + assert.strictEqual(result, jpegUri); +}); + +test("resolveImageAsDataUri treats plain base64 with prefix as data URI", () => { + // If it doesn't start with data: or http, treat as raw base64 + const rawBase64 = "abcdef123456=="; + const result = resolveImageAsDataUri(rawBase64); + assert.strictEqual(result, "data:image/png;base64,abcdef123456=="); +}); diff --git a/tests/unit/visionBridgeDefaults.test.ts b/tests/unit/visionBridgeDefaults.test.ts new file mode 100644 index 0000000000..2d631e05de --- /dev/null +++ b/tests/unit/visionBridgeDefaults.test.ts @@ -0,0 +1,91 @@ +/** + * Tests for Vision Bridge default constants. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { + VISION_BRIDGE_DEFAULTS, + VISION_BRIDGE_SETTINGS_KEYS, + getVisionBridgeConfig, + type VisionBridgeSettings, +} from "@/shared/constants/visionBridgeDefaults"; + +test("VISION_BRIDGE_DEFAULTS exports correct values", () => { + assert.strictEqual(VISION_BRIDGE_DEFAULTS.enabled, true); + assert.strictEqual(VISION_BRIDGE_DEFAULTS.model, "openai/gpt-4o-mini"); + assert.strictEqual( + VISION_BRIDGE_DEFAULTS.prompt, + "Describe this image concisely in 2-3 sentences. Focus on the most relevant visual details." + ); + assert.strictEqual(VISION_BRIDGE_DEFAULTS.timeoutMs, 30000); + assert.strictEqual(VISION_BRIDGE_DEFAULTS.maxImagesPerRequest, 10); +}); + +test("VISION_BRIDGE_SETTINGS_KEYS exports all expected keys", () => { + assert.deepStrictEqual(VISION_BRIDGE_SETTINGS_KEYS, [ + "visionBridgeEnabled", + "visionBridgeModel", + "visionBridgePrompt", + "visionBridgeTimeout", + "visionBridgeMaxImages", + ]); +}); + +test("getVisionBridgeConfig returns defaults when no settings provided", () => { + const config = getVisionBridgeConfig({}); + + assert.strictEqual(config.enabled, true); + assert.strictEqual(config.model, "openai/gpt-4o-mini"); + assert.strictEqual(config.prompt, VISION_BRIDGE_DEFAULTS.prompt); + assert.strictEqual(config.timeoutMs, 30000); + assert.strictEqual(config.maxImages, 10); +}); + +test("getVisionBridgeConfig applies custom settings", () => { + const customSettings: VisionBridgeSettings = { + visionBridgeEnabled: false, + visionBridgeModel: "anthropic/claude-3-haiku", + visionBridgePrompt: "What is in this image?", + visionBridgeTimeout: 60000, + visionBridgeMaxImages: 5, + }; + + const config = getVisionBridgeConfig(customSettings); + + assert.strictEqual(config.enabled, false); + assert.strictEqual(config.model, "anthropic/claude-3-haiku"); + assert.strictEqual(config.prompt, "What is in this image?"); + assert.strictEqual(config.timeoutMs, 60000); + assert.strictEqual(config.maxImages, 5); +}); + +test("getVisionBridgeConfig merges partial settings with defaults", () => { + const partialSettings: VisionBridgeSettings = { + visionBridgeModel: "openai/gpt-4o", + }; + + const config = getVisionBridgeConfig(partialSettings); + + // Custom value + assert.strictEqual(config.model, "openai/gpt-4o"); + // Default values for the rest + assert.strictEqual(config.enabled, true); + assert.strictEqual(config.prompt, VISION_BRIDGE_DEFAULTS.prompt); + assert.strictEqual(config.timeoutMs, 30000); + assert.strictEqual(config.maxImages, 10); +}); + +test("getVisionBridgeConfig handles undefined settings", () => { + const config = getVisionBridgeConfig(undefined); + + assert.strictEqual(config.enabled, true); + assert.strictEqual(config.model, "openai/gpt-4o-mini"); +}); + +test("getVisionBridgeConfig handles null settings", () => { + const config = getVisionBridgeConfig(null as unknown as VisionBridgeSettings); + + assert.strictEqual(config.enabled, true); + assert.strictEqual(config.model, "openai/gpt-4o-mini"); +}); From 56608a4654ee1f99d1baf55ac0ef9d6939543f3b Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 21 Apr 2026 18:52:11 -0300 Subject: [PATCH 034/281] feat(ui): add batch processing dashboard page and translations --- src/app/(dashboard)/dashboard/batch/page.tsx | 206 ++++++++++++++++++ src/app/api/batches/route.ts | 18 ++ src/app/api/files/route.ts | 18 ++ src/i18n/messages/en.json | 1 + ...s.sql => 028_create_files_and_batches.sql} | 0 src/shared/constants/sidebarVisibility.ts | 2 + tests/unit/sidebar-visibility.test.ts | 1 + 7 files changed, 246 insertions(+) create mode 100644 src/app/(dashboard)/dashboard/batch/page.tsx create mode 100644 src/app/api/batches/route.ts create mode 100644 src/app/api/files/route.ts rename src/lib/db/migrations/{027_create_files_and_batches.sql => 028_create_files_and_batches.sql} (100%) diff --git a/src/app/(dashboard)/dashboard/batch/page.tsx b/src/app/(dashboard)/dashboard/batch/page.tsx new file mode 100644 index 0000000000..9e9fe12f69 --- /dev/null +++ b/src/app/(dashboard)/dashboard/batch/page.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Card from "@/shared/components/Card"; +import EmptyState from "@/shared/components/EmptyState"; +import { formatDistanceToNow } from "date-fns"; + +export default function BatchPage() { + const [batches, setBatches] = useState([]); + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState<"batches" | "files">("batches"); + + useEffect(() => { + fetchData(); + }, []); + + const fetchData = async () => { + setLoading(true); + try { + const [batchesRes, filesRes] = await Promise.all([ + fetch("/api/batches"), + fetch("/api/files"), + ]); + if (batchesRes.ok) { + const data = await batchesRes.json(); + setBatches(data.batches || []); + } + if (filesRes.ok) { + const data = await filesRes.json(); + setFiles(data.files || []); + } + } catch (error) { + console.error("Failed to fetch batches/files", error); + } finally { + setLoading(false); + } + }; + + const renderBatches = () => { + if (batches.length === 0) { + return ( + + ); + } + + return ( +
+ {batches.map((batch: any) => ( + +
+
+ {batch.id} + + {batch.status} + +
+ + {formatDistanceToNow(batch.createdAt, { addSuffix: true })} + +
+ +
+
+ Endpoint + {batch.endpoint} +
+
+ Input File + + {batch.inputFileId} + +
+
+ Output File + {batch.outputFileId || "—"} +
+
+ Progress + + {batch.requestCountsCompleted || 0} / {batch.requestCountsTotal || 0} reqs + +
+
+
+ ))} +
+ ); + }; + + const renderFiles = () => { + if (files.length === 0) { + return ( + + ); + } + + return ( +
+ {files.map((file: any) => ( + +
+
+ {file.id} +
+ + {formatDistanceToNow(file.createdAt, { addSuffix: true })} + +
+ +
+
+ Filename + {file.filename} +
+
+ Purpose + {file.purpose} +
+
+ Size + {(file.bytes / 1024).toFixed(2)} KB +
+
+ Status + {file.status} +
+
+
+ ))} +
+ ); + }; + + return ( +
+
+
+
+

Batch Processing

+

Monitor asynchronous batch requests and files

+
+ +
+ +
+ + +
+ + {loading && batches.length === 0 && files.length === 0 ? ( +
+
+
+ ) : activeTab === "batches" ? ( + renderBatches() + ) : ( + renderFiles() + )} +
+
+ ); +} diff --git a/src/app/api/batches/route.ts b/src/app/api/batches/route.ts new file mode 100644 index 0000000000..e67ff13507 --- /dev/null +++ b/src/app/api/batches/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { listBatches } from "@/lib/localDb"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const url = new URL(request.url); + const limit = Number.parseInt(url.searchParams.get("limit") || "100", 10); + const batches = listBatches(undefined, limit); + return NextResponse.json({ batches }); + } catch (error) { + console.log("Error fetching batches:", error); + return NextResponse.json({ error: "Failed to fetch batches" }, { status: 500 }); + } +} diff --git a/src/app/api/files/route.ts b/src/app/api/files/route.ts new file mode 100644 index 0000000000..fb34312f19 --- /dev/null +++ b/src/app/api/files/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { listFiles } from "@/lib/localDb"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const url = new URL(request.url); + const limit = Number.parseInt(url.searchParams.get("limit") || "100", 10); + const files = listFiles({ limit }); + return NextResponse.json({ files }); + } catch (error) { + console.log("Error fetching files:", error); + return NextResponse.json({ error: "Failed to fetch files" }, { status: 500 }); + } +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index d0f1202c61..4ed9b281af 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -146,6 +146,7 @@ "dashboard": "Dashboard", "providers": "Providers", "combos": "Combos", + "batch": "Batch Jobs", "usage": "Usage", "analytics": "Analytics", "costs": "Costs", diff --git a/src/lib/db/migrations/027_create_files_and_batches.sql b/src/lib/db/migrations/028_create_files_and_batches.sql similarity index 100% rename from src/lib/db/migrations/027_create_files_and_batches.sql rename to src/lib/db/migrations/028_create_files_and_batches.sql diff --git a/src/shared/constants/sidebarVisibility.ts b/src/shared/constants/sidebarVisibility.ts index 094729ea54..94933e007e 100644 --- a/src/shared/constants/sidebarVisibility.ts +++ b/src/shared/constants/sidebarVisibility.ts @@ -4,6 +4,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [ "api-manager", "providers", "combos", + "batch", "costs", "analytics", "cache", @@ -51,6 +52,7 @@ const PRIMARY_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [ { id: "api-manager", href: "/dashboard/api-manager", i18nKey: "apiManager", icon: "vpn_key" }, { id: "providers", href: "/dashboard/providers", i18nKey: "providers", icon: "dns" }, { id: "combos", href: "/dashboard/combos", i18nKey: "combos", icon: "layers" }, + { id: "batch", href: "/dashboard/batch", i18nKey: "batch", icon: "view_list" }, { id: "costs", href: "/dashboard/costs", i18nKey: "costs", icon: "account_balance_wallet" }, { id: "analytics", href: "/dashboard/analytics", i18nKey: "analytics", icon: "analytics" }, { id: "cache", href: "/dashboard/cache", i18nKey: "cache", icon: "cached" }, diff --git a/tests/unit/sidebar-visibility.test.ts b/tests/unit/sidebar-visibility.test.ts index b20829c284..14a33c10f0 100644 --- a/tests/unit/sidebar-visibility.test.ts +++ b/tests/unit/sidebar-visibility.test.ts @@ -29,6 +29,7 @@ test("primary sidebar items place limits after cache", () => { "api-manager", "providers", "combos", + "batch", "costs", "analytics", "cache", From 0937c5a8a36c313483422ef93156337cadac232c Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 21 Apr 2026 19:01:56 -0300 Subject: [PATCH 035/281] =?UTF-8?q?chore(release):=20v3.7.0=20=E2=80=94=20?= =?UTF-8?q?final=20integration=20of=20batch=20ui=20and=20versions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + package-lock.json | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 262c2d1b7b..b30c0ee479 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - **feat(core):** Add daily quota exhaustion lock to detect "quota exceeded" signals and lock the specific model until midnight. (#1430) - **feat(core):** Auto-inject `stream_options.include_usage = true` for OpenAI format streams to guarantee token usage is reported correctly during streaming. (#1423) - **feat(dashboard):** Introduce real-time model status badges with countdown timers in the provider detail and combo panel interfaces. (#1430) +- **feat(dashboard):** Add Batch/File management data grid to the Dashboard. (#1479) ### 🐛 Bug Fixes diff --git a/package-lock.json b/package-lock.json index a05f478aa7..75a5030333 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11201,7 +11201,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, From a7d30d9c6f741ca56e992fc1e55f3d530f4c4e57 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 21 Apr 2026 19:32:20 -0300 Subject: [PATCH 036/281] test: isolate batch API unit tests with temp DATA_DIR to prevent schema state collisions --- tests/unit/batch_api.test.ts | 1256 +++++++++++++++++++--------------- 1 file changed, 692 insertions(+), 564 deletions(-) diff --git a/tests/unit/batch_api.test.ts b/tests/unit/batch_api.test.ts index f40fee7b94..30adc29fa3 100644 --- a/tests/unit/batch_api.test.ts +++ b/tests/unit/batch_api.test.ts @@ -1,673 +1,801 @@ import { test } from "node:test"; import assert from "node:assert"; -import { createFile, createBatch, getBatch, getFileContent, updateBatch, createProviderConnection, createApiKey, getFile, listFiles, formatFileResponse, deleteFile, getTerminalBatches } from "../../src/lib/localDb"; -import { getDbInstance } from "@/lib/db/core.ts"; -import { initBatchProcessor, stopBatchProcessor } from "../../open-sse/services/batchProcessor"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-batch-api-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { + createFile, + createBatch, + getBatch, + getFileContent, + updateBatch, + createProviderConnection, + createApiKey, + getFile, + listFiles, + formatFileResponse, + deleteFile, + getTerminalBatches, +} = await import("../../src/lib/localDb.ts"); +const { getDbInstance } = await import("../../src/lib/db/core.ts"); +const { initBatchProcessor, stopBatchProcessor } = + await import("../../open-sse/services/batchProcessor.ts"); test("Batch API and Processing", async () => { - // 0. Setup environment, mock provider and API key - process.env.API_KEY_SECRET = "test-secret-123"; + // 0. Setup environment, mock provider and API key + process.env.API_KEY_SECRET = "test-secret-123"; - await createProviderConnection({ - provider: "openai", - authType: "apikey", - name: "Mock OpenAI", - apiKey: "sk-mock-key", - isActive: true - }); + await createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "Mock OpenAI", + apiKey: "sk-mock-key", + isActive: true, + }); - const apiKey = await createApiKey("Test Key", "test-machine"); + const apiKey = await createApiKey("Test Key", "test-machine"); - // 1. Create a file with batch items - const batchItems = [ - JSON.stringify({ - custom_id: "request-1", - method: "POST", - url: "/v1/chat/completions", - body: { model: "gpt-4o-mini", messages: [{ role: "user", content: "Hello world" }] } - }), - JSON.stringify({ - custom_id: "request-2", - method: "POST", - url: "/v1/chat/completions", - body: { model: "gpt-4o-mini", messages: [{ role: "user", content: "Goodbye world" }] } - }) - ].join("\n"); + // 1. Create a file with batch items + const batchItems = [ + JSON.stringify({ + custom_id: "request-1", + method: "POST", + url: "/v1/chat/completions", + body: { model: "gpt-4o-mini", messages: [{ role: "user", content: "Hello world" }] }, + }), + JSON.stringify({ + custom_id: "request-2", + method: "POST", + url: "/v1/chat/completions", + body: { model: "gpt-4o-mini", messages: [{ role: "user", content: "Goodbye world" }] }, + }), + ].join("\n"); - const file = createFile({ - bytes: Buffer.byteLength(batchItems), - filename: "test_batch.jsonl", - purpose: "batch", - content: Buffer.from(batchItems), - apiKeyId: apiKey.id - }); + const file = createFile({ + bytes: Buffer.byteLength(batchItems), + filename: "test_batch.jsonl", + purpose: "batch", + content: Buffer.from(batchItems), + apiKeyId: apiKey.id, + }); - assert.ok(file.id.startsWith("file-"), "File ID should start with file-"); - assert.ok(file.status === "validating" || !file.status, "File status should be 'validating' by default or null"); + assert.ok(file.id.startsWith("file-"), "File ID should start with file-"); + assert.ok( + file.status === "validating" || !file.status, + "File status should be 'validating' by default or null" + ); - // 2. Create a batch - const batch = createBatch({ - endpoint: "/v1/chat/completions", - completionWindow: "24h", - inputFileId: file.id, - apiKeyId: apiKey.id - }); + // 2. Create a batch + const batch = createBatch({ + endpoint: "/v1/chat/completions", + completionWindow: "24h", + inputFileId: file.id, + apiKeyId: apiKey.id, + }); - assert.ok(batch.id.startsWith("batch_"), "Batch ID should start with batch_"); - assert.strictEqual(batch.status, "validating"); + assert.ok(batch.id.startsWith("batch_"), "Batch ID should start with batch_"); + assert.strictEqual(batch.status, "validating"); - // 3. Start the processor manually for one tick (or wait if we used initBatchProcessor) - // For testing, we might want to expose the processing functions or just wait. - // We'll use a shorter interval in the processor if we want to test polling. - // Here we'll just call processPendingBatches if it was exported, but it's not. - - // Instead of polling, let's just wait a bit if we started the processor - initBatchProcessor(); - - console.log("Waiting for batch processing..."); - - // Poll for status change - let maxAttempts = 30; - let currentBatch = getBatch(batch.id); - while (maxAttempts > 0 && currentBatch?.status !== "completed" && currentBatch?.status !== "failed" && currentBatch?.status !== "cancelled") { - await new Promise(resolve => setTimeout(resolve, 2000)); - currentBatch = getBatch(batch.id); - const progress = currentBatch?.requestCountsTotal ? `${currentBatch.requestCountsCompleted}/${currentBatch.requestCountsTotal}` : "not started"; - console.log(`[TEST] Current status: ${currentBatch?.status}, completed: ${progress}, failed: ${currentBatch?.requestCountsFailed || 0}`); - maxAttempts--; - } + // 3. Start the processor manually for one tick (or wait if we used initBatchProcessor) + // For testing, we might want to expose the processing functions or just wait. + // We'll use a shorter interval in the processor if we want to test polling. + // Here we'll just call processPendingBatches if it was exported, but it's not. - // Stop the processor so the test can exit - stopBatchProcessor(); + // Instead of polling, let's just wait a bit if we started the processor + initBatchProcessor(); - if (maxAttempts === 0) { - console.error("[TEST] Polling timed out. Final batch state:", JSON.stringify(currentBatch, null, 2)); - } + console.log("Waiting for batch processing..."); - assert.ok(currentBatch?.status === "completed" || currentBatch?.status === "failed", "Batch should reach a terminal state"); - - // In test environment, the mock key might fail, which is fine for this test as long as it finishes - if (currentBatch?.status === "failed" || currentBatch?.requestCountsFailed > 0) { - console.warn("[TEST] Batch finished with failures (likely due to mock credentials). This is acceptable for this test."); - assert.strictEqual(currentBatch?.requestCountsTotal, 2, "Total requests should be 2"); - assert.strictEqual((currentBatch?.requestCountsCompleted || 0) + (currentBatch?.requestCountsFailed || 0), 2, "Total processed should be 2"); - return; - } + // Poll for status change + let maxAttempts = 30; + let currentBatch = getBatch(batch.id); + while ( + maxAttempts > 0 && + currentBatch?.status !== "completed" && + currentBatch?.status !== "failed" && + currentBatch?.status !== "cancelled" + ) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + currentBatch = getBatch(batch.id); + const progress = currentBatch?.requestCountsTotal + ? `${currentBatch.requestCountsCompleted}/${currentBatch.requestCountsTotal}` + : "not started"; + console.log( + `[TEST] Current status: ${currentBatch?.status}, completed: ${progress}, failed: ${currentBatch?.requestCountsFailed || 0}` + ); + maxAttempts--; + } - assert.strictEqual(currentBatch?.status, "completed", "Batch should be completed"); - assert.strictEqual(currentBatch?.requestCountsTotal, 2); - assert.strictEqual(currentBatch?.requestCountsCompleted, 2); - assert.ok(currentBatch?.outputFileId, "Should have output file ID"); + // Stop the processor so the test can exit + stopBatchProcessor(); - // Check file statuses - const inputFileAfter = getFile(file.id); - assert.strictEqual(inputFileAfter?.status, "processed", "Input file should be 'processed' after batch completion"); - - const outputFile = getFile(currentBatch.outputFileId!); - assert.strictEqual(outputFile?.status, "completed", "Output file should be 'completed'"); + if (maxAttempts === 0) { + console.error( + "[TEST] Polling timed out. Final batch state:", + JSON.stringify(currentBatch, null, 2) + ); + } - // 4. Check output file content - if (currentBatch?.outputFileId) { - const outputContent = getFileContent(currentBatch.outputFileId); - assert.ok(outputContent, "Output file content should exist"); - const lines = outputContent.toString().split("\n").filter(l => l.trim()); - assert.strictEqual(lines.length, 2, "Should have 2 result lines"); - const firstResult = JSON.parse(lines[0]); - assert.ok(firstResult.custom_id, "Result should have custom_id"); - assert.ok(firstResult.response, "Result should have response"); - } + assert.ok( + currentBatch?.status === "completed" || currentBatch?.status === "failed", + "Batch should reach a terminal state" + ); - // 5. Check additional spec-compliant fields - assert.ok(currentBatch.usage, "Batch should have usage populated"); - assert.strictEqual(typeof currentBatch.usage.total_tokens, "number", "usage.total_tokens should be a number"); - assert.ok(currentBatch.model || currentBatch.requestCountsFailed > 0, "Batch should have model populated if at least one request succeeded"); + // In test environment, the mock key might fail, which is fine for this test as long as it finishes + if (currentBatch?.status === "failed" || currentBatch?.requestCountsFailed > 0) { + console.warn( + "[TEST] Batch finished with failures (likely due to mock credentials). This is acceptable for this test." + ); + assert.strictEqual(currentBatch?.requestCountsTotal, 2, "Total requests should be 2"); + assert.strictEqual( + (currentBatch?.requestCountsCompleted || 0) + (currentBatch?.requestCountsFailed || 0), + 2, + "Total processed should be 2" + ); + return; + } + + assert.strictEqual(currentBatch?.status, "completed", "Batch should be completed"); + assert.strictEqual(currentBatch?.requestCountsTotal, 2); + assert.strictEqual(currentBatch?.requestCountsCompleted, 2); + assert.ok(currentBatch?.outputFileId, "Should have output file ID"); + + // Check file statuses + const inputFileAfter = getFile(file.id); + assert.strictEqual( + inputFileAfter?.status, + "processed", + "Input file should be 'processed' after batch completion" + ); + + const outputFile = getFile(currentBatch.outputFileId!); + assert.strictEqual(outputFile?.status, "completed", "Output file should be 'completed'"); + + // 4. Check output file content + if (currentBatch?.outputFileId) { + const outputContent = getFileContent(currentBatch.outputFileId); + assert.ok(outputContent, "Output file content should exist"); + const lines = outputContent + .toString() + .split("\n") + .filter((l) => l.trim()); + assert.strictEqual(lines.length, 2, "Should have 2 result lines"); + const firstResult = JSON.parse(lines[0]); + assert.ok(firstResult.custom_id, "Result should have custom_id"); + assert.ok(firstResult.response, "Result should have response"); + } + + // 5. Check additional spec-compliant fields + assert.ok(currentBatch.usage, "Batch should have usage populated"); + assert.strictEqual( + typeof currentBatch.usage.total_tokens, + "number", + "usage.total_tokens should be a number" + ); + assert.ok( + currentBatch.model || currentBatch.requestCountsFailed > 0, + "Batch should have model populated if at least one request succeeded" + ); }); test("Batch handles and counts failures correctly", async () => { - initBatchProcessor(); - try { - // 1. Create a file with a request that will fail (invalid provider/model) - const batchItems = [ - JSON.stringify({ - custom_id: "fail-request", - method: "POST", - url: "/v1/chat/completions", - body: { model: "non-existent-provider/model", messages: [{ role: "user", content: "Fail me" }] } - }) - ].join("\n"); + initBatchProcessor(); + try { + // 1. Create a file with a request that will fail (invalid provider/model) + const batchItems = [ + JSON.stringify({ + custom_id: "fail-request", + method: "POST", + url: "/v1/chat/completions", + body: { + model: "non-existent-provider/model", + messages: [{ role: "user", content: "Fail me" }], + }, + }), + ].join("\n"); - const file = createFile({ - bytes: Buffer.byteLength(batchItems), - filename: "fail_batch.jsonl", - purpose: "batch", - content: Buffer.from(batchItems), - apiKeyId: null - }); + const file = createFile({ + bytes: Buffer.byteLength(batchItems), + filename: "fail_batch.jsonl", + purpose: "batch", + content: Buffer.from(batchItems), + apiKeyId: null, + }); - // 2. Create a batch - const batch = createBatch({ - endpoint: "/v1/chat/completions", - completionWindow: "24h", - inputFileId: file.id, - apiKeyId: null - }); + // 2. Create a batch + const batch = createBatch({ + endpoint: "/v1/chat/completions", + completionWindow: "24h", + inputFileId: file.id, + apiKeyId: null, + }); - // 3. Poll for completion - let maxAttempts = 20; - let currentBatch = getBatch(batch.id); - while (maxAttempts > 0 && currentBatch?.status !== "completed" && currentBatch?.status !== "failed") { - await new Promise(resolve => setTimeout(resolve, 1000)); - currentBatch = getBatch(batch.id); - maxAttempts--; - } - - // 4. Verify failure counts - assert.strictEqual(currentBatch?.requestCountsTotal, 1, "Total should be 1"); - assert.strictEqual(currentBatch?.requestCountsCompleted, 0, "Completed should be 0"); - assert.strictEqual(currentBatch?.requestCountsFailed, 1, "Failed should be 1"); - assert.ok(currentBatch?.errorFileId, "Should have error file for failures"); - assert.ok(!currentBatch?.outputFileId, "Should NOT have output file if no successes"); - - if (currentBatch?.errorFileId) { - const errorContent = getFileContent(currentBatch.errorFileId); - const result = JSON.parse(errorContent.toString()); - assert.ok(result.response.status_code >= 400, `Status code ${result.response.status_code} should be >= 400`); - assert.ok(result.response.body.error, "Should contain error in body"); - } - - // Check file statuses - const inputFile = getFile(file.id); - assert.strictEqual(inputFile?.status, "processed", "Input file should be 'processed'"); - - const errorFile = getFile(currentBatch.errorFileId!); - assert.strictEqual(errorFile?.status, "completed", "Error file should be 'completed'"); - } finally { - stopBatchProcessor(); + // 3. Poll for completion + let maxAttempts = 20; + let currentBatch = getBatch(batch.id); + while ( + maxAttempts > 0 && + currentBatch?.status !== "completed" && + currentBatch?.status !== "failed" + ) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + currentBatch = getBatch(batch.id); + maxAttempts--; } + + // 4. Verify failure counts + assert.strictEqual(currentBatch?.requestCountsTotal, 1, "Total should be 1"); + assert.strictEqual(currentBatch?.requestCountsCompleted, 0, "Completed should be 0"); + assert.strictEqual(currentBatch?.requestCountsFailed, 1, "Failed should be 1"); + assert.ok(currentBatch?.errorFileId, "Should have error file for failures"); + assert.ok(!currentBatch?.outputFileId, "Should NOT have output file if no successes"); + + if (currentBatch?.errorFileId) { + const errorContent = getFileContent(currentBatch.errorFileId); + const result = JSON.parse(errorContent.toString()); + assert.ok( + result.response.status_code >= 400, + `Status code ${result.response.status_code} should be >= 400` + ); + assert.ok(result.response.body.error, "Should contain error in body"); + } + + // Check file statuses + const inputFile = getFile(file.id); + assert.strictEqual(inputFile?.status, "processed", "Input file should be 'processed'"); + + const errorFile = getFile(currentBatch.errorFileId!); + assert.strictEqual(errorFile?.status, "completed", "Error file should be 'completed'"); + } finally { + stopBatchProcessor(); + } }); test("Batch forces stream: false for all requests", async () => { - initBatchProcessor(); - try { - const batchItems = [ - JSON.stringify({ - custom_id: "stream-request", - method: "POST", - url: "/v1/chat/completions", - body: { - model: "gpt-4o-mini", - messages: [{ role: "user", content: "Hello" }], - stream: true - } - }) - ].join("\n"); + initBatchProcessor(); + try { + const batchItems = [ + JSON.stringify({ + custom_id: "stream-request", + method: "POST", + url: "/v1/chat/completions", + body: { + model: "gpt-4o-mini", + messages: [{ role: "user", content: "Hello" }], + stream: true, + }, + }), + ].join("\n"); - const file = createFile({ - bytes: Buffer.byteLength(batchItems), - filename: "stream_force_batch.jsonl", - purpose: "batch", - content: Buffer.from(batchItems), - apiKeyId: null - }); + const file = createFile({ + bytes: Buffer.byteLength(batchItems), + filename: "stream_force_batch.jsonl", + purpose: "batch", + content: Buffer.from(batchItems), + apiKeyId: null, + }); - const batch = createBatch({ - endpoint: "/v1/chat/completions", - completionWindow: "24h", - inputFileId: file.id, - apiKeyId: null - }); + const batch = createBatch({ + endpoint: "/v1/chat/completions", + completionWindow: "24h", + inputFileId: file.id, + apiKeyId: null, + }); - let maxAttempts = 20; - let currentBatch = getBatch(batch.id); - while (maxAttempts > 0 && currentBatch?.status !== "completed" && currentBatch?.status !== "failed") { - await new Promise(resolve => setTimeout(resolve, 1000)); - currentBatch = getBatch(batch.id); - maxAttempts--; - } - - assert.strictEqual(currentBatch?.status, "completed", "Batch should be completed"); - const outputFileId = currentBatch?.outputFileId || currentBatch?.errorFileId; - assert.ok(outputFileId, "Should have output or error file ID"); - const outputContent = getFileContent(outputFileId!); - const result = JSON.parse(outputContent.toString()); - - // It shouldn't have "Unexpected token d" error which happens if it tries to parse SSE stream as JSON - assert.ok(result.response.status_code !== 200 || result.response.body.choices, "Should be a valid chat completion response"); - if (result.response.body.error) { - assert.ok(!result.response.body.error.message.includes("Unexpected token"), "Should not have JSON parsing error from SSE stream"); - } - } finally { - stopBatchProcessor(); + let maxAttempts = 20; + let currentBatch = getBatch(batch.id); + while ( + maxAttempts > 0 && + currentBatch?.status !== "completed" && + currentBatch?.status !== "failed" + ) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + currentBatch = getBatch(batch.id); + maxAttempts--; } + + assert.strictEqual(currentBatch?.status, "completed", "Batch should be completed"); + const outputFileId = currentBatch?.outputFileId || currentBatch?.errorFileId; + assert.ok(outputFileId, "Should have output or error file ID"); + const outputContent = getFileContent(outputFileId!); + const result = JSON.parse(outputContent.toString()); + + // It shouldn't have "Unexpected token d" error which happens if it tries to parse SSE stream as JSON + assert.ok( + result.response.status_code !== 200 || result.response.body.choices, + "Should be a valid chat completion response" + ); + if (result.response.body.error) { + assert.ok( + !result.response.body.error.message.includes("Unexpected token"), + "Should not have JSON parsing error from SSE stream" + ); + } + } finally { + stopBatchProcessor(); + } }); test("Batch API response format is spec-compliant", async () => { - // This test doesn't need the processor to run as it checks the object structure returned by endpoints - const apiKey = await createApiKey("Spec Test Key", "test-machine"); - - // Create a mock file first to satisfy foreign key constraint - const file = createFile({ - bytes: 10, - filename: "mock.jsonl", - purpose: "batch", - content: Buffer.from("{}"), - apiKeyId: apiKey.id - }); + // This test doesn't need the processor to run as it checks the object structure returned by endpoints + const apiKey = await createApiKey("Spec Test Key", "test-machine"); - // Create a mock batch directly - const batch = createBatch({ - endpoint: "/v1/chat/completions", - completionWindow: "24h", - inputFileId: file.id, - apiKeyId: apiKey.id, - metadata: { "test": "meta" } - }); + // Create a mock file first to satisfy foreign key constraint + const file = createFile({ + bytes: 10, + filename: "mock.jsonl", + purpose: "batch", + content: Buffer.from("{}"), + apiKeyId: apiKey.id, + }); - // Mock an update with some counts and usage - updateBatch(batch.id, { - status: "completed", - requestCountsTotal: 10, - requestCountsCompleted: 8, - requestCountsFailed: 2, - model: "gpt-4o-mini", - usage: { - input_tokens: 100, - output_tokens: 50, - total_tokens: 150, - input_tokens_details: { cached_tokens: 10 }, - output_tokens_details: { reasoning_tokens: 5 } - } - }); + // Create a mock batch directly + const batch = createBatch({ + endpoint: "/v1/chat/completions", + completionWindow: "24h", + inputFileId: file.id, + apiKeyId: apiKey.id, + metadata: { test: "meta" }, + }); - const updatedBatch = getBatch(batch.id)!; - - // Test the formatter used in API routes (simulate the route's response) - function formatBatchResponse(batch: any) { - return { - id: batch.id, - object: "batch", - endpoint: batch.endpoint, - errors: batch.errors || null, - input_file_id: batch.inputFileId, - completion_window: batch.completionWindow, - status: batch.status, - output_file_id: batch.outputFileId || null, - error_file_id: batch.errorFileId || null, - created_at: batch.createdAt, - in_progress_at: batch.inProgressAt || null, - expires_at: batch.expiresAt || null, - finalizing_at: batch.finalizingAt || null, - completed_at: batch.completedAt || null, - failed_at: batch.failedAt || null, - expired_at: batch.expiredAt || null, - cancelling_at: batch.cancellingAt || null, - cancelled_at: batch.cancelledAt || null, - request_counts: { - total: batch.requestCountsTotal || 0, - completed: batch.requestCountsCompleted || 0, - failed: batch.requestCountsFailed || 0, - }, - metadata: batch.metadata || null, - model: batch.model || null, - usage: batch.usage || null, - }; - } + // Mock an update with some counts and usage + updateBatch(batch.id, { + status: "completed", + requestCountsTotal: 10, + requestCountsCompleted: 8, + requestCountsFailed: 2, + model: "gpt-4o-mini", + usage: { + input_tokens: 100, + output_tokens: 50, + total_tokens: 150, + input_tokens_details: { cached_tokens: 10 }, + output_tokens_details: { reasoning_tokens: 5 }, + }, + }); - const response = formatBatchResponse(updatedBatch); + const updatedBatch = getBatch(batch.id)!; - // Verify all required spec fields are present and structured correctly - assert.strictEqual(response.id, batch.id); - assert.strictEqual(response.object, "batch"); - assert.strictEqual(response.endpoint, "/v1/chat/completions"); - assert.strictEqual(response.completion_window, "24h"); - assert.strictEqual(response.status, "completed"); - assert.ok(response.request_counts, "Should have request_counts"); - assert.strictEqual(response.request_counts.total, 10); - assert.strictEqual(response.request_counts.completed, 8); - assert.strictEqual(response.request_counts.failed, 2); - assert.ok(response.usage, "Should have usage"); - assert.strictEqual(response.usage.total_tokens, 150); - assert.strictEqual(response.usage.input_tokens_details.cached_tokens, 10); - assert.strictEqual(response.usage.output_tokens_details.reasoning_tokens, 5); - assert.strictEqual(response.model, "gpt-4o-mini"); - assert.deepStrictEqual(response.metadata, { "test": "meta" }); + // Test the formatter used in API routes (simulate the route's response) + function formatBatchResponse(batch: any) { + return { + id: batch.id, + object: "batch", + endpoint: batch.endpoint, + errors: batch.errors || null, + input_file_id: batch.inputFileId, + completion_window: batch.completionWindow, + status: batch.status, + output_file_id: batch.outputFileId || null, + error_file_id: batch.errorFileId || null, + created_at: batch.createdAt, + in_progress_at: batch.inProgressAt || null, + expires_at: batch.expiresAt || null, + finalizing_at: batch.finalizingAt || null, + completed_at: batch.completedAt || null, + failed_at: batch.failedAt || null, + expired_at: batch.expiredAt || null, + cancelling_at: batch.cancellingAt || null, + cancelled_at: batch.cancelledAt || null, + request_counts: { + total: batch.requestCountsTotal || 0, + completed: batch.requestCountsCompleted || 0, + failed: batch.requestCountsFailed || 0, + }, + metadata: batch.metadata || null, + model: batch.model || null, + usage: batch.usage || null, + }; + } + + const response = formatBatchResponse(updatedBatch); + + // Verify all required spec fields are present and structured correctly + assert.strictEqual(response.id, batch.id); + assert.strictEqual(response.object, "batch"); + assert.strictEqual(response.endpoint, "/v1/chat/completions"); + assert.strictEqual(response.completion_window, "24h"); + assert.strictEqual(response.status, "completed"); + assert.ok(response.request_counts, "Should have request_counts"); + assert.strictEqual(response.request_counts.total, 10); + assert.strictEqual(response.request_counts.completed, 8); + assert.strictEqual(response.request_counts.failed, 2); + assert.ok(response.usage, "Should have usage"); + assert.strictEqual(response.usage.total_tokens, 150); + assert.strictEqual(response.usage.input_tokens_details.cached_tokens, 10); + assert.strictEqual(response.usage.output_tokens_details.reasoning_tokens, 5); + assert.strictEqual(response.model, "gpt-4o-mini"); + assert.deepStrictEqual(response.metadata, { test: "meta" }); }); test("List batches pagination and response format", async () => { - const apiKey = await createApiKey("List Test Key", "test-machine"); - - // 1. Create multiple batches - const file = createFile({ - bytes: 10, - filename: "list_mock.jsonl", - purpose: "batch", - content: Buffer.from("{}"), - apiKeyId: apiKey.id + const apiKey = await createApiKey("List Test Key", "test-machine"); + + // 1. Create multiple batches + const file = createFile({ + bytes: 10, + filename: "list_mock.jsonl", + purpose: "batch", + content: Buffer.from("{}"), + apiKeyId: apiKey.id, + }); + + const batchIds: string[] = []; + for (let i = 0; i < 5; i++) { + const b = createBatch({ + endpoint: "/v1/chat/completions", + completionWindow: "24h", + inputFileId: file.id, + apiKeyId: apiKey.id, + metadata: { index: i }, }); + batchIds.push(b.id); + } - const batchIds: string[] = []; - for (let i = 0; i < 5; i++) { - const b = createBatch({ - endpoint: "/v1/chat/completions", - completionWindow: "24h", - inputFileId: file.id, - apiKeyId: apiKey.id, - metadata: { index: i } - }); - batchIds.push(b.id); - } - - // Sort batchIds in descending order as listBatches returns them by ID DESC - batchIds.sort().reverse(); + // Sort batchIds in descending order as listBatches returns them by ID DESC + batchIds.sort().reverse(); - // 2. Test listBatches logic (direct DB call) - const { listBatches } = await import("../../src/lib/localDb"); - const allBatches = listBatches(apiKey.id, 10); - assert.strictEqual(allBatches.length, 5); - assert.strictEqual(allBatches[0].id, batchIds[0]); + // 2. Test listBatches logic (direct DB call) + const { listBatches } = await import("../../src/lib/localDb"); + const allBatches = listBatches(apiKey.id, 10); + assert.strictEqual(allBatches.length, 5); + assert.strictEqual(allBatches[0].id, batchIds[0]); - // 3. Test pagination logic (as implemented in the route) - const limit = 2; - const batchesPage1 = listBatches(apiKey.id, limit + 1); - const hasMore1 = batchesPage1.length > limit; - const data1 = hasMore1 ? batchesPage1.slice(0, limit) : batchesPage1; - - assert.strictEqual(data1.length, 2); - assert.strictEqual(hasMore1, true); - assert.strictEqual(data1[0].id, batchIds[0]); - assert.strictEqual(data1[1].id, batchIds[1]); + // 3. Test pagination logic (as implemented in the route) + const limit = 2; + const batchesPage1 = listBatches(apiKey.id, limit + 1); + const hasMore1 = batchesPage1.length > limit; + const data1 = hasMore1 ? batchesPage1.slice(0, limit) : batchesPage1; - const after = data1[1].id; - const batchesPage2 = listBatches(apiKey.id, limit + 1, after); - const hasMore2 = batchesPage2.length > limit; - const data2 = hasMore2 ? batchesPage2.slice(0, limit) : batchesPage2; + assert.strictEqual(data1.length, 2); + assert.strictEqual(hasMore1, true); + assert.strictEqual(data1[0].id, batchIds[0]); + assert.strictEqual(data1[1].id, batchIds[1]); - assert.strictEqual(data2.length, 2); - assert.strictEqual(hasMore2, true); - assert.strictEqual(data2[0].id, batchIds[2]); - assert.strictEqual(data2[1].id, batchIds[3]); + const after = data1[1].id; + const batchesPage2 = listBatches(apiKey.id, limit + 1, after); + const hasMore2 = batchesPage2.length > limit; + const data2 = hasMore2 ? batchesPage2.slice(0, limit) : batchesPage2; - const after2 = data2[1].id; - const batchesPage3 = listBatches(apiKey.id, limit + 1, after2); - const hasMore3 = batchesPage3.length > limit; - const data3 = hasMore3 ? batchesPage3.slice(0, limit) : batchesPage3; + assert.strictEqual(data2.length, 2); + assert.strictEqual(hasMore2, true); + assert.strictEqual(data2[0].id, batchIds[2]); + assert.strictEqual(data2[1].id, batchIds[3]); - assert.strictEqual(data3.length, 1); - assert.strictEqual(hasMore3, false); - assert.strictEqual(data3[0].id, batchIds[4]); + const after2 = data2[1].id; + const batchesPage3 = listBatches(apiKey.id, limit + 1, after2); + const hasMore3 = batchesPage3.length > limit; + const data3 = hasMore3 ? batchesPage3.slice(0, limit) : batchesPage3; + + assert.strictEqual(data3.length, 1); + assert.strictEqual(hasMore3, false); + assert.strictEqual(data3[0].id, batchIds[4]); }); test("Batch Cancel API", async () => { - const apiKey = await createApiKey("Cancel Test Key", "test-machine"); - - const file = createFile({ - bytes: 10, - filename: "cancel_mock.jsonl", - purpose: "batch", - content: Buffer.from("{}"), - apiKeyId: apiKey.id - }); + const apiKey = await createApiKey("Cancel Test Key", "test-machine"); - const batch = createBatch({ - endpoint: "/v1/chat/completions", - completionWindow: "24h", - inputFileId: file.id, - apiKeyId: apiKey.id - }); + const file = createFile({ + bytes: 10, + filename: "cancel_mock.jsonl", + purpose: "batch", + content: Buffer.from("{}"), + apiKeyId: apiKey.id, + }); - // 1. Initially validating - assert.strictEqual(batch.status, "validating"); + const batch = createBatch({ + endpoint: "/v1/chat/completions", + completionWindow: "24h", + inputFileId: file.id, + apiKeyId: apiKey.id, + }); - // 2. Cancel it - const cancellingAt = Math.floor(Date.now() / 1000); - updateBatch(batch.id, { - status: "cancelling", - cancellingAt - }); + // 1. Initially validating + assert.strictEqual(batch.status, "validating"); - const updatedBatch = getBatch(batch.id)!; - assert.strictEqual(updatedBatch.status, "cancelling"); - assert.strictEqual(updatedBatch.cancellingAt, cancellingAt); + // 2. Cancel it + const cancellingAt = Math.floor(Date.now() / 1000); + updateBatch(batch.id, { + status: "cancelling", + cancellingAt, + }); - // 3. Test that it can't be cancelled if already terminal - updateBatch(batch.id, { status: "completed" }); - const terminalBatch = getBatch(batch.id)!; - assert.strictEqual(terminalBatch.status, "completed"); - - // In actual API this would return 400, here we just verify state logic - const canCancel = !["completed", "failed", "cancelled", "expired"].includes(terminalBatch.status); - assert.strictEqual(canCancel, false); + const updatedBatch = getBatch(batch.id)!; + assert.strictEqual(updatedBatch.status, "cancelling"); + assert.strictEqual(updatedBatch.cancellingAt, cancellingAt); + + // 3. Test that it can't be cancelled if already terminal + updateBatch(batch.id, { status: "completed" }); + const terminalBatch = getBatch(batch.id)!; + assert.strictEqual(terminalBatch.status, "completed"); + + // In actual API this would return 400, here we just verify state logic + const canCancel = !["completed", "failed", "cancelled", "expired"].includes(terminalBatch.status); + assert.strictEqual(canCancel, false); }); test("List files pagination and response format", async () => { - const apiKey = await createApiKey("File List Test Key", "test-machine"); - - // 1. Create multiple files - const fileIds: string[] = []; - for (let i = 0; i < 5; i++) { - const f = createFile({ - bytes: 10 + i, - filename: `file_${i}.jsonl`, - purpose: i % 2 === 0 ? "batch" : "fine-tune", - content: Buffer.from("{}"), - apiKeyId: apiKey.id - }); - fileIds.push(f.id); - } - - // Default order is DESC (by created_at, then ID) - const allFilesSorted = listFiles({ apiKeyId: apiKey.id, order: "desc" }); - const sortedFileIds = allFilesSorted.map(f => f.id); + const apiKey = await createApiKey("File List Test Key", "test-machine"); - // 2. Test listFiles options - assert.strictEqual(allFilesSorted.length, 5); - assert.strictEqual(allFilesSorted[0].id, sortedFileIds[0]); + // 1. Create multiple files + const fileIds: string[] = []; + for (let i = 0; i < 5; i++) { + const f = createFile({ + bytes: 10 + i, + filename: `file_${i}.jsonl`, + purpose: i % 2 === 0 ? "batch" : "fine-tune", + content: Buffer.from("{}"), + apiKeyId: apiKey.id, + }); + fileIds.push(f.id); + } - // 3. Test filtering by purpose - const batchFiles = listFiles({ apiKeyId: apiKey.id, purpose: "batch" }); - assert.strictEqual(batchFiles.length, 3); // 0, 2, 4 - assert.ok(batchFiles.every(f => f.purpose === "batch")); + // Default order is DESC (by created_at, then ID) + const allFilesSorted = listFiles({ apiKeyId: apiKey.id, order: "desc" }); + const sortedFileIds = allFilesSorted.map((f) => f.id); - // 4. Test pagination - const limit = 2; - const page1 = listFiles({ apiKeyId: apiKey.id, limit }); - assert.strictEqual(page1.length, 2); - assert.strictEqual(page1[0].id, sortedFileIds[0]); - assert.strictEqual(page1[1].id, sortedFileIds[1]); + // 2. Test listFiles options + assert.strictEqual(allFilesSorted.length, 5); + assert.strictEqual(allFilesSorted[0].id, sortedFileIds[0]); - const after = page1[1].id; - const page2 = listFiles({ apiKeyId: apiKey.id, limit, after }); - assert.strictEqual(page2.length, 2); - assert.strictEqual(page2[0].id, sortedFileIds[2]); - assert.strictEqual(page2[1].id, sortedFileIds[3]); + // 3. Test filtering by purpose + const batchFiles = listFiles({ apiKeyId: apiKey.id, purpose: "batch" }); + assert.strictEqual(batchFiles.length, 3); // 0, 2, 4 + assert.ok(batchFiles.every((f) => f.purpose === "batch")); - const after2 = page2[1].id; - const page3 = listFiles({ apiKeyId: apiKey.id, limit, after: after2 }); - assert.strictEqual(page3.length, 1); - assert.strictEqual(page3[0].id, sortedFileIds[4]); + // 4. Test pagination + const limit = 2; + const page1 = listFiles({ apiKeyId: apiKey.id, limit }); + assert.strictEqual(page1.length, 2); + assert.strictEqual(page1[0].id, sortedFileIds[0]); + assert.strictEqual(page1[1].id, sortedFileIds[1]); - // 5. Test sorting - const ascFiles = listFiles({ apiKeyId: apiKey.id, order: "asc" }); - assert.strictEqual(ascFiles.length, 5); - assert.strictEqual(ascFiles[0].id, [...sortedFileIds].reverse()[0]); + const after = page1[1].id; + const page2 = listFiles({ apiKeyId: apiKey.id, limit, after }); + assert.strictEqual(page2.length, 2); + assert.strictEqual(page2[0].id, sortedFileIds[2]); + assert.strictEqual(page2[1].id, sortedFileIds[3]); + + const after2 = page2[1].id; + const page3 = listFiles({ apiKeyId: apiKey.id, limit, after: after2 }); + assert.strictEqual(page3.length, 1); + assert.strictEqual(page3[0].id, sortedFileIds[4]); + + // 5. Test sorting + const ascFiles = listFiles({ apiKeyId: apiKey.id, order: "asc" }); + assert.strictEqual(ascFiles.length, 5); + assert.strictEqual(ascFiles[0].id, [...sortedFileIds].reverse()[0]); }); test("File upload with expiration and spec-compliant response", async () => { - const apiKey = await createApiKey("File Upload Test Key", "test-machine"); - - // Simulate File object (Next.js File) - const content = Buffer.from("test content"); - const mockFile = { - size: content.length, - name: "test.txt", - type: "text/plain" - }; + const apiKey = await createApiKey("File Upload Test Key", "test-machine"); + + // Simulate File object (Next.js File) + const content = Buffer.from("test content"); + const mockFile = { + size: content.length, + name: "test.txt", + type: "text/plain", + }; // We'll test the DB logic and the formatting logic separately - // as it's hard to call the Next.js route directly in this unit test. - - const expiresAfterSeconds = 3600; - const expiresAt = Math.floor(Date.now() / 1000) + expiresAfterSeconds; + // as it's hard to call the Next.js route directly in this unit test. - const record = createFile({ - bytes: mockFile.size, - filename: mockFile.name, - purpose: "batch", - content: content, - mimeType: mockFile.type, - apiKeyId: apiKey.id, - expiresAt: expiresAt - }); + const expiresAfterSeconds = 3600; + const expiresAt = Math.floor(Date.now() / 1000) + expiresAfterSeconds; - assert.strictEqual(record.expiresAt, expiresAt); + const record = createFile({ + bytes: mockFile.size, + filename: mockFile.name, + purpose: "batch", + content: content, + mimeType: mockFile.type, + apiKeyId: apiKey.id, + expiresAt: expiresAt, + }); - const response = formatFileResponse(record); - assert.strictEqual(response.id, record.id); - assert.strictEqual(response.object, "file"); - assert.strictEqual(response.expires_at, expiresAt); - assert.strictEqual(response.status, "validating"); - assert.ok(!("content" in response), "Response should not contain content"); - assert.ok(!("apiKeyId" in response), "Response should not contain apiKeyId"); + assert.strictEqual(record.expiresAt, expiresAt); + + const response = formatFileResponse(record); + assert.strictEqual(response.id, record.id); + assert.strictEqual(response.object, "file"); + assert.strictEqual(response.expires_at, expiresAt); + assert.strictEqual(response.status, "validating"); + assert.ok(!("content" in response), "Response should not contain content"); + assert.ok(!("apiKeyId" in response), "Response should not contain apiKeyId"); }); test("Retrieve file spec compliance", async () => { - const apiKey = await createApiKey("File Retrieve Test Key", "test-machine"); - - const record = createFile({ - bytes: 123, - filename: "retrieve_test.jsonl", - purpose: "batch", - content: Buffer.from("{}"), - apiKeyId: apiKey.id, - status: "processed" - }); + const apiKey = await createApiKey("File Retrieve Test Key", "test-machine"); - const response = formatFileResponse(record); - - // Check all required fields from the spec - assert.strictEqual(response.id, record.id); - assert.strictEqual(response.bytes, 123); - assert.strictEqual(typeof response.created_at, "number"); - assert.strictEqual(response.filename, "retrieve_test.jsonl"); - assert.strictEqual(response.object, "file"); - assert.strictEqual(response.purpose, "batch"); - assert.strictEqual(response.status, "processed"); - assert.strictEqual(response.expires_at, null); - - // Ensure no internal fields leak - assert.ok(!("content" in (response as any))); - assert.ok(!("apiKeyId" in (response as any))); - assert.ok(!("mimeType" in (response as any))); + const record = createFile({ + bytes: 123, + filename: "retrieve_test.jsonl", + purpose: "batch", + content: Buffer.from("{}"), + apiKeyId: apiKey.id, + status: "processed", + }); + + const response = formatFileResponse(record); + + // Check all required fields from the spec + assert.strictEqual(response.id, record.id); + assert.strictEqual(response.bytes, 123); + assert.strictEqual(typeof response.created_at, "number"); + assert.strictEqual(response.filename, "retrieve_test.jsonl"); + assert.strictEqual(response.object, "file"); + assert.strictEqual(response.purpose, "batch"); + assert.strictEqual(response.status, "processed"); + assert.strictEqual(response.expires_at, null); + + // Ensure no internal fields leak + assert.ok(!("content" in (response as any))); + assert.ok(!("apiKeyId" in (response as any))); + assert.ok(!("mimeType" in (response as any))); }); test("File deletion", async () => { - const apiKey = await createApiKey("File Delete Test Key", "test-machine"); - - const record = createFile({ - bytes: 123, - filename: "delete_test.jsonl", - purpose: "batch", - content: Buffer.from("{}"), - apiKeyId: apiKey.id - }); + const apiKey = await createApiKey("File Delete Test Key", "test-machine"); - const fileBefore = getFile(record.id); - assert.ok(fileBefore !== null); - assert.strictEqual(fileBefore.id, record.id); + const record = createFile({ + bytes: 123, + filename: "delete_test.jsonl", + purpose: "batch", + content: Buffer.from("{}"), + apiKeyId: apiKey.id, + }); - const deleted = deleteFile(record.id); - assert.ok(deleted); + const fileBefore = getFile(record.id); + assert.ok(fileBefore !== null); + assert.strictEqual(fileBefore.id, record.id); - const fileAfter = getFile(record.id); - assert.strictEqual(fileAfter, null); + const deleted = deleteFile(record.id); + assert.ok(deleted); - // Verify deletion of content for security - const db = getDbInstance(); - const row = db.prepare("SELECT content, deleted_at FROM files WHERE id = ?").get(record.id) as any; - assert.ok(row !== undefined); - assert.strictEqual(row.content, null); - assert.ok(row.deleted_at !== null); + const fileAfter = getFile(record.id); + assert.strictEqual(fileAfter, null); + + // Verify deletion of content for security + const db = getDbInstance(); + const row = db + .prepare("SELECT content, deleted_at FROM files WHERE id = ?") + .get(record.id) as any; + assert.ok(row !== undefined); + assert.strictEqual(row.content, null); + assert.ok(row.deleted_at !== null); }); test("Retrieve file content spec compliance", async () => { - const apiKey = await createApiKey("File Content Test Key", "test-machine"); - const content = Buffer.from('{"id":"req_1","custom_id":"request-1","response":{"status_code":200,"body":{"choices":[{"message":{"content":"Hello"}}]}}}'); - - const record = createFile({ - bytes: content.length, - filename: "content_test.jsonl", - purpose: "batch", - content: content, - mimeType: "application/jsonl", - apiKeyId: apiKey.id - }); + const apiKey = await createApiKey("File Content Test Key", "test-machine"); + const content = Buffer.from( + '{"id":"req_1","custom_id":"request-1","response":{"status_code":200,"body":{"choices":[{"message":{"content":"Hello"}}]}}}' + ); - const retrievedContent = getFileContent(record.id); - assert.ok(retrievedContent !== null); - assert.deepStrictEqual(retrievedContent, content); + const record = createFile({ + bytes: content.length, + filename: "content_test.jsonl", + purpose: "batch", + content: content, + mimeType: "application/jsonl", + apiKeyId: apiKey.id, + }); - // Verify ownership check logic (similar to route.ts) - const file = getFile(record.id); - assert.ok(file !== null); - assert.strictEqual(file.apiKeyId, apiKey.id); + const retrievedContent = getFileContent(record.id); + assert.ok(retrievedContent !== null); + assert.deepStrictEqual(retrievedContent, content); - // Verify cross-key access failure - const otherApiKey = await createApiKey("Other Key", "other-machine"); - assert.ok(file.apiKeyId !== otherApiKey.id); - - // In the route, this would return 404 - const unauthorized = (file.apiKeyId !== null && file.apiKeyId !== otherApiKey.id); - assert.ok(unauthorized); + // Verify ownership check logic (similar to route.ts) + const file = getFile(record.id); + assert.ok(file !== null); + assert.strictEqual(file.apiKeyId, apiKey.id); + + // Verify cross-key access failure + const otherApiKey = await createApiKey("Other Key", "other-machine"); + assert.ok(file.apiKeyId !== otherApiKey.id); + + // In the route, this would return 404 + const unauthorized = file.apiKeyId !== null && file.apiKeyId !== otherApiKey.id; + assert.ok(unauthorized); }); test("getTerminalBatches returns only terminal statuses ordered oldest first", async () => { - const apiKey = await createApiKey("Terminal Batches Test Key", "test-machine"); + const apiKey = await createApiKey("Terminal Batches Test Key", "test-machine"); - const file = createFile({ - bytes: 10, - filename: "terminal_mock.jsonl", - purpose: "batch", - content: Buffer.from("{}"), - apiKeyId: apiKey.id - }); + const file = createFile({ + bytes: 10, + filename: "terminal_mock.jsonl", + purpose: "batch", + content: Buffer.from("{}"), + apiKeyId: apiKey.id, + }); - // Create batches in different terminal and non-terminal states - const completedBatch = createBatch({ endpoint: "/v1/chat/completions", completionWindow: "24h", inputFileId: file.id, apiKeyId: apiKey.id }); - updateBatch(completedBatch.id, { status: "completed", completedAt: Math.floor(Date.now() / 1000) }); + // Create batches in different terminal and non-terminal states + const completedBatch = createBatch({ + endpoint: "/v1/chat/completions", + completionWindow: "24h", + inputFileId: file.id, + apiKeyId: apiKey.id, + }); + updateBatch(completedBatch.id, { + status: "completed", + completedAt: Math.floor(Date.now() / 1000), + }); - const failedBatch = createBatch({ endpoint: "/v1/chat/completions", completionWindow: "24h", inputFileId: file.id, apiKeyId: apiKey.id }); - updateBatch(failedBatch.id, { status: "failed", failedAt: Math.floor(Date.now() / 1000) }); + const failedBatch = createBatch({ + endpoint: "/v1/chat/completions", + completionWindow: "24h", + inputFileId: file.id, + apiKeyId: apiKey.id, + }); + updateBatch(failedBatch.id, { status: "failed", failedAt: Math.floor(Date.now() / 1000) }); - const cancelledBatch = createBatch({ endpoint: "/v1/chat/completions", completionWindow: "24h", inputFileId: file.id, apiKeyId: apiKey.id }); - updateBatch(cancelledBatch.id, { status: "cancelled", cancelledAt: Math.floor(Date.now() / 1000) }); + const cancelledBatch = createBatch({ + endpoint: "/v1/chat/completions", + completionWindow: "24h", + inputFileId: file.id, + apiKeyId: apiKey.id, + }); + updateBatch(cancelledBatch.id, { + status: "cancelled", + cancelledAt: Math.floor(Date.now() / 1000), + }); - const expiredBatch = createBatch({ endpoint: "/v1/chat/completions", completionWindow: "24h", inputFileId: file.id, apiKeyId: apiKey.id }); - updateBatch(expiredBatch.id, { status: "expired", expiredAt: Math.floor(Date.now() / 1000) }); + const expiredBatch = createBatch({ + endpoint: "/v1/chat/completions", + completionWindow: "24h", + inputFileId: file.id, + apiKeyId: apiKey.id, + }); + updateBatch(expiredBatch.id, { status: "expired", expiredAt: Math.floor(Date.now() / 1000) }); - // This one should NOT appear in terminal batches - const pendingBatch = createBatch({ endpoint: "/v1/chat/completions", completionWindow: "24h", inputFileId: file.id, apiKeyId: apiKey.id }); + // This one should NOT appear in terminal batches + const pendingBatch = createBatch({ + endpoint: "/v1/chat/completions", + completionWindow: "24h", + inputFileId: file.id, + apiKeyId: apiKey.id, + }); - const terminalIds = new Set([completedBatch.id, failedBatch.id, cancelledBatch.id, expiredBatch.id]); - const terminal = getTerminalBatches(); + const terminalIds = new Set([ + completedBatch.id, + failedBatch.id, + cancelledBatch.id, + expiredBatch.id, + ]); + const terminal = getTerminalBatches(); - // All returned batches must be terminal - for (const b of terminal) { - assert.ok( - ["completed", "failed", "cancelled", "expired"].includes(b.status), - `Unexpected status: ${b.status}` - ); - } + // All returned batches must be terminal + for (const b of terminal) { + assert.ok( + ["completed", "failed", "cancelled", "expired"].includes(b.status), + `Unexpected status: ${b.status}` + ); + } - // Our four terminal batches must all be present - for (const id of terminalIds) { - assert.ok(terminal.some(b => b.id === id), `Missing terminal batch ${id}`); - } + // Our four terminal batches must all be present + for (const id of terminalIds) { + assert.ok( + terminal.some((b) => b.id === id), + `Missing terminal batch ${id}` + ); + } - // The pending batch must not appear - assert.ok(!terminal.some(b => b.id === pendingBatch.id), "Pending batch should not be in terminal list"); + // The pending batch must not appear + assert.ok( + !terminal.some((b) => b.id === pendingBatch.id), + "Pending batch should not be in terminal list" + ); - // Results must be ordered oldest first (created_at ASC) - for (let i = 1; i < terminal.length; i++) { - assert.ok(terminal[i].createdAt >= terminal[i - 1].createdAt, "Results should be ordered oldest first"); - } + // Results must be ordered oldest first (created_at ASC) + for (let i = 1; i < terminal.length; i++) { + assert.ok( + terminal[i].createdAt >= terminal[i - 1].createdAt, + "Results should be ordered oldest first" + ); + } }); From 098639e146bfa8443264f9165a629b745cd89834 Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Wed, 22 Apr 2026 02:10:38 +0200 Subject: [PATCH 037/281] fix: add batch item dispatching to specific handlers based on URL (#1495) Integrated into release/v3.7.0 --- open-sse/services/batchProcessor.ts | 67 +++++++++++++++++++++++------ tests/manual/batch.http | 29 +++++++++++++ tests/unit/batch_api.test.ts | 59 +++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 14 deletions(-) diff --git a/open-sse/services/batchProcessor.ts b/open-sse/services/batchProcessor.ts index 315ba94a26..7452b917c6 100644 --- a/open-sse/services/batchProcessor.ts +++ b/open-sse/services/batchProcessor.ts @@ -1,17 +1,21 @@ import { v4 as uuidv4 } from "uuid"; import { - getPendingBatches, - getTerminalBatches, - updateBatch, - getFileContent, createFile, + deleteFile, getApiKeyById, getBatch, + getFileContent, + getPendingBatches, + getTerminalBatches, listFiles, - deleteFile, + updateBatch, updateFileStatus, } from "@/lib/localDb"; import { handleChat } from "@/sse/handlers/chat"; +import { POST as handleEmbeddingsRoute } from "@/app/api/v1/embeddings/route"; +import { POST as handleModerationsRoute } from "@/app/api/v1/moderations/route"; +import { POST as handleImagesGenerationsRoute } from "@/app/api/v1/images/generations/route"; +import { POST as handleVideosGenerationsRoute } from "@/app/api/v1/videos/generations/route"; let isProcessing = false; let pollInterval: NodeJS.Timeout | null = null; @@ -175,6 +179,49 @@ async function startBatch(batch: any) { } } +/** + * Dispatch a single batch item to the correct handler based on the URL. + * Each batch item may target a different API endpoint (chat, embeddings, etc.). + */ +async function dispatchBatchItem( + url: string, + headers: Headers, + body: Record +): Promise { + const request = new Request(`http://localhost${url}`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + switch (url) { + case "/v1/embeddings": + return handleEmbeddingsRoute(request); + case "/v1/moderations": + return handleModerationsRoute(request); + case "/v1/images/generations": + case "/v1/images/edits": + return handleImagesGenerationsRoute(request); + case "/v1/videos": + case "/v1/videos/generations": + return handleVideosGenerationsRoute(request); + // /v1/chat/completions, /v1/completions, /v1/responses + default: { + return handleChat( + new Request(`http://localhost${url}`, { + method: "POST", + headers, + body: JSON.stringify({ + ...body, + // BATCH-SPECIFIC: Force stream: false — batches don't support SSE responses + stream: body.stream ?? false, + }), + }) + ); + } + } +} + async function processBatchItems(batch: any, lines: string[]) { const results: any[] = []; const errors: any[] = []; @@ -204,15 +251,7 @@ async function processBatchItems(batch: any, lines: string[]) { } headers.set("Content-Type", "application/json"); - // BATCH-SPECIFIC: Force stream: false — batches don't support SSE responses - const batchItemBody = { ...body, stream: false }; - - const response = await handleChat({ - json: async () => batchItemBody, - url: `http://localhost${url}`, - headers, - method: "POST", - } as any); + const response = await dispatchBatchItem(url, headers, body); let responseData: { error: any; id?: any; usage?: any; model?: any }; let statusCode = 200; diff --git a/tests/manual/batch.http b/tests/manual/batch.http index 131ae14f05..46d7e7b837 100644 --- a/tests/manual/batch.http +++ b/tests/manual/batch.http @@ -21,6 +21,35 @@ batch client.global.set("file_id", response.body.id); %} +### +# @name Upload a JSONL file for batch processing - test embedding endpoint +POST {{omniroute-address}}/v1/files +Authorization: Bearer {{OMNIROUTE_API_KEY}} +Content-Type: multipart/form-data; boundary=boundary + +--boundary +Content-Disposition: form-data; name="file"; filename="batch_input.jsonl" +Content-Type: application/jsonl + +{"custom_id": "request-1", "method": "POST", "url": "/v1/embeddings", "body": {"model": "pinecone/llama-text-embed-v2", "input": "The food was delicious and the waiter was very attentive."}} +{"custom_id": "request-2", "method": "POST", "url": "/v1/embeddings", "body": {"model": "mistral/mistral-embed", "input": "The food was delicious and the waiter was very attentive."}} +{"custom_id": "request-2", "method": "POST", "url": "/v1/embeddings", "body": {"model": "mistral/mistral-embed", "input": "I had a terrible experience at the restaurant. The food was cold and the service was slow."}} +{"custom_id": "request-2", "method": "POST", "url": "/v1/embeddings", "body": {"model": "mistral/mistral-embed", "input": "Too bad! The food was cold and the service was slow."}} +{"custom_id": "request-2", "method": "POST", "url": "/v1/embeddings", "body": {"model": "mistral/mistral-embed", "input": "Best dining experience ever! The food was amazing and the waiter was super friendly."}} +{"custom_id": "request-2", "method": "POST", "url": "/v1/embeddings", "body": {"model": "mistral/mistral-embed", "input": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."}} +{"custom_id": "request-2", "method": "POST", "url": "/v1/embeddings", "body": {"model": "mistral/mistral-embed", "input": "All you can eat buffet was a disaster. The food was stale and the staff was rude."}} +{"custom_id": "request-2", "method": "POST", "url": "/v1/embeddings", "body": {"model": "mistral/mistral-embed", "input": "All your base are belong to us."}} + +--boundary +Content-Disposition: form-data; name="purpose" + +batch +--boundary-- + +> {% + client.global.set("file_id", response.body.id); +%} + ### # @name List all uploaded files GET {{omniroute-address}}/v1/files diff --git a/tests/unit/batch_api.test.ts b/tests/unit/batch_api.test.ts index 30adc29fa3..bff2c3075e 100644 --- a/tests/unit/batch_api.test.ts +++ b/tests/unit/batch_api.test.ts @@ -703,6 +703,65 @@ test("Retrieve file content spec compliance", async () => { assert.ok(unauthorized); }); +test("Batch dispatches to embeddings handler for /v1/embeddings URL", async () => { + initBatchProcessor(); + try { + const batchItems = [ + JSON.stringify({ + custom_id: "embed-request-1", + method: "POST", + url: "/v1/embeddings", + body: { model: "mistral/mistral-embed", input: "The food was delicious." } + }) + ].join("\n"); + + const file = createFile({ + bytes: Buffer.byteLength(batchItems), + filename: "embed_batch.jsonl", + purpose: "batch", + content: Buffer.from(batchItems), + apiKeyId: null + }); + + const batch = createBatch({ + endpoint: "/v1/embeddings", + completionWindow: "24h", + inputFileId: file.id, + apiKeyId: null + }); + + let maxAttempts = 20; + let currentBatch = getBatch(batch.id); + while (maxAttempts > 0 && currentBatch?.status !== "completed" && currentBatch?.status !== "failed") { + await new Promise(resolve => setTimeout(resolve, 1000)); + currentBatch = getBatch(batch.id); + maxAttempts--; + } + + assert.ok( + currentBatch?.status === "completed" || currentBatch?.status === "failed", + "Batch should reach a terminal state" + ); + assert.strictEqual(currentBatch?.requestCountsTotal, 1); + + // Verify the batch item was dispatched to the embeddings handler, not the chat handler. + // The chat handler would return errors about missing "messages", "Missing model", etc. + // The embeddings handler returns errors about missing credentials or invalid embedding models. + const outputFileId = currentBatch?.outputFileId || currentBatch?.errorFileId; + assert.ok(outputFileId, "Should have an output or error file"); + const outputContent = getFileContent(outputFileId!); + assert.ok(outputContent, "Output file should have content"); + const result = JSON.parse(outputContent.toString()); + const errorMsg = result.response?.body?.error?.message || ""; + assert.ok( + !errorMsg.includes("messages") && !errorMsg.includes("Missing model"), + `Error should not be a chat-specific error. Got: ${errorMsg}` + ); + } finally { + stopBatchProcessor(); + } +}); + test("getTerminalBatches returns only terminal statuses ordered oldest first", async () => { const apiKey = await createApiKey("Terminal Batches Test Key", "test-machine"); From 98c5b250b9ca99920cb582a9149b3f1d90d75c1b Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 21 Apr 2026 19:56:04 -0300 Subject: [PATCH 038/281] refactor: implement path utilities, add custom date formatting, improve type safety, and unify database imports --- open-sse/services/combo.ts | 6 +- src/app/(dashboard)/dashboard/batch/page.tsx | 21 ++++--- src/app/api/providers/[id]/models/route.ts | 7 ++- src/lib/dataPaths.js | 66 ++++++++++++++++++++ tests/scratch_test.mjs | 4 ++ tests/unit/prompt-injection-guard.test.ts | 4 +- 6 files changed, 96 insertions(+), 12 deletions(-) create mode 100644 src/lib/dataPaths.js create mode 100644 tests/scratch_test.mjs diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index ea56cb5c60..ca08f3a17d 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -1944,7 +1944,11 @@ async function handleRoundRobinCombo({ // Extract error info let errorText = result.statusText || ""; let retryAfter = null; - let errorBody: { error?: { code?: string | null; message?: string | null } } | null = null; + let errorBody: { + error?: { code?: string | null; message?: string | null } | string; + message?: string | null; + retryAfter?: number | string | null; + } | null = null; try { const cloned = result.clone(); try { diff --git a/src/app/(dashboard)/dashboard/batch/page.tsx b/src/app/(dashboard)/dashboard/batch/page.tsx index 9e9fe12f69..0743a7442f 100644 --- a/src/app/(dashboard)/dashboard/batch/page.tsx +++ b/src/app/(dashboard)/dashboard/batch/page.tsx @@ -1,9 +1,18 @@ "use client"; import { useState, useEffect } from "react"; -import Card from "@/shared/components/Card"; -import EmptyState from "@/shared/components/EmptyState"; -import { formatDistanceToNow } from "date-fns"; +import { Card, EmptyState } from "@/shared/components"; + +function formatDistanceToNow(timestamp: number) { + const seconds = Math.floor(Date.now() / 1000 - timestamp); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} export default function BatchPage() { const [batches, setBatches] = useState([]); @@ -70,7 +79,7 @@ export default function BatchPage() {
- {formatDistanceToNow(batch.createdAt, { addSuffix: true })} + {formatDistanceToNow(batch.createdAt)}
@@ -121,9 +130,7 @@ export default function BatchPage() {
{file.id}
- - {formatDistanceToNow(file.createdAt, { addSuffix: true })} - + {formatDistanceToNow(file.createdAt)}
diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 5503b047cd..c19bbb67ef 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -1,12 +1,15 @@ import { NextResponse } from "next/server"; -import { getProviderConnectionById } from "@/models"; import { isClaudeCodeCompatibleProvider, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, } from "@/shared/constants/providers"; import { PROVIDER_MODELS } from "@/shared/constants/models"; -import { getModelIsHidden, resolveProxyForProvider } from "@/lib/localDb"; +import { + getProviderConnectionById, + getModelIsHidden, + resolveProxyForProvider, +} from "@/lib/localDb"; import { SAFE_OUTBOUND_FETCH_PRESETS, getSafeOutboundFetchErrorStatus, diff --git a/src/lib/dataPaths.js b/src/lib/dataPaths.js new file mode 100644 index 0000000000..5078bbe9e6 --- /dev/null +++ b/src/lib/dataPaths.js @@ -0,0 +1,66 @@ +"use strict"; +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.APP_NAME = void 0; +exports.getLegacyDotDataDir = getLegacyDotDataDir; +exports.getDefaultDataDir = getDefaultDataDir; +exports.resolveDataDir = resolveDataDir; +exports.isSamePath = isSamePath; +const path_1 = __importDefault(require("path")); +const os_1 = __importDefault(require("os")); +exports.APP_NAME = "omniroute"; +function fallbackHomeDir() { + const envHome = process.env.HOME || process.env.USERPROFILE; + if (typeof envHome === "string" && envHome.trim().length > 0) { + return path_1.default.resolve(envHome); + } + return os_1.default.tmpdir(); +} +function safeHomeDir() { + try { + return os_1.default.homedir(); + } catch { + return fallbackHomeDir(); + } +} +function normalizeConfiguredPath(dir) { + if (typeof dir !== "string") return null; + const trimmed = dir.trim(); + if (!trimmed) return null; + return path_1.default.resolve(trimmed); +} +function getLegacyDotDataDir() { + return path_1.default.join(safeHomeDir(), `.${exports.APP_NAME}`); +} +function getDefaultDataDir() { + const homeDir = safeHomeDir(); + if (process.platform === "win32") { + const appData = process.env.APPDATA || path_1.default.join(homeDir, "AppData", "Roaming"); + return path_1.default.join(appData, exports.APP_NAME); + } + // Support XDG on Linux/macOS when explicitly configured. + const xdgConfigHome = normalizeConfiguredPath(process.env.XDG_CONFIG_HOME); + if (xdgConfigHome) { + return path_1.default.join(xdgConfigHome, exports.APP_NAME); + } + return getLegacyDotDataDir(); +} +function resolveDataDir({ isCloud = false } = {}) { + if (isCloud) return "/tmp"; + const configured = normalizeConfiguredPath(process.env.DATA_DIR); + if (configured) return configured; + return getDefaultDataDir(); +} +function isSamePath(a, b) { + if (!a || !b) return false; + const normalizedA = path_1.default.resolve(a); + const normalizedB = path_1.default.resolve(b); + if (process.platform === "win32") { + return normalizedA.toLowerCase() === normalizedB.toLowerCase(); + } + return normalizedA === normalizedB; +} diff --git a/tests/scratch_test.mjs b/tests/scratch_test.mjs new file mode 100644 index 0000000000..a2cccf40c4 --- /dev/null +++ b/tests/scratch_test.mjs @@ -0,0 +1,4 @@ +import { getDbInstance } from "../src/lib/db/core.ts"; +import { runMigrations, getMigrationStatus } from "../src/lib/db/migrationRunner.ts"; +const db = getDbInstance(); +console.log(getMigrationStatus(db).pending); diff --git a/tests/unit/prompt-injection-guard.test.ts b/tests/unit/prompt-injection-guard.test.ts index ea3b41bcad..b0975950f5 100644 --- a/tests/unit/prompt-injection-guard.test.ts +++ b/tests/unit/prompt-injection-guard.test.ts @@ -6,8 +6,8 @@ import { withInjectionGuard, } from "../../src/middleware/promptInjectionGuard.ts"; -async function withEnv(overrides, fn) { - const originals = {}; +async function withEnv(overrides: Record, fn: any) { + const originals: Record = {}; for (const [key, value] of Object.entries(overrides)) { originals[key] = process.env[key]; From a7b6dfb06e162fea1a37ddb04886df087fd36ee3 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 21 Apr 2026 21:12:29 -0300 Subject: [PATCH 039/281] chore: apply PR 1495 and update changelog --- CHANGELOG.md | 52 ++- electron/package.json | 3 +- open-sse/executors/codex.ts | 14 +- open-sse/services/batchProcessor.ts | 252 +++++++------ open-sse/services/combo.ts | 2 +- .../cli-tools/antigravity-mitm/alias/route.ts | 7 + .../api/cli-tools/antigravity-mitm/route.ts | 12 +- src/app/api/cli-tools/backups/route.ts | 10 + .../api/cli-tools/claude-settings/route.ts | 14 +- src/app/api/cli-tools/cline-settings/route.ts | 14 +- src/app/api/cli-tools/codex-profiles/route.ts | 15 +- src/app/api/cli-tools/codex-settings/route.ts | 14 +- src/app/api/cli-tools/droid-settings/route.ts | 14 +- .../guide-settings/[toolId]/route.ts | 4 + src/app/api/cli-tools/kilo-settings/route.ts | 14 +- .../api/cli-tools/openclaw-settings/route.ts | 14 +- .../cli-tools/openclaw/auto-order/route.ts | 6 +- src/app/api/cli-tools/qwen-settings/route.ts | 14 +- .../api/cli-tools/runtime/[toolId]/route.ts | 6 +- src/app/api/cli-tools/status/route.ts | 6 +- src/app/api/v1/_helpers/apiKeyScope.ts | 50 +++ src/app/api/v1/batches/[id]/cancel/route.ts | 114 +++--- src/app/api/v1/batches/[id]/route.ts | 84 ++--- src/app/api/v1/batches/route.ts | 139 ++++---- src/app/api/v1/files/[id]/content/route.ts | 12 +- src/app/api/v1/files/[id]/route.ts | 31 +- src/app/api/v1/files/route.ts | 29 +- src/app/globals.css | 2 + src/lib/api/requireCliToolsAuth.ts | 5 + src/lib/batches/dispatch.ts | 49 +++ src/lib/db/batches.ts | 97 +++-- src/shared/constants/batchEndpoints.ts | 11 + src/shared/services/cliRuntime.ts | 1 + src/shared/validation/schemas.ts | 12 +- src/sse/handlers/chat.ts | 2 + src/sse/handlers/chatHelpers.ts | 30 +- tests/e2e/api.spec.ts | 6 +- tests/e2e/ecosystem.test.ts | 28 +- tests/e2e/helpers/dashboardAuth.ts | 4 +- tests/e2e/protocol-clients.test.ts | 6 +- tests/integration/api-keys.test.ts | 44 +-- tests/integration/api-routes-critical.test.ts | 68 ++-- tests/integration/chat-pipeline.test.ts | 36 +- tests/integration/combo-routing-e2e.test.ts | 14 +- tests/integration/memory-pipeline.test.ts | 2 +- .../performance-regression.test.ts | 2 +- tests/integration/proxy-registry-flow.test.ts | 12 +- tests/integration/resilience-http-e2e.test.ts | 6 +- tests/integration/skills-pipeline.test.ts | 27 +- .../integration/v1-contracts-behavior.test.ts | 16 +- tests/translator/testFromFile.ts | 2 +- tests/unit/acp-agents-route.test.ts | 4 +- tests/unit/admin-audit-events.test.ts | 16 +- tests/unit/api-key-policy.test.ts | 4 +- tests/unit/api-key-reveal-route.test.ts | 14 +- tests/unit/audio-speech-handler.test.ts | 14 +- .../unit/audio-transcription-handler.test.ts | 18 +- tests/unit/auth-clear-account-error.test.ts | 10 +- tests/unit/auth-login-route.test.ts | 5 +- tests/unit/auth-terminal-status.test.ts | 30 +- tests/unit/batch_api.test.ts | 330 +++++++++++++++--- tests/unit/build-next-isolated.test.ts | 2 +- tests/unit/bypass-handler.test.ts | 4 +- tests/unit/call-log-cap.test.ts | 65 ++-- tests/unit/call-log-file-rotation.test.ts | 8 +- tests/unit/call-log-startup.test.ts | 2 +- .../unit/cc-compatible-model-catalog.test.ts | 4 +- tests/unit/cc-compatible-provider.test.ts | 62 ++-- tests/unit/chat-combo-live-test.test.ts | 8 +- tests/unit/chat-context-relay.test.ts | 6 +- tests/unit/chat-cooldown-aware-retry.test.ts | 20 +- tests/unit/chat-helpers.test.ts | 21 +- tests/unit/chat-rate-limit-body-lock.test.ts | 4 +- tests/unit/chat-route-coverage.test.ts | 28 +- tests/unit/chat-route-edge-cases.test.ts | 8 +- .../chatcore-compression-integration.test.ts | 4 +- tests/unit/chatcore-translation-paths.test.ts | 47 +-- .../claude-code-compatible-helpers.test.ts | 4 +- .../claude-code-compatible-request.test.ts | 22 +- tests/unit/cli-runtime-extended.test.ts | 2 +- tests/unit/cli-tools-auth-hardening.test.ts | 76 ++++ tests/unit/cloud-sync.test.ts | 4 +- tests/unit/cloudflaredTunnel-extended.test.ts | 6 +- tests/unit/codex-connection-defaults.test.ts | 28 +- tests/unit/codex-stream-false.test.ts | 6 +- .../unit/combo-builder-options-route.test.ts | 4 +- tests/unit/combo-health-route.test.ts | 4 +- tests/unit/combo-provider-cooldown.test.ts | 4 +- .../unit/combo-routes-composite-tiers.test.ts | 14 +- tests/unit/combo-routing-engine.test.ts | 26 +- tests/unit/combo-strategies.test.ts | 8 +- tests/unit/combo-test-route.test.ts | 20 +- tests/unit/compliance-audit-route.test.ts | 2 +- tests/unit/compliance-index.test.ts | 15 +- tests/unit/console-log-levels.test.ts | 6 +- tests/unit/context-manager.test.ts | 8 +- tests/unit/context-pinning-tool-calls.test.ts | 8 +- tests/unit/db-apikeys-crud.test.ts | 2 +- tests/unit/db-backup-extended.test.ts | 2 +- tests/unit/db-combos-crud.test.ts | 18 +- tests/unit/db-core-migration.test.ts | 8 +- tests/unit/db-detailed-logs.test.ts | 8 +- tests/unit/db-health-check.test.ts | 117 +++++-- tests/unit/db-health-route.test.ts | 14 +- tests/unit/db-models-crud.test.ts | 4 +- tests/unit/db-providers-crud.test.ts | 26 +- tests/unit/db-proxies-crud.test.ts | 16 +- tests/unit/db-read-cache.test.ts | 2 +- tests/unit/db-secrets.test.ts | 2 +- tests/unit/db-settings-crud.test.ts | 22 +- tests/unit/display-and-error-utils.test.ts | 8 +- tests/unit/domain-branch-hardening.test.ts | 2 +- tests/unit/domain-cost-rules.test.ts | 2 +- tests/unit/domain-fallback-policy.test.ts | 2 +- tests/unit/domain-lockout-policy.test.ts | 2 +- tests/unit/empty-tool-name-loop.test.ts | 16 +- tests/unit/executor-antigravity.test.ts | 8 +- tests/unit/executor-cursor-extended.test.ts | 18 +- tests/unit/executor-default-base.test.ts | 18 +- tests/unit/executor-kiro.test.ts | 5 +- tests/unit/fetch-timeout.test.ts | 10 +- tests/unit/fixes-p1.test.ts | 38 +- .../glm-provider-model-import-route.test.ts | 2 +- tests/unit/grok-web.test.ts | 8 +- tests/unit/guide-settings-route.test.ts | 2 +- tests/unit/image-generation-route.test.ts | 12 +- tests/unit/log-retention.test.ts | 20 +- tests/unit/login-bootstrap-route.test.ts | 18 +- tests/unit/management-password.test.ts | 10 +- tests/unit/memory-route.test.ts | 4 +- tests/unit/memory-store.test.ts | 2 +- tests/unit/memory-summarization.test.ts | 4 +- .../unit/messages-count-tokens-route.test.ts | 6 +- tests/unit/model-alias-route.test.ts | 12 +- tests/unit/model-sync-route.test.ts | 22 +- tests/unit/model-sync-scheduler.test.ts | 2 +- tests/unit/models-catalog-route.test.ts | 122 +++---- tests/unit/modelsDevSync-extended.test.ts | 4 +- tests/unit/moderations-handler.test.ts | 8 +- .../unit/nanobanana-image-generation.test.ts | 4 +- tests/unit/observability-fase04.test.ts | 2 +- tests/unit/openapi-spec-route.test.ts | 2 +- tests/unit/orphaned-tool-filter.test.ts | 8 +- tests/unit/payload-rules-route.test.ts | 14 +- tests/unit/payload-rules.test.ts | 4 +- tests/unit/perplexity-web.test.ts | 16 +- tests/unit/plan3-p0.test.ts | 21 +- tests/unit/prompt-injection-guard.test.ts | 4 +- tests/unit/prompt-required-routes.test.ts | 4 +- .../provider-models-management-route.test.ts | 8 +- tests/unit/provider-models-route.test.ts | 28 +- tests/unit/provider-nodes-route.test.ts | 14 +- .../providers-route-managed-catalog.test.ts | 2 +- tests/unit/providers-validate-route.test.ts | 8 +- tests/unit/proxy-fetch.test.ts | 2 +- tests/unit/proxy-login-bootstrap-auth.test.ts | 4 +- tests/unit/proxy-management-v1-route.test.ts | 44 +-- tests/unit/proxy-registry.test.ts | 14 +- tests/unit/qoder-executor.test.ts | 6 +- tests/unit/remaining-tasks.test.ts | 2 +- tests/unit/response-sanitizer.test.ts | 62 ++-- tests/unit/responses-handler.test.ts | 2 +- .../unit/responses-translation-fixes.test.ts | 46 +-- tests/unit/route-edge-coverage.test.ts | 102 +++--- tests/unit/safe-outbound-fetch.test.ts | 14 +- tests/unit/schema-coercion.test.ts | 18 +- tests/unit/search-route.test.ts | 12 +- tests/unit/services-branch-hardening.test.ts | 2 +- tests/unit/settings-api.test.ts | 2 +- tests/unit/settings-route-password.test.ts | 7 +- tests/unit/shared-api-utils.test.ts | 9 +- tests/unit/signature-cache.test.ts | 2 +- tests/unit/skills-routes.test.ts | 18 +- tests/unit/skills-skillssh.test.ts | 14 +- tests/unit/spend-batch-writer.test.ts | 2 +- tests/unit/sse-auth.test.ts | 121 ++++--- tests/unit/sse-parser.test.ts | 12 +- tests/unit/stream-utils.test.ts | 28 +- tests/unit/sync-bundle.test.ts | 4 +- tests/unit/sync-routes.test.ts | 10 +- tests/unit/t14-proxy-fast-fail.test.ts | 2 +- .../t19-codex-responses-empty-content.test.ts | 6 +- .../unit/t23-t24-fallback-resilience.test.ts | 2 +- tests/unit/tag-routing.test.ts | 2 +- tests/unit/telemetry-summary-route.test.ts | 2 +- tests/unit/token-health-check.test.ts | 14 +- .../unit/token-refresh-route-service.test.ts | 12 +- tests/unit/tool-request-sanitization.test.ts | 16 +- .../translator-antigravity-to-openai.test.ts | 2 +- .../unit/translator-claude-to-openai.test.ts | 2 +- tests/unit/translator-helper-branches.test.ts | 44 +-- .../translator-openai-responses-req.test.ts | 95 ++--- tests/unit/translator-openai-to-kiro.test.ts | 12 +- .../translator-resp-claude-to-openai.test.ts | 28 +- .../translator-resp-gemini-to-openai.test.ts | 61 ++-- .../translator-resp-openai-responses.test.ts | 8 +- .../translator-resp-openai-to-claude.test.ts | 22 +- tests/unit/usage-analytics.test.ts | 21 +- tests/unit/usage-migrations.test.ts | 37 +- tests/unit/v1-ws-bridge.test.ts | 4 +- tests/unit/v1-ws-route.test.ts | 8 +- tests/unit/version-manager.test.ts | 2 +- .../unit/versionManager-orchestrator.test.ts | 4 +- tests/unit/web-runtime-env.test.ts | 2 +- 204 files changed, 2515 insertions(+), 1652 deletions(-) create mode 100644 src/app/api/v1/_helpers/apiKeyScope.ts create mode 100644 src/lib/api/requireCliToolsAuth.ts create mode 100644 src/lib/batches/dispatch.ts create mode 100644 src/shared/constants/batchEndpoints.ts create mode 100644 tests/unit/cli-tools-auth-hardening.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b30c0ee479..27d7faadda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,17 +8,61 @@ ### ✨ New Features -- **feat(providers):** Add ModelScope provider (Chinese AI marketplace) with Kimi K2.5, GLM-5, and Step-3.5-Flash integration. (#1430) +- **feat(providers):** Add ModelScope provider (Chinese AI marketplace) with Kimi K2.5, GLM-5, and Step-3.5-Flash integration. (#1430 — thanks @clousky2020) +- **feat(providers):** Add LM Studio as an OpenAI-compatible local provider for self-hosted model inference. +- **feat(providers):** Add Grok 4.3 thinking model support for xAI web executor requests. - **feat(core):** Implement provider-level Circuit Breaker to prevent cascading failures across connections, enforcing a 10-minute cooldown after 5 consecutive transient failures. (#1430) - **feat(core):** Add daily quota exhaustion lock to detect "quota exceeded" signals and lock the specific model until midnight. (#1430) - **feat(core):** Auto-inject `stream_options.include_usage = true` for OpenAI format streams to guarantee token usage is reported correctly during streaming. (#1423) +- **feat(core):** Add OpenAI Batch Processing API support — submit, monitor, and manage batch jobs through the proxy with full lifecycle tracking. +- **feat(vision-bridge):** Add automatic image description fallback for non-vision models via `VisionBridgeGuardrail` (priority 5). Intercepts image-bearing requests to non-vision models, extracts descriptions via a configurable vision model (default: gpt-4o-mini), and replaces images with text before forwarding. Fails open on any error. (#1476) - **feat(dashboard):** Introduce real-time model status badges with countdown timers in the provider detail and combo panel interfaces. (#1430) -- **feat(dashboard):** Add Batch/File management data grid to the Dashboard. (#1479) +- **feat(dashboard):** Add Batch/File management data grid with full i18n translations for batch processing workflows. (#1479) ### 🐛 Bug Fixes -- **fix(dashboard):** Correct TOML round-trip corruption in Codex config serializer by dequoting keys and preserving array/boolean structures properly. (#1438) -- **fix(security):** Resolve CodeQL alert 164 (ReDoS in extraction) and 163 (incomplete URL sanitization) (#163, #164) +- **fix(batch):** Add batch item dispatching to specific handlers based on URL to support embeddings and other modalities (#1495 — thanks @hartmark) +- **fix(dashboard):** Correct TOML round-trip corruption in Codex config serializer by dequoting keys and preserving array/boolean structures properly. (#1438 — thanks @benzntech) +- **fix(security):** Resolve CodeQL alert 164 (ReDoS in extraction) and 163 (incomplete URL sanitization). (#163, #164) +- **fix(providers):** Add optional chaining to connection object before accessing `providerSpecificData`, preventing runtime errors when the connection is null/undefined. +- **fix(codex):** Preserve namespace MCP tools forwarded to Codex Responses API, preventing tool name stripping during translation. (#1483) +- **fix(codex):** Deduplicate case-variant `anthropic-version` header in Claude Code patch to prevent duplicate header injection. (#1481) +- **fix(fallback):** Use shared `CircuitBreaker` instead of undefined constants, fixing runtime errors in provider failure handling. (#1485) +- **fix(fallback):** Merge new provider failure threshold fields (`providerFailureThreshold`, `providerFailureWindowMs`, `providerCooldownMs`) into resilience profiles. +- **fix(fallback):** Remove 429 from `PROVIDER_FAILURE_ERROR_CODES` — rate limits are already handled by model-level and account-level locks; including them in the provider-wide circuit breaker caused premature cooldown. +- **fix(sse):** Enable tool calling for GPT OSS and DeepSeek Reasoner models. (#1455) +- **fix(encryption):** Return null on decryption failure to prevent sending encrypted tokens to providers. (#1462) +- **fix(combo):** Resolve cross-provider thinking 400 errors and HTTP clipboard issues during combo routing. (#1444) +- **fix(core):** Resolve skills, memory, and encryption system issues affecting startup and runtime stability. (#1456) +- **fix(core):** Fix model ID parsing for providers with slashes in model names — use `indexOf`/`substring` instead of `split` to handle models like `modelscope/moonshotai/Kimi-K2.5`. +- **fix(core):** Fix reference counting in `ModelStatusContext` — changed `registeredModels` from `Set` to `Map` to prevent polling stop when one component unmounts while others still track the same model. +- **fix(security):** Prompt injection guard failures now return an explicit 500 response instead of silently passing through (fail-closed policy). +- **fix(security):** Encryption now derives new keys from a secret-based salt while falling back to the legacy static-salt key during decryption, preserving existing stored credentials. + +### ♻️ Refactoring + +- **refactor(fallback):** Make provider failure thresholds configurable via `PROVIDER_PROFILES` instead of hardcoded constants, supporting different failure tolerance per provider type. (#1449) +- **refactor(resilience):** Unify resilience controls across the codebase for consistent circuit breaker and fallback behavior. (#1449) +- **refactor(core):** Implement shared path utilities, add custom date formatting, improve type safety, and unify database imports across modules. +- **refactor(security):** Harden backup archive creation by switching to `execFileSync`, validate ACP agent IDs, expand shared CORS handling. + +### 🧪 Tests + +- **test(vision-bridge):** Add 51 unit tests covering all VisionBridge spec scenarios (VB-S01 through VB-S10), including helper functions for `callVisionModel`, `extractImageParts`, `replaceImageParts`, and `resolveImageAsDataUri`. +- **test(batch-api):** Isolate batch API unit tests with temp `DATA_DIR` to prevent schema state collisions. +- **test(settings-api):** Add test harness with `createSettingsApiHarness` function for proper temp directory setup and storage reset between tests. +- **test(security):** Update prompt injection test for fail-closed policy alignment. +- **test(core):** Restore local test fixes for encryption and resilience modules. + +### 📚 Documentation + +- **docs:** Add Arch Linux AUR install notes for community package support. (#1478) +- **docs(i18n):** Improve Ukrainian (uk-UA) translation quality — full Ukrainian translation for README, SECURITY, A2A-SERVER, API_REFERENCE, AUTO-COMBO, and USER_GUIDE documents. Fix mixed Latin/Cyrillic typos, translate model table entries, and standardize section headers. + +### 📦 Dependencies + +- **deps:** Bump the development group with 4 updates. (#1464) +- **deps:** Bump the production group with 4 updates. (#1463) --- diff --git a/electron/package.json b/electron/package.json index f4da5e1509..58f200b0f2 100644 --- a/electron/package.json +++ b/electron/package.json @@ -45,7 +45,8 @@ "files": [ "main.js", "preload.js", - "package.json" + "package.json", + "node_modules/**/*" ], "extraResources": [ { diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 294c4e6ba5..dc9abdcd55 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -339,12 +339,14 @@ function normalizeCodexTools(body: Record): void { // Preserve namespace tools (MCP tool groups used by Codex/OpenAI Responses API). // Codex API supports them natively; register sub-tool names for tool_choice validation. - if (tool.type === "namespace" && Array.isArray(tool.tools)) { - for (const st of tool.tools as unknown[]) { - if (st && typeof st === "object" && !Array.isArray(st)) { - const subTool = st as Record; - const name = typeof subTool.name === "string" ? subTool.name.trim() : ""; - if (name) validToolNames.add(name); + if (tool.type === "namespace") { + if (Array.isArray(tool.tools)) { + for (const st of tool.tools as unknown[]) { + if (st && typeof st === "object" && !Array.isArray(st)) { + const subTool = st as Record; + const name = typeof subTool.name === "string" ? subTool.name.trim() : ""; + if (name) validToolNames.add(name); + } } } return true; diff --git a/open-sse/services/batchProcessor.ts b/open-sse/services/batchProcessor.ts index 7452b917c6..57ed33ee08 100644 --- a/open-sse/services/batchProcessor.ts +++ b/open-sse/services/batchProcessor.ts @@ -1,24 +1,31 @@ import { v4 as uuidv4 } from "uuid"; import { - createFile, - deleteFile, - getApiKeyById, - getBatch, - getFileContent, getPendingBatches, getTerminalBatches, - listFiles, updateBatch, + getFileContent, + createFile, + getApiKeyById, + getBatch, + listFiles, + deleteFile, updateFileStatus, } from "@/lib/localDb"; -import { handleChat } from "@/sse/handlers/chat"; -import { POST as handleEmbeddingsRoute } from "@/app/api/v1/embeddings/route"; -import { POST as handleModerationsRoute } from "@/app/api/v1/moderations/route"; -import { POST as handleImagesGenerationsRoute } from "@/app/api/v1/images/generations/route"; -import { POST as handleVideosGenerationsRoute } from "@/app/api/v1/videos/generations/route"; +import type { BatchRecord } from "@/lib/localDb"; +import { dispatchBatchApiRequest } from "@/lib/batches/dispatch"; +import type { SupportedBatchEndpoint } from "@/shared/constants/batchEndpoints"; let isProcessing = false; let pollInterval: NodeJS.Timeout | null = null; +const DEFAULT_BATCH_WINDOW_SECONDS = 24 * 60 * 60; + +interface BatchRequestItem { + body: Record; + customId: string | null; + lineNumber: number; + method: "POST"; + url: SupportedBatchEndpoint; +} export function initBatchProcessor() { if (pollInterval) return pollInterval; @@ -91,39 +98,125 @@ export async function processPendingBatches() { await cleanupExpiredBatches(); } +function parseBatchWindowSeconds(window: string | null | undefined): number { + if (!window) return DEFAULT_BATCH_WINDOW_SECONDS; + const match = /^(\d+)([hdm])$/.exec(window); + if (!match) return DEFAULT_BATCH_WINDOW_SECONDS; + + const value = Number.parseInt(match[1], 10); + const unit = match[2]; + if (unit === "h") return value * 3600; + if (unit === "d") return value * 86400; + if (unit === "m") return value * 60; + return DEFAULT_BATCH_WINDOW_SECONDS; +} + +function getBatchOutputExpiresAt(batch: BatchRecord): number | null { + if ( + batch.outputExpiresAfterAnchor === "created_at" && + typeof batch.outputExpiresAfterSeconds === "number" && + batch.outputExpiresAfterSeconds > 0 + ) { + return batch.createdAt + batch.outputExpiresAfterSeconds; + } + + const completionTime = + batch.completedAt || batch.failedAt || batch.cancelledAt || batch.expiredAt; + if (!completionTime) return null; + return completionTime + parseBatchWindowSeconds(batch.completionWindow); +} + +function resolveBatchApiKeyValue(batch: Pick, apiKeyRow: any) { + if (typeof apiKeyRow?.key === "string" && apiKeyRow.key.length > 0) { + return apiKeyRow.key; + } + if (batch.apiKeyId === "env-key") { + return process.env.OMNIROUTE_API_KEY || process.env.ROUTER_API_KEY || null; + } + return null; +} + +function parseBatchItems( + content: Buffer, + batchEndpoint: SupportedBatchEndpoint +): { items: BatchRequestItem[]; error: null } | { items: null; error: string } { + const lines = content + .toString() + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + + const items: BatchRequestItem[] = []; + for (const [index, line] of lines.entries()) { + let parsed: Record; + try { + parsed = JSON.parse(line); + } catch { + return { items: null, error: `Line ${index + 1} is not valid JSON` }; + } + + const method = String(parsed.method || "POST").toUpperCase(); + const url = parsed.url; + const body = parsed.body; + + if (method !== "POST") { + return { + items: null, + error: `Line ${index + 1} uses unsupported method ${method}; only POST is supported`, + }; + } + if (url !== batchEndpoint) { + return { + items: null, + error: `Line ${index + 1} url ${String(url)} does not match batch endpoint ${batchEndpoint}`, + }; + } + if (!body || typeof body !== "object" || Array.isArray(body)) { + return { items: null, error: `Line ${index + 1} must include a JSON object body` }; + } + + items.push({ + body: body as Record, + customId: typeof parsed.custom_id === "string" ? parsed.custom_id : null, + lineNumber: index + 1, + method: "POST", + url: batchEndpoint, + }); + } + + return { items, error: null }; +} + async function cleanupExpiredBatches() { try { const now = Math.floor(Date.now() / 1000); const batches = getTerminalBatches(); - const parseWindow = (window: string): number => { - if (!window) return 86400; - const match = new RegExp(/^(\d+)([hdm])$/).exec(window); - if (!match) return 86400; - const val = Number.parseInt(match[1]); - const unit = match[2]; - if (unit === "h") return val * 3600; - if (unit === "d") return val * 86400; - if (unit === "m") return val * 60; - return 86400; - }; - // Delete files for terminal batches that have exceeded their completion window for (const batch of batches) { - const windowSeconds = parseWindow(batch.completionWindow); const completionTime = batch.completedAt || batch.failedAt || batch.cancelledAt || batch.expiredAt; - if (completionTime && now - completionTime > windowSeconds) { - if (batch.inputFileId) deleteFile(batch.inputFileId); - if (batch.outputFileId) deleteFile(batch.outputFileId); - if (batch.errorFileId) deleteFile(batch.errorFileId); + const inputExpiresAt = + completionTime && batch.inputFileId + ? completionTime + parseBatchWindowSeconds(batch.completionWindow) + : null; + const outputExpiresAt = getBatchOutputExpiresAt(batch); + + if (batch.inputFileId && inputExpiresAt && now > inputExpiresAt) { + deleteFile(batch.inputFileId); + } + if (batch.outputFileId && outputExpiresAt && now > outputExpiresAt) { + deleteFile(batch.outputFileId); + } + if (batch.errorFileId && outputExpiresAt && now > outputExpiresAt) { + deleteFile(batch.errorFileId); } } // Expire validating batches that have exceeded their completion window for (const batch of getPendingBatches()) { if (batch.status === "validating") { - const windowSeconds = parseWindow(batch.completionWindow); + const windowSeconds = parseBatchWindowSeconds(batch.completionWindow); if (now - batch.createdAt > windowSeconds) { updateBatch(batch.id, { status: "expired", expiredAt: now }); } @@ -157,8 +250,13 @@ async function startBatch(batch: any) { } try { - const lines = content.toString().split("\n").filter((l) => l.trim()); - const total = lines.length; + const parsedItems = parseBatchItems(content, batch.endpoint); + if (parsedItems.error) { + updateFileStatus(batch.inputFileId, "processed"); + failBatch(batch.id, parsedItems.error); + return; + } + const total = parsedItems.items.length; updateFileStatus(batch.inputFileId, "validating"); updateBatch(batch.id, { @@ -169,7 +267,7 @@ async function startBatch(batch: any) { // Fire-and-forget: process items in the background so the poll loop isn't blocked. // isProcessing prevents a second poll tick from overlapping. - processBatchItems(batch, lines).catch((err) => { + processBatchItems(batch, parsedItems.items).catch((err) => { console.error(`[BATCH] Critical error in processBatchItems for ${batch.id}:`, err); failBatch(batch.id, String(err)); }); @@ -179,50 +277,7 @@ async function startBatch(batch: any) { } } -/** - * Dispatch a single batch item to the correct handler based on the URL. - * Each batch item may target a different API endpoint (chat, embeddings, etc.). - */ -async function dispatchBatchItem( - url: string, - headers: Headers, - body: Record -): Promise { - const request = new Request(`http://localhost${url}`, { - method: "POST", - headers, - body: JSON.stringify(body), - }); - - switch (url) { - case "/v1/embeddings": - return handleEmbeddingsRoute(request); - case "/v1/moderations": - return handleModerationsRoute(request); - case "/v1/images/generations": - case "/v1/images/edits": - return handleImagesGenerationsRoute(request); - case "/v1/videos": - case "/v1/videos/generations": - return handleVideosGenerationsRoute(request); - // /v1/chat/completions, /v1/completions, /v1/responses - default: { - return handleChat( - new Request(`http://localhost${url}`, { - method: "POST", - headers, - body: JSON.stringify({ - ...body, - // BATCH-SPECIFIC: Force stream: false — batches don't support SSE responses - stream: body.stream ?? false, - }), - }) - ); - } - } -} - -async function processBatchItems(batch: any, lines: string[]) { +async function processBatchItems(batch: BatchRecord, items: BatchRequestItem[]) { const results: any[] = []; const errors: any[] = []; let completedCount = 0; @@ -233,8 +288,9 @@ async function processBatchItems(batch: any, lines: string[]) { let usedModel = batch.model || null; const apiKeyRow = batch.apiKeyId ? await getApiKeyById(batch.apiKeyId) : null; + const apiKeyValue = resolveBatchApiKeyValue(batch, apiKeyRow); - for (const line of lines) { + for (const item of items) { // Check if cancelled mid-process const current = getBatch(batch.id); if (!current || current.status === "cancelling" || current.status === "cancelled") { @@ -242,16 +298,13 @@ async function processBatchItems(batch: any, lines: string[]) { } try { - const item = JSON.parse(line); - const { custom_id: customId, url, body } = item; - - const headers = new Headers(); - if (apiKeyRow?.key) { - headers.set("Authorization", `Bearer ${apiKeyRow.key}`); - } - headers.set("Content-Type", "application/json"); - - const response = await dispatchBatchItem(url, headers, body); + // BATCH-SPECIFIC: Force stream: false — batches don't support SSE responses + const batchItemBody = { ...item.body, stream: false }; + const response = await dispatchBatchApiRequest({ + endpoint: item.url, + body: batchItemBody, + apiKey: apiKeyValue, + }); let responseData: { error: any; id?: any; usage?: any; model?: any }; let statusCode = 200; @@ -266,7 +319,9 @@ async function processBatchItems(batch: any, lines: string[]) { try { responseData = JSON.parse(text); } catch { - responseData = { error: { message: text || "Unknown error", type: "invalid_response" } }; + responseData = { + error: { message: text || "Unknown error", type: "invalid_response" }, + }; } } } else { @@ -278,7 +333,7 @@ async function processBatchItems(batch: any, lines: string[]) { results.push({ id: requestId, - custom_id: customId, + custom_id: item.customId, response: { status_code: statusCode, request_id: responseData?.id || "req_unknown", @@ -303,14 +358,8 @@ async function processBatchItems(batch: any, lines: string[]) { } } catch (err) { console.error(`[BATCH] Item failed in ${batch.id}:`, err); - let customId = "unknown"; - try { - customId = JSON.parse(line).custom_id || "unknown"; - } catch { - // line was malformed JSON - } errors.push({ - custom_id: customId, + custom_id: item.customId || `line-${item.lineNumber}`, error: err instanceof Error ? err.message : String(err), }); failedCount++; @@ -365,9 +414,8 @@ async function finalizeBatch(batchId: string, results: any[], itemsWithErrors: a } let outputFileId: string | null = null; - const successes = results.filter( - (r) => r.response.status_code < 400 && !r.response.body?.error - ); + const outputExpiresAt = current ? getBatchOutputExpiresAt(current) : null; + const successes = results.filter((r) => r.response.status_code < 400 && !r.response.body?.error); if (successes.length > 0) { const outputContent = successes.map((r) => JSON.stringify(r)).join("\n"); const file = createFile({ @@ -377,14 +425,13 @@ async function finalizeBatch(batchId: string, results: any[], itemsWithErrors: a content: Buffer.from(outputContent), apiKeyId: current?.apiKeyId, status: "completed", + expiresAt: outputExpiresAt, }); outputFileId = file.id; } let errorFileId: string | null = null; - const failures = results.filter( - (r) => r.response.status_code >= 400 || r.response.body?.error - ); + const failures = results.filter((r) => r.response.status_code >= 400 || r.response.body?.error); const allFailures = [ ...failures, ...itemsWithErrors.map((e) => ({ @@ -404,6 +451,7 @@ async function finalizeBatch(batchId: string, results: any[], itemsWithErrors: a content: Buffer.from(errorContent), apiKeyId: current?.apiKeyId, status: "completed", + expiresAt: outputExpiresAt, }); errorFileId = file.id; } diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index ca08f3a17d..33e845b421 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -1982,7 +1982,7 @@ async function handleRoundRobinCombo({ } } - if (isProviderBreakerOpenResponse(result, errorBody)) { + if (isProviderBreakerOpenResponse(result, errorBody as any)) { lastError = errorText || String(result.status); if (!lastStatus) lastStatus = result.status; if (offset > 0) fallbackCount++; diff --git a/src/app/api/cli-tools/antigravity-mitm/alias/route.ts b/src/app/api/cli-tools/antigravity-mitm/alias/route.ts index 67e6babefd..9233a8cd62 100644 --- a/src/app/api/cli-tools/antigravity-mitm/alias/route.ts +++ b/src/app/api/cli-tools/antigravity-mitm/alias/route.ts @@ -1,12 +1,16 @@ "use server"; import { NextResponse } from "next/server"; +import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; import { getMitmAlias, setMitmAliasAll } from "@/models"; import { cliMitmAliasUpdateSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; // GET - Get MITM aliases for a tool export async function GET(request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + try { const { searchParams } = new URL(request.url); const toolName = searchParams.get("tool"); @@ -20,6 +24,9 @@ export async function GET(request) { // PUT - Save MITM aliases for a specific tool export async function PUT(request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + let rawBody; try { rawBody = await request.json(); diff --git a/src/app/api/cli-tools/antigravity-mitm/route.ts b/src/app/api/cli-tools/antigravity-mitm/route.ts index 48a523ff8f..6d4e0b442f 100644 --- a/src/app/api/cli-tools/antigravity-mitm/route.ts +++ b/src/app/api/cli-tools/antigravity-mitm/route.ts @@ -3,12 +3,16 @@ export const runtime = "nodejs"; import { NextResponse } from "next/server"; +import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; import { cliMitmStartSchema, cliMitmStopSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { resolveApiKey } from "@/shared/services/apiKeyResolver"; // GET - Check MITM status -export async function GET() { +export async function GET(request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + try { const { getMitmStatus, getCachedPassword } = await import("@/mitm/manager"); const status = await getMitmStatus(); @@ -27,6 +31,9 @@ export async function GET() { // POST - Start MITM proxy export async function POST(request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + let rawBody; try { rawBody = await request.json(); @@ -81,6 +88,9 @@ export async function POST(request) { // DELETE - Stop MITM proxy export async function DELETE(request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + let rawBody; try { rawBody = await request.json(); diff --git a/src/app/api/cli-tools/backups/route.ts b/src/app/api/cli-tools/backups/route.ts index beb3a7d6b5..39a55a9be7 100644 --- a/src/app/api/cli-tools/backups/route.ts +++ b/src/app/api/cli-tools/backups/route.ts @@ -1,6 +1,7 @@ "use server"; import { NextResponse } from "next/server"; +import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; import { listBackups, restoreBackup, deleteBackup } from "@/shared/services/backupService"; import { ensureCliConfigWriteAllowed } from "@/shared/services/cliRuntime"; import { cliBackupMutationSchema } from "@/shared/validation/schemas"; @@ -10,6 +11,9 @@ const VALID_TOOLS = ["claude", "codex", "droid", "openclaw", "cline", "kilo", "q // GET /api/cli-tools/backups?tool=claude — list backups export async function GET(request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + try { const { searchParams } = new URL(request.url); const tool = searchParams.get("tool") || searchParams.get("toolId"); @@ -37,6 +41,9 @@ export async function GET(request) { // POST /api/cli-tools/backups { tool, backupId } — restore a backup export async function POST(request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + let rawBody; try { rawBody = await request.json(); @@ -86,6 +93,9 @@ export async function POST(request) { // DELETE /api/cli-tools/backups { tool, backupId } — delete a backup export async function DELETE(request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + let rawBody; try { rawBody = await request.json(); diff --git a/src/app/api/cli-tools/claude-settings/route.ts b/src/app/api/cli-tools/claude-settings/route.ts index 63205c9e2a..45d37c45ae 100644 --- a/src/app/api/cli-tools/claude-settings/route.ts +++ b/src/app/api/cli-tools/claude-settings/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server"; import fs from "fs/promises"; import path from "path"; +import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; import { ensureCliConfigWriteAllowed, getCliPrimaryConfigPath, @@ -32,7 +33,10 @@ const readSettings = async () => { }; // GET - Check claude CLI and read current settings -export async function GET() { +export async function GET(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + try { const runtime = await getCliRuntimeStatus("claude"); @@ -74,6 +78,9 @@ export async function GET() { // POST - Backup old fields and write new settings export async function POST(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + let rawBody; try { rawBody = await request.json(); @@ -186,7 +193,10 @@ const RESET_ENV_KEYS = [ ]; // DELETE - Reset settings (remove env fields) -export async function DELETE() { +export async function DELETE(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + try { const writeGuard = ensureCliConfigWriteAllowed(); if (writeGuard) { diff --git a/src/app/api/cli-tools/cline-settings/route.ts b/src/app/api/cli-tools/cline-settings/route.ts index dbe832800b..090dcaf920 100644 --- a/src/app/api/cli-tools/cline-settings/route.ts +++ b/src/app/api/cli-tools/cline-settings/route.ts @@ -4,6 +4,7 @@ import { NextResponse } from "next/server"; import fs from "fs/promises"; import path from "path"; import os from "os"; +import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; import { ensureCliConfigWriteAllowed, getCliRuntimeStatus } from "@/shared/services/cliRuntime"; import { createBackup } from "@/shared/services/backupService"; import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState"; @@ -52,7 +53,10 @@ const hasOmniRouteConfig = (globalState: any) => { }; // GET - Check cline CLI and read current settings -export async function GET() { +export async function GET(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + try { const runtime = await getCliRuntimeStatus("cline"); @@ -101,6 +105,9 @@ export async function GET() { // POST - Configure Cline to use OmniRoute as OpenAI-compatible provider export async function POST(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + let rawBody; try { rawBody = await request.json(); @@ -193,7 +200,10 @@ export async function POST(request: Request) { } // DELETE - Remove OmniRoute OpenAI-compatible provider config -export async function DELETE() { +export async function DELETE(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + try { const writeGuard = ensureCliConfigWriteAllowed(); if (writeGuard) { diff --git a/src/app/api/cli-tools/codex-profiles/route.ts b/src/app/api/cli-tools/codex-profiles/route.ts index feb790a444..65126fd282 100644 --- a/src/app/api/cli-tools/codex-profiles/route.ts +++ b/src/app/api/cli-tools/codex-profiles/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server"; import fs from "fs/promises"; import path from "path"; +import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; import { ensureCliConfigWriteAllowed, getCliConfigPaths } from "@/shared/services/cliRuntime"; import { resolveDataDir } from "@/lib/dataPaths"; import { codexProfileIdSchema, codexProfileNameSchema } from "@/shared/validation/schemas"; @@ -52,7 +53,10 @@ function extractAuthLabel(authJson) { } // GET - List all saved profiles -export async function GET() { +export async function GET(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + try { await ensureProfilesDir(); @@ -94,6 +98,9 @@ export async function GET() { // POST - Save current config as a named profile export async function POST(request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + let rawBody; try { rawBody = await request.json(); @@ -180,6 +187,9 @@ export async function POST(request) { // PUT - Activate a saved profile (restore its config + auth) export async function PUT(request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + let rawBody; try { rawBody = await request.json(); @@ -251,6 +261,9 @@ export async function PUT(request) { // DELETE - Remove a saved profile export async function DELETE(request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + let rawBody; try { rawBody = await request.json(); diff --git a/src/app/api/cli-tools/codex-settings/route.ts b/src/app/api/cli-tools/codex-settings/route.ts index 5cf1151a1d..f997aa5fb1 100644 --- a/src/app/api/cli-tools/codex-settings/route.ts +++ b/src/app/api/cli-tools/codex-settings/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server"; import fs from "fs/promises"; import path from "path"; +import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; import { ensureCliConfigWriteAllowed, getCliConfigPaths, @@ -120,7 +121,10 @@ const hasOmniRouteConfig = (config: string | null) => { }; // GET - Check codex CLI and read current settings -export async function GET() { +export async function GET(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + try { const runtime = await getCliRuntimeStatus("codex"); @@ -161,6 +165,9 @@ export async function GET() { // POST - Update OmniRoute settings (merge with existing config) export async function POST(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + let rawBody; try { rawBody = await request.json(); @@ -301,7 +308,10 @@ export async function POST(request: Request) { } // DELETE - Remove OmniRoute settings only (keep other settings) -export async function DELETE() { +export async function DELETE(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + try { const writeGuard = ensureCliConfigWriteAllowed(); if (writeGuard) { diff --git a/src/app/api/cli-tools/droid-settings/route.ts b/src/app/api/cli-tools/droid-settings/route.ts index 46a7d64212..c7e15f42a0 100644 --- a/src/app/api/cli-tools/droid-settings/route.ts +++ b/src/app/api/cli-tools/droid-settings/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server"; import fs from "fs/promises"; import path from "path"; +import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; import { ensureCliConfigWriteAllowed, getCliPrimaryConfigPath, @@ -36,7 +37,10 @@ const hasOmniRouteConfig = (settings: any) => { }; // GET - Check droid CLI and read current settings -export async function GET() { +export async function GET(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + try { const runtime = await getCliRuntimeStatus("droid"); @@ -77,6 +81,9 @@ export async function GET() { // POST - Update OmniRoute customModels (merge with existing settings) export async function POST(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + let rawBody; try { rawBody = await request.json(); @@ -174,7 +181,10 @@ export async function POST(request: Request) { } // DELETE - Remove OmniRoute customModels only (keep other settings) -export async function DELETE() { +export async function DELETE(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + try { const writeGuard = ensureCliConfigWriteAllowed(); if (writeGuard) { diff --git a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts index 1dd970bb60..d97d6344da 100644 --- a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts +++ b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import fs from "fs/promises"; import path from "path"; import os from "os"; +import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; import { getRuntimePorts } from "@/lib/runtime/ports"; import { getOpenCodeConfigPath } from "@/shared/services/cliRuntime"; import { mergeOpenCodeConfig } from "@/shared/services/opencodeConfig"; @@ -16,6 +17,9 @@ import { resolveApiKey } from "@/shared/services/apiKeyResolver"; * Currently supports: continue, opencode */ export async function POST(request, { params }) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + let rawBody; try { rawBody = await request.json(); diff --git a/src/app/api/cli-tools/kilo-settings/route.ts b/src/app/api/cli-tools/kilo-settings/route.ts index d14f4b17a5..aac82bebd7 100644 --- a/src/app/api/cli-tools/kilo-settings/route.ts +++ b/src/app/api/cli-tools/kilo-settings/route.ts @@ -4,6 +4,7 @@ import { NextResponse } from "next/server"; import fs from "fs/promises"; import path from "path"; import os from "os"; +import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; import { ensureCliConfigWriteAllowed, getCliRuntimeStatus } from "@/shared/services/cliRuntime"; import { createBackup } from "@/shared/services/backupService"; import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState"; @@ -38,7 +39,10 @@ const hasOmniRouteConfig = (auth) => { }; // GET - Check kilo CLI and read current settings -export async function GET() { +export async function GET(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + try { const runtime = await getCliRuntimeStatus("kilo"); @@ -109,6 +113,9 @@ export async function GET() { // POST - Configure Kilo Code to use OmniRoute as OpenAI-compatible provider export async function POST(request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + let rawBody; try { rawBody = await request.json(); @@ -217,7 +224,10 @@ export async function POST(request) { } // DELETE - Remove OmniRoute config from Kilo -export async function DELETE() { +export async function DELETE(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + try { const writeGuard = ensureCliConfigWriteAllowed(); if (writeGuard) { diff --git a/src/app/api/cli-tools/openclaw-settings/route.ts b/src/app/api/cli-tools/openclaw-settings/route.ts index e007951bb6..3b6e37d0d4 100644 --- a/src/app/api/cli-tools/openclaw-settings/route.ts +++ b/src/app/api/cli-tools/openclaw-settings/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server"; import fs from "fs/promises"; import path from "path"; +import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; import { ensureCliConfigWriteAllowed, getCliPrimaryConfigPath, @@ -36,7 +37,10 @@ const hasOmniRouteConfig = (settings: any) => { }; // GET - Check openclaw CLI and read current settings -export async function GET() { +export async function GET(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + try { const runtime = await getCliRuntimeStatus("openclaw"); @@ -77,6 +81,9 @@ export async function GET() { // POST - Update OmniRoute settings (merge with existing settings) export async function POST(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + let rawBody; try { rawBody = await request.json(); @@ -174,7 +181,10 @@ export async function POST(request: Request) { } // DELETE - Remove OmniRoute settings only (keep other settings) -export async function DELETE() { +export async function DELETE(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + try { const writeGuard = ensureCliConfigWriteAllowed(); if (writeGuard) { diff --git a/src/app/api/cli-tools/openclaw/auto-order/route.ts b/src/app/api/cli-tools/openclaw/auto-order/route.ts index 2cbd2cc7f9..9c9b9ae43b 100644 --- a/src/app/api/cli-tools/openclaw/auto-order/route.ts +++ b/src/app/api/cli-tools/openclaw/auto-order/route.ts @@ -5,12 +5,16 @@ */ import { NextResponse } from "next/server"; +import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; import { getComboModelProvider } from "@/lib/combos/steps"; import { resolveOmniRouteBaseUrl } from "@/shared/utils/resolveOmniRouteBaseUrl"; const OMNIROUTE_BASE_URL = resolveOmniRouteBaseUrl(); -export async function GET() { +export async function GET(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + try { // Fetch current health and combos to determine best provider ordering const [healthRes, combosRes] = await Promise.allSettled([ diff --git a/src/app/api/cli-tools/qwen-settings/route.ts b/src/app/api/cli-tools/qwen-settings/route.ts index 1e0797559d..11ca68b298 100644 --- a/src/app/api/cli-tools/qwen-settings/route.ts +++ b/src/app/api/cli-tools/qwen-settings/route.ts @@ -4,6 +4,7 @@ import { NextResponse } from "next/server"; import fs from "fs/promises"; import path from "path"; import os from "os"; +import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; import { ensureCliConfigWriteAllowed, getCliPrimaryConfigPath, @@ -64,7 +65,10 @@ const hasOmniRouteConfig = (settings: any) => { }; // GET - Check Qwen CLI and read current settings -export async function GET() { +export async function GET(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + try { const runtime = await getCliRuntimeStatus("qwen"); @@ -106,6 +110,9 @@ export async function GET() { // POST - Write OmniRoute config to settings.json + .env export async function POST(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + let rawBody; try { rawBody = await request.json(); @@ -272,7 +279,10 @@ export async function POST(request: Request) { } // DELETE - Remove OmniRoute config from settings.json and .env -export async function DELETE() { +export async function DELETE(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + try { const writeGuard = ensureCliConfigWriteAllowed(); if (writeGuard) { diff --git a/src/app/api/cli-tools/runtime/[toolId]/route.ts b/src/app/api/cli-tools/runtime/[toolId]/route.ts index 27c5eb7d28..b410bd75a1 100644 --- a/src/app/api/cli-tools/runtime/[toolId]/route.ts +++ b/src/app/api/cli-tools/runtime/[toolId]/route.ts @@ -1,13 +1,17 @@ "use server"; import { NextResponse } from "next/server"; +import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; import { CLI_TOOL_IDS, getCliPrimaryConfigPath, getCliRuntimeStatus, } from "@/shared/services/cliRuntime"; -export async function GET(_request, { params }) { +export async function GET(request, { params }) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + try { const { toolId } = await params; const normalizedToolId = String(toolId || "") diff --git a/src/app/api/cli-tools/status/route.ts b/src/app/api/cli-tools/status/route.ts index cc7c5aff0e..7bf7ef83e6 100644 --- a/src/app/api/cli-tools/status/route.ts +++ b/src/app/api/cli-tools/status/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import fs from "fs/promises"; +import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; import { getCliRuntimeStatus, CLI_TOOL_IDS, @@ -99,7 +100,10 @@ async function checkToolConfigStatus(toolId: string): Promise { * Returns runtime + config status for all CLI tools in one batch call. * Used by the CLI Tools page to show status badges in collapsed state. */ -export async function GET() { +export async function GET(request: Request) { + const authError = await requireCliToolsAuth(request); + if (authError) return authError; + try { const statuses = {}; diff --git a/src/app/api/v1/_helpers/apiKeyScope.ts b/src/app/api/v1/_helpers/apiKeyScope.ts new file mode 100644 index 0000000000..07c7afd8ac --- /dev/null +++ b/src/app/api/v1/_helpers/apiKeyScope.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; + +import { getApiKeyMetadata } from "@/lib/localDb"; +import { CORS_HEADERS } from "@/shared/utils/cors"; +import { extractApiKey, isValidApiKey } from "@/sse/services/auth"; + +export interface ApiKeyRequestScope { + apiKey: string | null; + apiKeyId: string | null; + apiKeyMetadata: Awaited>; + rejection: Response | null; +} + +export async function getApiKeyRequestScope(request: Request): Promise { + const apiKey = extractApiKey(request); + + if (process.env.REQUIRE_API_KEY === "true") { + if (!apiKey) { + return { + apiKey: null, + apiKeyId: null, + apiKeyMetadata: null, + rejection: NextResponse.json( + { error: { message: "Missing API key", type: "invalid_request_error" } }, + { status: 401, headers: CORS_HEADERS } + ), + }; + } + + if (!(await isValidApiKey(apiKey))) { + return { + apiKey: null, + apiKeyId: null, + apiKeyMetadata: null, + rejection: NextResponse.json( + { error: { message: "Invalid API key", type: "invalid_request_error" } }, + { status: 401, headers: CORS_HEADERS } + ), + }; + } + } + + const apiKeyMetadata = await getApiKeyMetadata(apiKey); + return { + apiKey, + apiKeyId: apiKeyMetadata?.id || null, + apiKeyMetadata, + rejection: null, + }; +} diff --git a/src/app/api/v1/batches/[id]/cancel/route.ts b/src/app/api/v1/batches/[id]/cancel/route.ts index a83e406e1d..bfc22823d4 100644 --- a/src/app/api/v1/batches/[id]/cancel/route.ts +++ b/src/app/api/v1/batches/[id]/cancel/route.ts @@ -1,71 +1,73 @@ import { CORS_HEADERS } from "@/shared/utils/cors"; -import { extractApiKey } from "@/sse/services/auth"; -import { getBatch, updateBatch, getApiKeyMetadata } from "@/lib/localDb"; +import { getBatch, updateBatch } from "@/lib/localDb"; import { NextResponse } from "next/server"; +import { getApiKeyRequestScope } from "@/app/api/v1/_helpers/apiKeyScope"; function formatBatchResponse(batch: any) { - return { - id: batch.id, - object: "batch", - endpoint: batch.endpoint, - errors: batch.errors || null, - input_file_id: batch.inputFileId, - completion_window: batch.completionWindow, - status: batch.status, - output_file_id: batch.outputFileId || null, - error_file_id: batch.errorFileId || null, - created_at: batch.createdAt, - in_progress_at: batch.inProgressAt || null, - expires_at: batch.expiresAt || null, - finalizing_at: batch.finalizingAt || null, - completed_at: batch.completedAt || null, - failed_at: batch.failedAt || null, - expired_at: batch.expiredAt || null, - cancelling_at: batch.cancellingAt || null, - cancelled_at: batch.cancelledAt || null, - request_counts: { - total: batch.requestCountsTotal || 0, - completed: batch.requestCountsCompleted || 0, - failed: batch.requestCountsFailed || 0, - }, - metadata: batch.metadata || null, - model: batch.model || null, - usage: batch.usage || null, - }; + return { + id: batch.id, + object: "batch", + endpoint: batch.endpoint, + errors: batch.errors || null, + input_file_id: batch.inputFileId, + completion_window: batch.completionWindow, + status: batch.status, + output_file_id: batch.outputFileId || null, + error_file_id: batch.errorFileId || null, + created_at: batch.createdAt, + in_progress_at: batch.inProgressAt || null, + expires_at: batch.expiresAt || null, + finalizing_at: batch.finalizingAt || null, + completed_at: batch.completedAt || null, + failed_at: batch.failedAt || null, + expired_at: batch.expiredAt || null, + cancelling_at: batch.cancellingAt || null, + cancelled_at: batch.cancelledAt || null, + request_counts: { + total: batch.requestCountsTotal || 0, + completed: batch.requestCountsCompleted || 0, + failed: batch.requestCountsFailed || 0, + }, + metadata: batch.metadata || null, + model: batch.model || null, + usage: batch.usage || null, + }; } export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { - const apiKey = extractApiKey(request); - const apiKeyMetadata = await getApiKeyMetadata(apiKey); - const apiKeyId = apiKeyMetadata?.id || null; + const scope = await getApiKeyRequestScope(request); + if (scope.rejection) return scope.rejection; + const apiKeyId = scope.apiKeyId; - const { id } = await params; - const batch = getBatch(id); + const { id } = await params; + const batch = getBatch(id); - if (!batch || (batch.apiKeyId !== null && batch.apiKeyId !== apiKeyId)) { - return NextResponse.json( - { error: { message: "Batch not found", type: "invalid_request_error" } }, - { status: 404, headers: CORS_HEADERS } - ); - } + if (!batch || (batch.apiKeyId !== null && batch.apiKeyId !== apiKeyId)) { + return NextResponse.json( + { error: { message: "Batch not found", type: "invalid_request_error" } }, + { status: 404, headers: CORS_HEADERS } + ); + } - if (["completed", "failed", "cancelled", "expired"].includes(batch.status)) { - return NextResponse.json( - { error: { message: `Batch ${id} is already ${batch.status}`, type: "invalid_request_error" } }, - { status: 400, headers: CORS_HEADERS } - ); - } + if (["completed", "failed", "cancelled", "expired"].includes(batch.status)) { + return NextResponse.json( + { + error: { message: `Batch ${id} is already ${batch.status}`, type: "invalid_request_error" }, + }, + { status: 400, headers: CORS_HEADERS } + ); + } - if (batch.status === "cancelling") { - return NextResponse.json(formatBatchResponse(batch), { headers: CORS_HEADERS }); - } + if (batch.status === "cancelling") { + return NextResponse.json(formatBatchResponse(batch), { headers: CORS_HEADERS }); + } - updateBatch(id, { - status: "cancelling", - cancellingAt: Math.floor(Date.now() / 1000) - }); + updateBatch(id, { + status: "cancelling", + cancellingAt: Math.floor(Date.now() / 1000), + }); - const updatedBatch = getBatch(id); + const updatedBatch = getBatch(id); - return NextResponse.json(formatBatchResponse(updatedBatch), { headers: CORS_HEADERS }); + return NextResponse.json(formatBatchResponse(updatedBatch), { headers: CORS_HEADERS }); } diff --git a/src/app/api/v1/batches/[id]/route.ts b/src/app/api/v1/batches/[id]/route.ts index db1cc119fb..c661fb3a21 100644 --- a/src/app/api/v1/batches/[id]/route.ts +++ b/src/app/api/v1/batches/[id]/route.ts @@ -1,53 +1,53 @@ import { CORS_HEADERS } from "@/shared/utils/cors"; -import { extractApiKey } from "@/sse/services/auth"; -import { getBatch, getApiKeyMetadata } from "@/lib/localDb"; +import { getBatch } from "@/lib/localDb"; import { NextResponse } from "next/server"; +import { getApiKeyRequestScope } from "@/app/api/v1/_helpers/apiKeyScope"; function formatBatchResponse(batch: any) { - return { - id: batch.id, - object: "batch", - endpoint: batch.endpoint, - errors: batch.errors || null, - input_file_id: batch.inputFileId, - completion_window: batch.completionWindow, - status: batch.status, - output_file_id: batch.outputFileId || null, - error_file_id: batch.errorFileId || null, - created_at: batch.createdAt, - in_progress_at: batch.inProgressAt || null, - expires_at: batch.expiresAt || null, - finalizing_at: batch.finalizingAt || null, - completed_at: batch.completedAt || null, - failed_at: batch.failedAt || null, - expired_at: batch.expiredAt || null, - cancelling_at: batch.cancellingAt || null, - cancelled_at: batch.cancelledAt || null, - request_counts: { - total: batch.requestCountsTotal || 0, - completed: batch.requestCountsCompleted || 0, - failed: batch.requestCountsFailed || 0, - }, - metadata: batch.metadata || null, - model: batch.model || null, - usage: batch.usage || null, - }; + return { + id: batch.id, + object: "batch", + endpoint: batch.endpoint, + errors: batch.errors || null, + input_file_id: batch.inputFileId, + completion_window: batch.completionWindow, + status: batch.status, + output_file_id: batch.outputFileId || null, + error_file_id: batch.errorFileId || null, + created_at: batch.createdAt, + in_progress_at: batch.inProgressAt || null, + expires_at: batch.expiresAt || null, + finalizing_at: batch.finalizingAt || null, + completed_at: batch.completedAt || null, + failed_at: batch.failedAt || null, + expired_at: batch.expiredAt || null, + cancelling_at: batch.cancellingAt || null, + cancelled_at: batch.cancelledAt || null, + request_counts: { + total: batch.requestCountsTotal || 0, + completed: batch.requestCountsCompleted || 0, + failed: batch.requestCountsFailed || 0, + }, + metadata: batch.metadata || null, + model: batch.model || null, + usage: batch.usage || null, + }; } export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { - const apiKey = extractApiKey(request); - const apiKeyMetadata = await getApiKeyMetadata(apiKey); - const apiKeyId = apiKeyMetadata?.id || null; + const scope = await getApiKeyRequestScope(request); + if (scope.rejection) return scope.rejection; + const apiKeyId = scope.apiKeyId; - const { id } = await params; - const batch = getBatch(id); + const { id } = await params; + const batch = getBatch(id); - if (!batch || (batch.apiKeyId !== null && batch.apiKeyId !== apiKeyId)) { - return NextResponse.json( - { error: { message: "Batch not found", type: "invalid_request_error" } }, - { status: 404, headers: CORS_HEADERS } - ); - } + if (!batch || (batch.apiKeyId !== null && batch.apiKeyId !== apiKeyId)) { + return NextResponse.json( + { error: { message: "Batch not found", type: "invalid_request_error" } }, + { status: 404, headers: CORS_HEADERS } + ); + } - return NextResponse.json(formatBatchResponse(batch), { headers: CORS_HEADERS }); + return NextResponse.json(formatBatchResponse(batch), { headers: CORS_HEADERS }); } diff --git a/src/app/api/v1/batches/route.ts b/src/app/api/v1/batches/route.ts index 14734a7d69..f732efb76d 100644 --- a/src/app/api/v1/batches/route.ts +++ b/src/app/api/v1/batches/route.ts @@ -1,44 +1,44 @@ import { CORS_HEADERS } from "@/shared/utils/cors"; -import { extractApiKey } from "@/sse/services/auth"; -import { createBatch, getApiKeyMetadata, getFile, listBatches } from "@/lib/localDb"; +import { createBatch, getFile, listBatches } from "@/lib/localDb"; import { v1BatchCreateSchema } from "@/shared/validation/schemas"; import { NextResponse } from "next/server"; +import { getApiKeyRequestScope } from "@/app/api/v1/_helpers/apiKeyScope"; function formatBatchResponse(batch: any) { - return { - id: batch.id, - object: "batch", - endpoint: batch.endpoint, - errors: batch.errors || null, - input_file_id: batch.inputFileId, - completion_window: batch.completionWindow, - status: batch.status, - output_file_id: batch.outputFileId || null, - error_file_id: batch.errorFileId || null, - created_at: batch.createdAt, - in_progress_at: batch.inProgressAt || null, - expires_at: batch.expiresAt || null, - finalizing_at: batch.finalizingAt || null, - completed_at: batch.completedAt || null, - failed_at: batch.failedAt || null, - expired_at: batch.expiredAt || null, - cancelling_at: batch.cancellingAt || null, - cancelled_at: batch.cancelledAt || null, - request_counts: { - total: batch.requestCountsTotal || 0, - completed: batch.requestCountsCompleted || 0, - failed: batch.requestCountsFailed || 0, - }, - metadata: batch.metadata || null, - model: batch.model || null, - usage: batch.usage || null, - }; + return { + id: batch.id, + object: "batch", + endpoint: batch.endpoint, + errors: batch.errors || null, + input_file_id: batch.inputFileId, + completion_window: batch.completionWindow, + status: batch.status, + output_file_id: batch.outputFileId || null, + error_file_id: batch.errorFileId || null, + created_at: batch.createdAt, + in_progress_at: batch.inProgressAt || null, + expires_at: batch.expiresAt || null, + finalizing_at: batch.finalizingAt || null, + completed_at: batch.completedAt || null, + failed_at: batch.failedAt || null, + expired_at: batch.expiredAt || null, + cancelling_at: batch.cancellingAt || null, + cancelled_at: batch.cancelledAt || null, + request_counts: { + total: batch.requestCountsTotal || 0, + completed: batch.requestCountsCompleted || 0, + failed: batch.requestCountsFailed || 0, + }, + metadata: batch.metadata || null, + model: batch.model || null, + usage: batch.usage || null, + }; } export async function POST(request: Request) { - const apiKey = extractApiKey(request); - const apiKeyMetadata = await getApiKeyMetadata(apiKey); - const apiKeyId = apiKeyMetadata?.id || null; + const scope = await getApiKeyRequestScope(request); + if (scope.rejection) return scope.rejection; + const apiKeyId = scope.apiKeyId; try { const body = await request.json(); @@ -46,55 +46,60 @@ export async function POST(request: Request) { const inputFile = getFile(validated.input_file_id); if (!inputFile || (inputFile.apiKeyId !== null && inputFile.apiKeyId !== apiKeyId)) { - return NextResponse.json( - { error: { message: "Input file not found", type: "invalid_request_error" } }, - { status: 400, headers: CORS_HEADERS } - ); + return NextResponse.json( + { error: { message: "Input file not found", type: "invalid_request_error" } }, + { status: 400, headers: CORS_HEADERS } + ); } const batch = createBatch({ - endpoint: validated.endpoint as any, - completionWindow: validated.completion_window, - inputFileId: validated.input_file_id, - metadata: validated.metadata, - apiKeyId, - outputExpiresAfterSeconds: validated.output_expires_after?.seconds || null, - outputExpiresAfterAnchor: validated.output_expires_after?.anchor || null, + endpoint: validated.endpoint as any, + completionWindow: validated.completion_window, + inputFileId: validated.input_file_id, + metadata: validated.metadata, + apiKeyId, + outputExpiresAfterSeconds: validated.output_expires_after?.seconds || null, + outputExpiresAfterAnchor: validated.output_expires_after?.anchor || null, }); return NextResponse.json(formatBatchResponse(batch), { headers: CORS_HEADERS }); } catch (error) { console.error("[BATCHES] Create failed:", error); return NextResponse.json( - { error: { message: error instanceof Error ? error.message : "Create failed", type: "invalid_request_error" } }, + { + error: { + message: error instanceof Error ? error.message : "Create failed", + type: "invalid_request_error", + }, + }, { status: 400, headers: CORS_HEADERS } ); } } export async function GET(request: Request) { - const apiKey = extractApiKey(request); - const apiKeyMetadata = await getApiKeyMetadata(apiKey); - const apiKeyId = apiKeyMetadata?.id || null; + const scope = await getApiKeyRequestScope(request); + if (scope.rejection) return scope.rejection; + const apiKeyId = scope.apiKeyId; - const url = new URL(request.url); - const limit = Number.parseInt(url.searchParams.get("limit") || "20"); - const after = url.searchParams.get("after") || undefined; + const url = new URL(request.url); + const limit = Number.parseInt(url.searchParams.get("limit") || "20"); + const after = url.searchParams.get("after") || undefined; - const batches = listBatches(apiKeyId || undefined, limit + 1, after); - const hasMore = batches.length > limit; - const data = hasMore ? batches.slice(0, limit) : batches; - - const formattedData = data.map(b => formatBatchResponse(b)); - - return NextResponse.json( - { - object: "list", - data: formattedData, - first_id: formattedData.length > 0 ? formattedData[0].id : null, - last_id: formattedData.length > 0 ? formattedData.at(-1).id : null, - has_more: hasMore, - }, - { headers: CORS_HEADERS } - ); + const batches = listBatches(apiKeyId || undefined, limit + 1, after); + const hasMore = batches.length > limit; + const data = hasMore ? batches.slice(0, limit) : batches; + + const formattedData = data.map((b) => formatBatchResponse(b)); + + return NextResponse.json( + { + object: "list", + data: formattedData, + first_id: formattedData.length > 0 ? formattedData[0].id : null, + last_id: formattedData.length > 0 ? formattedData.at(-1).id : null, + has_more: hasMore, + }, + { headers: CORS_HEADERS } + ); } diff --git a/src/app/api/v1/files/[id]/content/route.ts b/src/app/api/v1/files/[id]/content/route.ts index b7a150bb75..4cad037f60 100644 --- a/src/app/api/v1/files/[id]/content/route.ts +++ b/src/app/api/v1/files/[id]/content/route.ts @@ -1,12 +1,12 @@ import { CORS_HEADERS } from "@/shared/utils/cors"; -import { extractApiKey } from "@/sse/services/auth"; -import { getFile, getFileContent, getApiKeyMetadata } from "@/lib/localDb"; +import { getFile, getFileContent } from "@/lib/localDb"; import { NextResponse } from "next/server"; +import { getApiKeyRequestScope } from "@/app/api/v1/_helpers/apiKeyScope"; export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { - const apiKey = extractApiKey(request); - const apiKeyMetadata = await getApiKeyMetadata(apiKey); - const apiKeyId = apiKeyMetadata?.id || null; + const scope = await getApiKeyRequestScope(request); + if (scope.rejection) return scope.rejection; + const apiKeyId = scope.apiKeyId; const { id } = await params; const file = getFile(id); @@ -20,7 +20,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: const content = getFileContent(id); if (!content) { - return NextResponse.json( + return NextResponse.json( { error: { message: "File content not found", type: "invalid_request_error" } }, { status: 404, headers: CORS_HEADERS } ); diff --git a/src/app/api/v1/files/[id]/route.ts b/src/app/api/v1/files/[id]/route.ts index 00baab6617..3b31db553f 100644 --- a/src/app/api/v1/files/[id]/route.ts +++ b/src/app/api/v1/files/[id]/route.ts @@ -1,12 +1,12 @@ import { CORS_HEADERS } from "@/shared/utils/cors"; -import { extractApiKey } from "@/sse/services/auth"; -import { getFile, deleteFile, getApiKeyMetadata, formatFileResponse } from "@/lib/localDb"; +import { getFile, deleteFile, formatFileResponse } from "@/lib/localDb"; import { NextResponse } from "next/server"; +import { getApiKeyRequestScope } from "@/app/api/v1/_helpers/apiKeyScope"; export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { - const apiKey = extractApiKey(request); - const apiKeyMetadata = await getApiKeyMetadata(apiKey); - const apiKeyId = apiKeyMetadata?.id || null; + const scope = await getApiKeyRequestScope(request); + if (scope.rejection) return scope.rejection; + const apiKeyId = scope.apiKeyId; const { id } = await params; const file = getFile(id); @@ -17,14 +17,14 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: { status: 404, headers: CORS_HEADERS } ); } - + return NextResponse.json(formatFileResponse(file), { headers: CORS_HEADERS }); } export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { - const apiKey = extractApiKey(request); - const apiKeyMetadata = await getApiKeyMetadata(apiKey); - const apiKeyId = apiKeyMetadata?.id || null; + const scope = await getApiKeyRequestScope(request); + if (scope.rejection) return scope.rejection; + const apiKeyId = scope.apiKeyId; const { id } = await params; const file = getFile(id); @@ -38,9 +38,12 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i deleteFile(id); - return NextResponse.json({ - id, - object: "file", - deleted: true - }, { headers: CORS_HEADERS }); + return NextResponse.json( + { + id, + object: "file", + deleted: true, + }, + { headers: CORS_HEADERS } + ); } diff --git a/src/app/api/v1/files/route.ts b/src/app/api/v1/files/route.ts index 8fe4f36565..cceb347f39 100644 --- a/src/app/api/v1/files/route.ts +++ b/src/app/api/v1/files/route.ts @@ -1,12 +1,12 @@ import { CORS_HEADERS } from "@/shared/utils/cors"; -import { extractApiKey } from "@/sse/services/auth"; -import { createFile, listFiles, getApiKeyMetadata, formatFileResponse } from "@/lib/localDb"; +import { createFile, listFiles, formatFileResponse } from "@/lib/localDb"; import { NextResponse } from "next/server"; +import { getApiKeyRequestScope } from "@/app/api/v1/_helpers/apiKeyScope"; export async function POST(request: Request) { - const apiKey = extractApiKey(request); - const apiKeyMetadata = await getApiKeyMetadata(apiKey); - const apiKeyId = apiKeyMetadata?.id || null; + const scope = await getApiKeyRequestScope(request); + if (scope.rejection) return scope.rejection; + const apiKeyId = scope.apiKeyId; try { const formData = await request.formData(); @@ -25,7 +25,12 @@ export async function POST(request: Request) { const MAX_FILE_BYTES = 512 * 1024 * 1024; // 512 MB if (file.size > MAX_FILE_BYTES) { return NextResponse.json( - { error: { message: "File exceeds maximum allowed size of 512 MB", type: "invalid_request_error" } }, + { + error: { + message: "File exceeds maximum allowed size of 512 MB", + type: "invalid_request_error", + }, + }, { status: 400, headers: CORS_HEADERS } ); } @@ -61,7 +66,7 @@ export async function POST(request: Request) { apiKeyId, expiresAt, }); - + return NextResponse.json(formatFileResponse(record), { headers: CORS_HEADERS }); } catch (error) { console.error("[FILES] Upload failed:", error); @@ -73,9 +78,9 @@ export async function POST(request: Request) { } export async function GET(request: Request) { - const apiKey = extractApiKey(request); - const apiKeyMetadata = await getApiKeyMetadata(apiKey); - const apiKeyId = apiKeyMetadata?.id || null; + const scope = await getApiKeyRequestScope(request); + if (scope.rejection) return scope.rejection; + const apiKeyId = scope.apiKeyId; const { searchParams } = new URL(request.url); const limit = Math.min(Number.parseInt(searchParams.get("limit") || "20") || 20, 10000); @@ -89,12 +94,12 @@ export async function GET(request: Request) { purpose, limit: limit + 1, after, - order + order, }); const hasMore = files.length > limit; const data = files.slice(0, limit); - + return NextResponse.json( { object: "list", diff --git a/src/app/globals.css b/src/app/globals.css index ab42b2b10a..0c06029166 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -14,6 +14,7 @@ :root { --desktop-safe-top: 0px; --desktop-safe-bottom: 0px; + color-scheme: light; /* Primary - Coral Red (OpenClaw) */ --color-primary: #e54d5e; @@ -54,6 +55,7 @@ .dark { /* Dark theme (ClawHub deep) */ + color-scheme: dark; --color-bg: #0b0e14; --color-bg-alt: #111520; --color-bg-primary: #0b0e14; diff --git a/src/lib/api/requireCliToolsAuth.ts b/src/lib/api/requireCliToolsAuth.ts new file mode 100644 index 0000000000..b5180346da --- /dev/null +++ b/src/lib/api/requireCliToolsAuth.ts @@ -0,0 +1,5 @@ +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +export async function requireCliToolsAuth(request: Request): Promise { + return requireManagementAuth(request); +} diff --git a/src/lib/batches/dispatch.ts b/src/lib/batches/dispatch.ts new file mode 100644 index 0000000000..68bc5e51eb --- /dev/null +++ b/src/lib/batches/dispatch.ts @@ -0,0 +1,49 @@ +import type { SupportedBatchEndpoint } from "@/shared/constants/batchEndpoints"; + +type BatchRouteHandler = (request: Request) => Promise | Response; + +const handlerLoaders: Record Promise> = { + "/v1/responses": async () => (await import("@/app/api/v1/responses/route")).POST, + "/v1/chat/completions": async () => (await import("@/app/api/v1/chat/completions/route")).POST, + "/v1/embeddings": async () => (await import("@/app/api/v1/embeddings/route")).POST, + "/v1/completions": async () => (await import("@/app/api/v1/completions/route")).POST, + "/v1/moderations": async () => (await import("@/app/api/v1/moderations/route")).POST, + "/v1/images/generations": async () => + (await import("@/app/api/v1/images/generations/route")).POST, + "/v1/videos/generations": async () => + (await import("@/app/api/v1/videos/generations/route")).POST, +}; + +const handlerCache = new Map(); + +async function getHandler(endpoint: SupportedBatchEndpoint): Promise { + const cached = handlerCache.get(endpoint); + if (cached) return cached; + + const handler = await handlerLoaders[endpoint](); + handlerCache.set(endpoint, handler); + return handler; +} + +export async function dispatchBatchApiRequest({ + endpoint, + body, + apiKey, +}: { + endpoint: SupportedBatchEndpoint; + body: Record; + apiKey?: string | null; +}): Promise { + const headers = new Headers({ "Content-Type": "application/json" }); + if (apiKey) { + headers.set("Authorization", `Bearer ${apiKey}`); + } + + const handler = await getHandler(endpoint); + const request = new Request(`http://localhost${endpoint}`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + return await handler(request); +} diff --git a/src/lib/db/batches.ts b/src/lib/db/batches.ts index 039ae0136e..f96dcd99f3 100644 --- a/src/lib/db/batches.ts +++ b/src/lib/db/batches.ts @@ -4,13 +4,25 @@ import { v4 as uuidv4 } from "uuid"; function parseBatchRow(row: any): BatchRecord { const camel = rowToCamel(row) as any; if (camel.metadata && typeof camel.metadata === "string") { - try { camel.metadata = JSON.parse(camel.metadata); } catch { camel.metadata = null; } + try { + camel.metadata = JSON.parse(camel.metadata); + } catch { + camel.metadata = null; + } } if (camel.errors && typeof camel.errors === "string") { - try { camel.errors = JSON.parse(camel.errors); } catch { camel.errors = null; } + try { + camel.errors = JSON.parse(camel.errors); + } catch { + camel.errors = null; + } } if (camel.usage && typeof camel.usage === "string") { - try { camel.usage = JSON.parse(camel.usage); } catch { camel.usage = null; } + try { + camel.usage = JSON.parse(camel.usage); + } catch { + camel.usage = null; + } } return camel as BatchRecord; } @@ -19,7 +31,15 @@ export interface BatchRecord { id: string; endpoint: string; completionWindow: string; - status: "validating" | "failed" | "in_progress" | "finalizing" | "completed" | "expired" | "cancelling" | "cancelled"; + status: + | "validating" + | "failed" + | "in_progress" + | "finalizing" + | "completed" + | "expired" + | "cancelling" + | "cancelled"; inputFileId: string; outputFileId?: string | null; errorFileId?: string | null; @@ -44,7 +64,17 @@ export interface BatchRecord { outputExpiresAfterAnchor?: string | null; } -export function createBatch(batch: Omit): BatchRecord { +export function createBatch( + batch: Omit< + BatchRecord, + | "id" + | "createdAt" + | "status" + | "requestCountsTotal" + | "requestCountsCompleted" + | "requestCountsFailed" + > +): BatchRecord { const db = getDbInstance(); const id = "batch_" + uuidv4().replaceAll("-", "").substring(0, 24); const createdAt = Math.floor(Date.now() / 1000); @@ -67,15 +97,13 @@ export function createBatch(batch: Omit "?").join(", "); - db.prepare( - `INSERT INTO batches (${keys.join(", ")}) VALUES (${placeholders})` - ).run(...values); + db.prepare(`INSERT INTO batches (${keys.join(", ")}) VALUES (${placeholders})`).run(...values); return record; } @@ -99,52 +127,61 @@ export function updateBatch(id: string, updates: Partial): boolean if (snakeUpdates.usage && typeof snakeUpdates.usage !== "string") { snakeUpdates.usage = JSON.stringify(snakeUpdates.usage); } - + const keys = Object.keys(snakeUpdates); if (keys.length === 0) return false; - - const setClause = keys.map(k => `${k} = ?`).join(", "); + + const setClause = keys.map((k) => `${k} = ?`).join(", "); const values = Object.values(snakeUpdates); - + const result = db.prepare(`UPDATE batches SET ${setClause} WHERE id = ?`).run(...values, id); return result.changes > 0; } export function listBatches(apiKeyId?: string, limit: number = 20, after?: string): BatchRecord[] { const db = getDbInstance(); + const afterBatch = after ? getBatch(after) : null; let rows: any[]; if (apiKeyId) { - if (after) { + if (afterBatch) { rows = db - .prepare("SELECT * FROM batches WHERE api_key_id = ? AND id < ? ORDER BY id DESC LIMIT ?") - .all(apiKeyId, after, limit); + .prepare( + "SELECT * FROM batches WHERE api_key_id = ? AND (created_at < ? OR (created_at = ? AND id < ?)) ORDER BY created_at DESC, id DESC LIMIT ?" + ) + .all(apiKeyId, afterBatch.createdAt, afterBatch.createdAt, after, limit); } else { rows = db - .prepare("SELECT * FROM batches WHERE api_key_id = ? ORDER BY id DESC LIMIT ?") + .prepare( + "SELECT * FROM batches WHERE api_key_id = ? ORDER BY created_at DESC, id DESC LIMIT ?" + ) .all(apiKeyId, limit); } - } else if (after) { + } else if (afterBatch) { rows = db - .prepare("SELECT * FROM batches WHERE id < ? ORDER BY id DESC LIMIT ?") - .all(after, limit); + .prepare( + "SELECT * FROM batches WHERE (created_at < ? OR (created_at = ? AND id < ?)) ORDER BY created_at DESC, id DESC LIMIT ?" + ) + .all(afterBatch.createdAt, afterBatch.createdAt, after, limit); } else { - rows = db.prepare("SELECT * FROM batches ORDER BY id DESC LIMIT ?").all(limit); + rows = db.prepare("SELECT * FROM batches ORDER BY created_at DESC, id DESC LIMIT ?").all(limit); } - return rows.map(row => parseBatchRow(row)); + return rows.map((row) => parseBatchRow(row)); } export function getPendingBatches(): BatchRecord[] { const db = getDbInstance(); - const rows = db.prepare( - "SELECT * FROM batches WHERE status IN ('validating', 'in_progress', 'cancelling')" - ).all(); - return rows.map(row => parseBatchRow(row)); + const rows = db + .prepare("SELECT * FROM batches WHERE status IN ('validating', 'in_progress', 'cancelling')") + .all(); + return rows.map((row) => parseBatchRow(row)); } export function getTerminalBatches(): BatchRecord[] { const db = getDbInstance(); - const rows = db.prepare( - "SELECT * FROM batches WHERE status IN ('completed', 'failed', 'cancelled', 'expired') ORDER BY created_at ASC" - ).all(); - return rows.map(row => parseBatchRow(row)); + const rows = db + .prepare( + "SELECT * FROM batches WHERE status IN ('completed', 'failed', 'cancelled', 'expired') ORDER BY created_at ASC" + ) + .all(); + return rows.map((row) => parseBatchRow(row)); } diff --git a/src/shared/constants/batchEndpoints.ts b/src/shared/constants/batchEndpoints.ts new file mode 100644 index 0000000000..1a9a92e023 --- /dev/null +++ b/src/shared/constants/batchEndpoints.ts @@ -0,0 +1,11 @@ +export const SUPPORTED_BATCH_ENDPOINTS = [ + "/v1/responses", + "/v1/chat/completions", + "/v1/embeddings", + "/v1/completions", + "/v1/moderations", + "/v1/images/generations", + "/v1/videos/generations", +] as const; + +export type SupportedBatchEndpoint = (typeof SUPPORTED_BATCH_ENDPOINTS)[number]; diff --git a/src/shared/services/cliRuntime.ts b/src/shared/services/cliRuntime.ts index 5a8e08effb..ea73f454ab 100644 --- a/src/shared/services/cliRuntime.ts +++ b/src/shared/services/cliRuntime.ts @@ -15,6 +15,7 @@ const CLI_TOOLS: Record = { healthcheckTimeoutMs: 4000, paths: { settings: ".claude/settings.json", + auth: ".claude/.credentials.json", }, }, codex: { diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 5c9d84c205..a7f7c52b08 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { SUPPORTED_BATCH_ENDPOINTS } from "@/shared/constants/batchEndpoints"; import { HIDEABLE_SIDEBAR_ITEM_IDS } from "@/shared/constants/sidebarVisibility"; import { isForbiddenUpstreamHeaderName } from "@/shared/constants/upstreamHeaders"; @@ -1799,16 +1800,7 @@ export const versionManagerInstallSchema = versionManagerToolSchema.extend({ export const v1BatchCreateSchema = z.object({ input_file_id: z.string().min(1), - endpoint: z.enum([ - "/v1/responses", - "/v1/chat/completions", - "/v1/embeddings", - "/v1/completions", - "/v1/moderations", - "/v1/images/generations", - "/v1/images/edits", - "/v1/videos", - ]), + endpoint: z.enum(SUPPORTED_BATCH_ENDPOINTS), completion_window: z.enum(["24h"]), metadata: z .record(z.string().max(64), z.string().max(512)) diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index b66d1ea534..955ba39527 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -699,6 +699,8 @@ async function handleSingleModelChat( // 4. Execute chat via core after breaker gate checks (with optional TLS tracking) if (telemetry) telemetry.startPhase("connect"); const { result, tlsFingerprintUsed } = await executeChatWithBreaker({ + bypassCircuitBreaker: forceLiveComboTest || hasForcedConnection, + breaker, body: requestBody, provider, model, diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index 8099889ed3..ae92f1c833 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -24,7 +24,7 @@ import { isTlsFingerprintActive, } from "@omniroute/open-sse/utils/proxyFetch.ts"; import { resolveProxyForConnection } from "@/lib/localDb"; -import { getCircuitBreaker } from "../../shared/utils/circuitBreaker"; +import { CircuitBreakerOpenError, getCircuitBreaker } from "../../shared/utils/circuitBreaker"; import { logProxyEvent } from "../../lib/proxyLogger"; import { logTranslationEvent } from "../../lib/translatorEvents"; import { getRuntimeProviderProfile } from "@omniroute/open-sse/services/accountFallback.ts"; @@ -102,6 +102,8 @@ export async function checkPipelineGates( } export async function executeChatWithBreaker({ + bypassCircuitBreaker, + breaker, body, provider, model, @@ -154,14 +156,36 @@ export async function executeChatWithBreaker({ }) ); + if (bypassCircuitBreaker) { + if (!proxyInfo?.proxy && isTlsFingerprintActive()) { + const tracked = await runWithTlsTracking(chatFn); + return { result: tracked.result, tlsFingerprintUsed: tracked.tlsFingerprintUsed }; + } + + const result = await chatFn(); + return { result, tlsFingerprintUsed: false }; + } + if (!proxyInfo?.proxy && isTlsFingerprintActive()) { - const tracked = await runWithTlsTracking(chatFn); + const tracked = await breaker.execute(async () => runWithTlsTracking(chatFn)); return { result: tracked.result, tlsFingerprintUsed: tracked.tlsFingerprintUsed }; } - const result = await chatFn(); + const result = await breaker.execute(chatFn); return { result, tlsFingerprintUsed: false }; } catch (cbErr: any) { + if (cbErr instanceof CircuitBreakerOpenError) { + log.warn("CIRCUIT", `${provider} circuit open during retry: ${cbErr.message}`); + return { + result: { + success: false, + response: providerCircuitOpenResponse(provider, Math.ceil(cbErr.retryAfterMs / 1000)), + status: HTTP_STATUS.SERVICE_UNAVAILABLE, + }, + tlsFingerprintUsed: false, + }; + } + if (cbErr?.code === "PROXY_UNREACHABLE" || /proxy unreachable/i.test(cbErr?.message || "")) { const detail = cbErr?.message || "Proxy unreachable"; log.warn("PROXY", detail); diff --git a/tests/e2e/api.spec.ts b/tests/e2e/api.spec.ts index e358fe6209..08738d6b8b 100644 --- a/tests/e2e/api.spec.ts +++ b/tests/e2e/api.spec.ts @@ -4,14 +4,14 @@ test.describe("API Health Checks", () => { test("GET /api/monitoring/health returns OK", async ({ request }) => { const res = await request.get("/api/monitoring/health"); expect(res.ok()).toBeTruthy(); - const body = await res.json(); + const body = (await res.json()) as any; expect(body).toHaveProperty("status"); }); test("GET /api/v1/models returns model list", async ({ request }) => { const res = await request.get("/api/v1/models"); expect(res.ok()).toBeTruthy(); - const body = await res.json(); + const body = (await res.json()) as any; expect(body).toHaveProperty("data"); expect(Array.isArray(body.data)).toBe(true); }); @@ -20,7 +20,7 @@ test.describe("API Health Checks", () => { const res = await request.get("/api/providers"); // In CI with auth enabled, 401 is acceptable — endpoint is reachable if (res.ok()) { - const body = await res.json(); + const body = (await res.json()) as any; expect(body).toHaveProperty("connections"); expect(Array.isArray(body.connections)).toBe(true); } else { diff --git a/tests/e2e/ecosystem.test.ts b/tests/e2e/ecosystem.test.ts index 8f7c8e09ea..dfb8fbfbd5 100644 --- a/tests/e2e/ecosystem.test.ts +++ b/tests/e2e/ecosystem.test.ts @@ -38,21 +38,21 @@ describe("E2E: MCP Server (16 tools)", () => { itCase("should respond to health check", async () => { const res = await apiFetch("/api/monitoring/health"); expect(res.ok).toBe(true); - const data = await res.json(); + const data = (await res.json()) as any; expect(data).toHaveProperty("status"); }); itCase("should list combos", async () => { const res = await apiFetch("/api/combos"); expect(res.ok).toBe(true); - const data = await res.json(); + const data = (await res.json()) as any; expect(Array.isArray(data?.combos)).toBe(true); }); itCase("should return quota data", async () => { const res = await apiFetch("/api/usage/quota"); expect(res.ok).toBe(true); - const data = await res.json(); + const data = (await res.json()) as any; expect(Array.isArray(data?.providers)).toBe(true); expect(data).toHaveProperty("meta"); }); @@ -74,7 +74,7 @@ describe("E2E: Quota Contract (/api/usage/quota)", () => { const res = await apiFetch("/api/usage/quota"); expect(res.ok).toBe(true); - const data = await res.json(); + const data = (await res.json()) as any; expect(Array.isArray(data.providers)).toBe(true); expect(data).toHaveProperty("meta"); expect(typeof data.meta.generatedAt).toBe("string"); @@ -96,13 +96,13 @@ describe("E2E: Quota Contract (/api/usage/quota)", () => { itCase("should filter quota by provider", async () => { const allRes = await apiFetch("/api/usage/quota"); expect(allRes.ok).toBe(true); - const allData = await allRes.json(); + const allData = (await allRes.json()) as any; if (!Array.isArray(allData.providers) || allData.providers.length === 0) return; const provider = allData.providers[0].provider; const filteredRes = await apiFetch(`/api/usage/quota?provider=${encodeURIComponent(provider)}`); expect(filteredRes.ok).toBe(true); - const filteredData = await filteredRes.json(); + const filteredData = (await filteredRes.json()) as any; expect(filteredData.meta.filters.provider).toBe(provider); expect(Array.isArray(filteredData.providers)).toBe(true); expect(filteredData.providers.every((p: any) => p.provider === provider)).toBe(true); @@ -111,7 +111,7 @@ describe("E2E: Quota Contract (/api/usage/quota)", () => { itCase("should filter quota by connectionId", async () => { const allRes = await apiFetch("/api/usage/quota"); expect(allRes.ok).toBe(true); - const allData = await allRes.json(); + const allData = (await allRes.json()) as any; if (!Array.isArray(allData.providers) || allData.providers.length === 0) return; const connectionId = allData.providers[0].connectionId; @@ -119,7 +119,7 @@ describe("E2E: Quota Contract (/api/usage/quota)", () => { `/api/usage/quota?connectionId=${encodeURIComponent(connectionId)}` ); expect(filteredRes.ok).toBe(true); - const filteredData = await filteredRes.json(); + const filteredData = (await filteredRes.json()) as any; expect(filteredData.meta.filters.connectionId).toBe(connectionId); expect(Array.isArray(filteredData.providers)).toBe(true); expect(filteredData.providers.every((p: any) => p.connectionId === connectionId)).toBe(true); @@ -131,7 +131,7 @@ describe("E2E: A2A Server (lifecycle)", () => { itCase("should serve Agent Card", async () => { const res = await apiFetch("/.well-known/agent.json"); expect(res.ok).toBe(true); - const card = await res.json(); + const card = (await res.json()) as any; expect(card).toHaveProperty("name"); expect(card).toHaveProperty("skills"); expect(card).toHaveProperty("version"); @@ -152,7 +152,7 @@ describe("E2E: A2A Server (lifecycle)", () => { }), }); expect(res.ok).toBe(true); - const data = await res.json(); + const data = (await res.json()) as any; expect(data).toHaveProperty("result"); expect(data.result.task).toHaveProperty("id"); expect(data.result.task).toHaveProperty("state"); @@ -168,7 +168,7 @@ describe("E2E: A2A Server (lifecycle)", () => { params: {}, }), }); - const data = await res.json(); + const data = (await res.json()) as any; expect(data).toHaveProperty("error"); expect(data.error.code).toBe(-32601); }); @@ -197,7 +197,7 @@ describe("E2E: Auto-Combo (routing + self-healing)", () => { const res = await apiFetch("/api/combos"); if (!res.ok) console.error("GET /api/combos failed:", await res.text()); expect(res.ok).toBe(true); - const data = await res.json(); + const data = (await res.json()) as any; expect(Array.isArray(data?.combos)).toBe(true); const autoCombos = data.combos.filter((c: any) => c.strategy === "auto"); expect(autoCombos.length).toBeGreaterThanOrEqual(0); @@ -209,7 +209,7 @@ describe("E2E: OpenClaw Integration", () => { itCase("should return dynamic provider.order", async () => { const res = await apiFetch("/api/cli-tools/openclaw/auto-order"); expect(res.ok).toBe(true); - const data = await res.json(); + const data = (await res.json()) as any; expect(data).toHaveProperty("provider"); expect(data.provider).toHaveProperty("order"); expect(Array.isArray(data.provider.order)).toBe(true); @@ -306,7 +306,7 @@ describe("E2E: Security", () => { method: "POST", body: "not-json", }); - const data = await res.json(); + const data = (await res.json()) as any; if (data.error) { expect(data.error.code).toBe(-32700); // Parse error } diff --git a/tests/e2e/helpers/dashboardAuth.ts b/tests/e2e/helpers/dashboardAuth.ts index 280d499f61..74974aece8 100644 --- a/tests/e2e/helpers/dashboardAuth.ts +++ b/tests/e2e/helpers/dashboardAuth.ts @@ -47,7 +47,7 @@ async function getDashboardAuthState(page: Page) { return await page.evaluate(async () => { const safeJson = async (response: Response) => { try { - return await response.json(); + return (await response.json()) as any; } catch { return null; } @@ -111,7 +111,7 @@ export async function gotoDashboardRoute( await page.locator("body").waitFor({ state: "visible", timeout: timeoutMs }); return; - } catch (error) { + } catch (error: any) { lastError = error; await page.waitForTimeout(1000); } diff --git a/tests/e2e/protocol-clients.test.ts b/tests/e2e/protocol-clients.test.ts index 7d31b80db1..0193e8bce5 100644 --- a/tests/e2e/protocol-clients.test.ts +++ b/tests/e2e/protocol-clients.test.ts @@ -129,7 +129,7 @@ describe("Protocol clients E2E", () => { expect([200, 401]).toContain(auditRes.status); if (auditRes.status === 200) { expect(auditRes.ok).toBe(true); - const auditJson = await auditRes.json(); + const auditJson = (await auditRes.json()) as any; const entries = Array.isArray(auditJson?.entries) ? auditJson.entries : []; expect(entries.some((entry: any) => entry.toolName === "omniroute_get_health")).toBe(true); } @@ -142,7 +142,7 @@ describe("Protocol clients E2E", () => { async () => { const cardRes = await apiFetch("/.well-known/agent.json"); expect(cardRes.ok).toBe(true); - const card = await cardRes.json(); + const card = (await cardRes.json()) as any; expect(card).toHaveProperty("name"); expect(Array.isArray(card?.skills)).toBe(true); @@ -203,7 +203,7 @@ describe("Protocol clients E2E", () => { expect([200, 401]).toContain(tasksRes.status); if (tasksRes.status === 200) { expect(tasksRes.ok).toBe(true); - const tasksJson = await tasksRes.json(); + const tasksJson = (await tasksRes.json()) as any; const tasks = Array.isArray(tasksJson?.tasks) ? tasksJson.tasks : []; expect(tasks.some((task: any) => task.id === sendTaskId)).toBe(true); } diff --git a/tests/integration/api-keys.test.ts b/tests/integration/api-keys.test.ts index 706f7dc1c1..1de3c0a1e0 100644 --- a/tests/integration/api-keys.test.ts +++ b/tests/integration/api-keys.test.ts @@ -73,8 +73,8 @@ test("API keys routes require management auth when login protection is enabled", }) ); - const unauthenticatedBody = await unauthenticated.json(); - const invalidTokenBody = await invalidToken.json(); + const unauthenticatedBody = (await unauthenticated.json()) as any; + const invalidTokenBody = (await invalidToken.json()) as any; assert.equal(unauthenticated.status, 401); assert.equal(unauthenticatedBody.error.message, "Authentication required"); @@ -99,8 +99,8 @@ test("API keys POST also requires management auth when login protection is enabl }) ); - const unauthenticatedBody = await unauthenticated.json(); - const invalidTokenBody = await invalidToken.json(); + const unauthenticatedBody = (await unauthenticated.json()) as any; + const invalidTokenBody = (await invalidToken.json()) as any; assert.equal(unauthenticated.status, 401); assert.equal(unauthenticatedBody.error.message, "Authentication required"); @@ -117,7 +117,7 @@ test("POST /api/keys creates a key, preserves special characters, and persists n body: { name: "Key / Prod #1", noLog: true }, }) ); - const body = await response.json(); + const body = (await response.json()) as any; const stored = await apiKeysDb.getApiKeyById(body.id); assert.equal(response.status, 201); @@ -160,7 +160,7 @@ test("POST /api/keys returns a server error for malformed JSON payloads", async body: "{", }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 500); assert.equal(body.error, "Failed to create key"); @@ -180,8 +180,8 @@ test("GET /api/keys lists masked keys with pagination and GET /api/keys/[id] sta { params: Promise.resolve({ id: createdB.id }) } ); - const listBody = await listResponse.json(); - const getBody = await getResponse.json(); + const listBody = (await listResponse.json()) as any; + const getBody = (await getResponse.json()) as any; assert.equal(listResponse.status, 200); assert.equal(listBody.total, 3); @@ -205,7 +205,7 @@ test("GET /api/keys falls back to default pagination for invalid query params", const response = await listRoute.GET( await makeManagementSessionRequest("http://localhost/api/keys?limit=0&offset=-25") ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.total, 3); @@ -222,7 +222,7 @@ test("GET /api/keys treats non-numeric pagination params as defaults", async () const response = await listRoute.GET( await makeManagementSessionRequest("http://localhost/api/keys?limit=abc&offset=xyz") ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.total, 3); @@ -243,7 +243,7 @@ test("GET /api/keys uses default pagination when query params are absent and rep const response = await listRoute.GET( await makeManagementSessionRequest("http://localhost/api/keys") ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.total, 3); @@ -274,7 +274,7 @@ test("POST /api/keys triggers cloud sync when cloud mode is enabled", async () = body: { name: "Cloud Synced Key" }, }) ); - const body = await response.json(); + const body = (await response.json()) as any; const syncPayload = JSON.parse(calls[0].options.body); assert.equal(response.status, 201); @@ -309,7 +309,7 @@ test("GET /api/keys returns 500 when the key store throws unexpectedly", async ( try { const response = await listRoute.GET(new Request("http://localhost/api/keys")); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 500); assert.equal(body.error, "Failed to fetch keys"); @@ -340,7 +340,7 @@ test("POST /api/keys still succeeds when cloud sync fails after creation", async body: { name: "Cloud Failure Tolerated" }, }) ); - const body = await response.json(); + const body = (await response.json()) as any; const stored = await apiKeysDb.getApiKeyById(body.id); assert.equal(response.status, 201); @@ -372,9 +372,9 @@ test("GET /api/keys/[id] returns 404 for an unknown key and reveal is gated by t { params: Promise.resolve({ id: created.id }) } ); - const missingBody = await missingResponse.json(); - const revealDisabledBody = await revealDisabled.json(); - const revealEnabledBody = await revealEnabled.json(); + const missingBody = (await missingResponse.json()) as any; + const revealDisabledBody = (await revealDisabled.json()) as any; + const revealEnabledBody = (await revealEnabled.json()) as any; assert.equal(missingResponse.status, 404); assert.equal(missingBody.error, "Key not found"); @@ -417,9 +417,9 @@ test("PATCH /api/keys/[id] updates permissions and rejects invalid payloads", as { params: Promise.resolve({ id: "missing" }) } ); - const patchBody = await patchResponse.json(); - const invalidJsonBody = await invalidJsonResponse.json(); - const missingKeyBody = await missingKeyResponse.json(); + const patchBody = (await patchResponse.json()) as any; + const invalidJsonBody = (await invalidJsonResponse.json()) as any; + const missingKeyBody = (await missingKeyResponse.json()) as any; const updated = await apiKeysDb.getApiKeyById(created.id); assert.equal(patchResponse.status, 200); @@ -453,8 +453,8 @@ test("DELETE /api/keys/[id] removes keys and reports missing resources", async ( { params: Promise.resolve({ id: "missing" }) } ); - const deleteBody = await deleteResponse.json(); - const missingDeleteBody = await missingDeleteResponse.json(); + const deleteBody = (await deleteResponse.json()) as any; + const missingDeleteBody = (await missingDeleteResponse.json()) as any; assert.equal(deleteResponse.status, 200); assert.equal(deleteBody.message, "Key deleted successfully"); diff --git a/tests/integration/api-routes-critical.test.ts b/tests/integration/api-routes-critical.test.ts index a1f3a184e9..4ab8291f7a 100644 --- a/tests/integration/api-routes-critical.test.ts +++ b/tests/integration/api-routes-critical.test.ts @@ -84,7 +84,7 @@ test("critical routes: v1 management proxies covers auth, lookup, where-used, pa }, }) ); - const created = await createResponse.json(); + const created = (await createResponse.json()) as any; await localDb.assignProxyToScope("provider", "openai", created.id); @@ -142,17 +142,17 @@ test("critical routes: v1 management proxies covers auth, lookup, where-used, pa ) ); - const unauthenticatedBody = await unauthenticated.json(); - const invalidTokenBody = await invalidToken.json(); - const getByIdBody = await getById.json(); - const whereUsedBody = await whereUsed.json(); - const missingGetBody = await missingGet.json(); - const invalidJsonPatchBody = await invalidJsonPatch.json(); - const invalidPatchBody = await invalidPatch.json(); - const validPatchBody = await validPatch.json(); - const missingDeleteBody = await missingDelete.json(); - const conflictDeleteBody = await conflictDelete.json(); - const forcedDeleteBody = await forcedDelete.json(); + const unauthenticatedBody = (await unauthenticated.json()) as any; + const invalidTokenBody = (await invalidToken.json()) as any; + const getByIdBody = (await getById.json()) as any; + const whereUsedBody = (await whereUsed.json()) as any; + const missingGetBody = (await missingGet.json()) as any; + const invalidJsonPatchBody = (await invalidJsonPatch.json()) as any; + const invalidPatchBody = (await invalidPatch.json()) as any; + const validPatchBody = (await validPatch.json()) as any; + const missingDeleteBody = (await missingDelete.json()) as any; + const conflictDeleteBody = (await conflictDelete.json()) as any; + const forcedDeleteBody = (await forcedDelete.json()) as any; assert.equal(unauthenticated.status, 401); assert.equal(unauthenticatedBody.error.message, "Authentication required"); @@ -210,7 +210,7 @@ test("critical routes: v1 management proxies validates create payloads and clamp }, }) ); - const created = await createResponse.json(); + const created = (await createResponse.json()) as any; createdIds.push(created.id); assert.equal(createResponse.status, 201); } @@ -232,11 +232,11 @@ test("critical routes: v1 management proxies validates create payloads and clamp }) ); - const invalidJsonPostBody = await invalidJsonPost.json(); - const invalidPostBody = await invalidPost.json(); - const pagedListBody = await pagedList.json(); - const missingPatchBody = await missingPatch.json(); - const missingDeleteBody = await missingDelete.json(); + const invalidJsonPostBody = (await invalidJsonPost.json()) as any; + const invalidPostBody = (await invalidPost.json()) as any; + const pagedListBody = (await pagedList.json()) as any; + const missingPatchBody = (await missingPatch.json()) as any; + const missingDeleteBody = (await missingDelete.json()) as any; assert.equal(invalidJsonPost.status, 400); assert.equal(invalidJsonPostBody.error.message, "Invalid JSON body"); @@ -304,13 +304,13 @@ test("critical routes: v1 management proxies requires auth on mutating routes", ); for (const response of [unauthenticatedPost, unauthenticatedPatch, unauthenticatedDelete]) { - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 401); assert.equal(body.error.message, "Authentication required"); } for (const response of [invalidPost, invalidPatch, invalidDelete]) { - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 403); assert.equal(body.error.message, "Invalid management token"); } @@ -370,14 +370,14 @@ test("critical routes: settings proxy resolves config, validates payloads, and d new Request("http://localhost/api/settings/proxy") ); - const invalidJsonBody = await invalidJson.json(); - const invalidProvidersBody = await invalidProviders.json(); - const setProviderProxyBody = await setProviderProxy.json(); - const getProviderProxyBody = await getProviderProxy.json(); - const resolveProxyBody = await resolveProxy.json(); - const missingLevelDeleteBody = await missingLevelDelete.json(); - const deleteProviderProxyBody = await deleteProviderProxy.json(); - const getFullConfigBody = await getFullConfig.json(); + const invalidJsonBody = (await invalidJson.json()) as any; + const invalidProvidersBody = (await invalidProviders.json()) as any; + const setProviderProxyBody = (await setProviderProxy.json()) as any; + const getProviderProxyBody = (await getProviderProxy.json()) as any; + const resolveProxyBody = (await resolveProxy.json()) as any; + const missingLevelDeleteBody = (await missingLevelDelete.json()) as any; + const deleteProviderProxyBody = (await deleteProviderProxy.json()) as any; + const getFullConfigBody = (await getFullConfig.json()) as any; assert.equal(invalidJson.status, 400); assert.equal(invalidJsonBody.error.message, "Invalid JSON body"); @@ -413,7 +413,7 @@ test("critical routes: settings proxy prefers registry assignment for global loo const response = await settingsProxyRoute.GET( new Request("http://localhost/api/settings/proxy?level=global") ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.level, "global"); @@ -470,10 +470,10 @@ test("critical routes: settings proxy covers global fallback and socks5 gating", }) ); - const setLegacyGlobalProxyBody = await setLegacyGlobalProxy.json(); - const getLegacyGlobalProxyBody = await getLegacyGlobalProxy.json(); - const socksDisabledBody = await socksDisabled.json(); - const socksEnabledBody = await socksEnabled.json(); + const setLegacyGlobalProxyBody = (await setLegacyGlobalProxy.json()) as any; + const getLegacyGlobalProxyBody = (await getLegacyGlobalProxy.json()) as any; + const socksDisabledBody = (await socksDisabled.json()) as any; + const socksEnabledBody = (await socksEnabled.json()) as any; assert.equal(setLegacyGlobalProxy.status, 200); assert.equal(setLegacyGlobalProxyBody.global.host, "legacy.proxy.local"); @@ -490,7 +490,7 @@ test("critical routes: v1 models route exposes CORS and list contracts", async ( const response = await v1ModelsRoute.GET( new Request("http://localhost/api/v1/models", { method: "GET" }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(options.status, 200); assert.match(options.headers.get("Access-Control-Allow-Methods") || "", /GET/); diff --git a/tests/integration/chat-pipeline.test.ts b/tests/integration/chat-pipeline.test.ts index ce6f4641b3..1742fbe62f 100644 --- a/tests/integration/chat-pipeline.test.ts +++ b/tests/integration/chat-pipeline.test.ts @@ -471,7 +471,7 @@ test("chat pipeline handles OpenAI passthrough with valid API key auth", async ( }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(fetchCalls.length, 1); assert.match(fetchCalls[0].url, /\/chat\/completions$/); @@ -504,7 +504,7 @@ test("chat pipeline persists Codex responses cache and reasoning tokens to call }) ); - const json = await response.json(); + const json = (await response.json()) as any; const callLog = await waitFor(() => getLatestCallLog()); assert.equal(response.status, 200); @@ -639,7 +639,7 @@ test("chat pipeline translates OpenAI requests to Claude and returns OpenAI-shap }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(fetchCalls.length, 1); assert.match(fetchCalls[0].url, /\?beta=true$/); @@ -673,7 +673,7 @@ test("chat pipeline translates OpenAI requests to Gemini and returns OpenAI-shap }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(fetchCalls.length, 1); assert.match(fetchCalls[0].url, /generateContent$/); @@ -710,7 +710,7 @@ test("chat pipeline translates Claude-format requests into OpenAI upstream and b }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(fetchCalls.length, 1); assert.match(fetchCalls[0].url, /\/chat\/completions$/); @@ -757,7 +757,7 @@ test("chat pipeline rejects invalid API keys and malformed JSON bodies", async ( }, }) ); - const invalidKeyJson = await invalidKeyResponse.json(); + const invalidKeyJson = (await invalidKeyResponse.json()) as any; const invalidJsonResponse = await handleChat( new Request("http://localhost/v1/chat/completions", { @@ -766,7 +766,7 @@ test("chat pipeline rejects invalid API keys and malformed JSON bodies", async ( body: "{bad-json", }) ); - const invalidJson = await invalidJsonResponse.json(); + const invalidJson = (await invalidJsonResponse.json()) as any; assert.equal(invalidKeyResponse.status, 401); assert.match(invalidKeyJson.error.message, /Invalid API key/i); @@ -786,7 +786,7 @@ test("chat pipeline rejects requests without a bearer key when strict API key mo }, }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 401); assert.match(json.error.message, /Missing API key/i); @@ -801,7 +801,7 @@ test("chat pipeline returns 400 when the model field is omitted", async () => { }, }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 400); assert.match(json.error.message, /Missing model/i); @@ -857,7 +857,7 @@ test("chat pipeline supports local mode without Authorization on explicit combos }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(fetchCalls.length, 1); assert.equal(json.choices[0].message.content, "Local combo route"); @@ -901,7 +901,7 @@ test("chat pipeline returns current no-credentials contract when no provider con }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 400); assert.match(json.error.message, /No credentials for provider: openai/); }); @@ -925,7 +925,7 @@ test("chat pipeline surfaces upstream 500 responses as structured errors", async }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 500); assert.match(json.error.message, /\[500\]: provider exploded/); }); @@ -963,7 +963,7 @@ test("chat pipeline returns 429 with Retry-After when the upstream rate-limits t }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 429); assert.ok(attempts >= 1, "expected at least one upstream attempt"); assert.ok(Number(response.headers.get("Retry-After")) >= 1); @@ -1029,7 +1029,7 @@ test("chat pipeline maps upstream timeouts to 504 responses", async () => { }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 504); assert.match(json.error.message, /\[504\]: upstream timed out/); }); @@ -1068,7 +1068,7 @@ test("chat pipeline injects memory context before sending the upstream request", }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(fetchCalls.length, 1); assert.equal(fetchCalls[0].body.messages[0].role, "system"); @@ -1129,7 +1129,7 @@ test("chat pipeline injects skills into tools and intercepts tool calls with ski }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(fetchCalls.length, 1); assert.ok(Array.isArray(fetchCalls[0].body.tools)); @@ -1176,7 +1176,7 @@ test("chat pipeline falls back to the next account after a provider failure", as }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 200); assert.deepEqual(seenAuthHeaders, [ "Bearer sk-openai-primary-fallback", @@ -1224,7 +1224,7 @@ test("chat pipeline falls back across combo models when the first provider fails }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(attempts.length, 2); assert.match(attempts[0].url, /\/chat\/completions$/); diff --git a/tests/integration/combo-routing-e2e.test.ts b/tests/integration/combo-routing-e2e.test.ts index 66427cb2af..20ea3b88fe 100644 --- a/tests/integration/combo-routing-e2e.test.ts +++ b/tests/integration/combo-routing-e2e.test.ts @@ -64,7 +64,7 @@ test("combo routes requests by exact combo name", async () => { body: buildOpenAIChatBody("router-priority"), }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(fetchCalls.length, 1); @@ -165,7 +165,7 @@ test("priority combo falls back to the secondary model when the first one fails" }; const response = await handleChat(buildRequest({ body: buildOpenAIChatBody("router-fallback") })); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(attempts.length, 2); @@ -227,7 +227,7 @@ test("priority combo can repeat the same provider/model with different fixed acc body: buildOpenAIChatBody("router-fixed-accounts"), }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(authHeaders.length, 2); @@ -297,7 +297,7 @@ test("model combo mappings route explicit model ids through the configured combo body: buildOpenAIChatBody("tenant/mapped-model"), }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(fetchCalls.length, 1); @@ -332,7 +332,7 @@ test("wildcard model combo mappings resolve arbitrary matching models", async () body: buildOpenAIChatBody("tenant/any-model-name"), }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(fetchCalls.length, 1); @@ -347,7 +347,7 @@ test("unmapped custom model requests fail after combo resolution falls through", body: buildOpenAIChatBody("tenant/unmapped-model"), }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 400); assert.match(json.error.message, /No credentials for provider: tenant/); @@ -378,7 +378,7 @@ test("strategy updates take effect for later requests on the same combo name", a body: buildOpenAIChatBody("router-dynamic", "Route dynamic initial"), }) ); - await combosDb.updateCombo(combo.id, { + await combosDb.updateCombo((combo as any).id, { strategy: "round-robin", config: { maxRetries: 0, retryDelayMs: 0 }, }); diff --git a/tests/integration/memory-pipeline.test.ts b/tests/integration/memory-pipeline.test.ts index dc0f0b73d7..998b17c0ec 100644 --- a/tests/integration/memory-pipeline.test.ts +++ b/tests/integration/memory-pipeline.test.ts @@ -37,7 +37,7 @@ function dropFts5Artifacts() { "DROP TRIGGER IF EXISTS memory_fts_au;" + "DROP TABLE IF EXISTS memory_fts;" ); - } catch (_) { + } catch (_: any) { /* ignore if already dropped or DB not yet initialized */ } } diff --git a/tests/integration/performance-regression.test.ts b/tests/integration/performance-regression.test.ts index 4c9c5a7313..6c93076cb0 100644 --- a/tests/integration/performance-regression.test.ts +++ b/tests/integration/performance-regression.test.ts @@ -231,7 +231,7 @@ describe("Performance: memory API route handler (1000 records)", () => { assert.equal(response.status, 200, "Response should be 200 OK"); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(body.data.length, 50, "Should return 50 items"); assert.equal(body.total, MEMORY_COUNT, "Total should be 1000"); assert.ok(body.stats, "Response should include stats"); diff --git a/tests/integration/proxy-registry-flow.test.ts b/tests/integration/proxy-registry-flow.test.ts index 168ebacb42..1a6c71ba35 100644 --- a/tests/integration/proxy-registry-flow.test.ts +++ b/tests/integration/proxy-registry-flow.test.ts @@ -50,7 +50,7 @@ test("integration: proxy registry full flow works and enforces safe delete", asy }) ); assert.equal(createRes.status, 201); - const createdProxy = await createRes.json(); + const createdProxy = (await createRes.json()) as any; assert.ok(createdProxy.id); const assignRes = await proxyAssignmentsRoute.PUT( @@ -72,7 +72,7 @@ test("integration: proxy registry full flow works and enforces safe delete", asy ) ); assert.equal(resolveRes.status, 200); - const resolved = await resolveRes.json(); + const resolved = (await resolveRes.json()) as any; assert.equal(resolved.level, "account"); assert.equal(resolved.source, "registry"); assert.equal(resolved.proxy.host, "flow.local"); @@ -89,7 +89,7 @@ test("integration: proxy registry full flow works and enforces safe delete", asy }) ); assert.equal(bulkRes.status, 200); - const bulkPayload = await bulkRes.json(); + const bulkPayload = (await bulkRes.json()) as any; assert.equal(bulkPayload.updated, 2); assert.equal(bulkPayload.failed.length, 0); @@ -114,7 +114,7 @@ test("integration: proxy registry full flow works and enforces safe delete", asy new Request("http://localhost/api/settings/proxies/health?hours=24") ); assert.equal(healthRes.status, 200); - const healthPayload = await healthRes.json(); + const healthPayload = (await healthRes.json()) as any; const row = healthPayload.items.find((item) => item.proxyId === createdProxy.id); assert.ok(row); assert.equal(row.totalRequests >= 2, true); @@ -126,7 +126,7 @@ test("integration: proxy registry full flow works and enforces safe delete", asy }) ); assert.equal(deleteConflictRes.status, 409); - const deleteConflict = await deleteConflictRes.json(); + const deleteConflict = (await deleteConflictRes.json()) as any; assert.equal(deleteConflict.error.type, "conflict"); assert.equal(typeof deleteConflict.requestId, "string"); assert.equal(deleteConflict.requestId.length > 0, true); @@ -163,6 +163,6 @@ test("integration: proxy registry full flow works and enforces safe delete", asy }) ); assert.equal(deleteOkRes.status, 200); - const deleteOkPayload = await deleteOkRes.json(); + const deleteOkPayload = (await deleteOkRes.json()) as any; assert.equal(deleteOkPayload.success, true); }); diff --git a/tests/integration/resilience-http-e2e.test.ts b/tests/integration/resilience-http-e2e.test.ts index f6ea094ab2..8942d7c32f 100644 --- a/tests/integration/resilience-http-e2e.test.ts +++ b/tests/integration/resilience-http-e2e.test.ts @@ -237,7 +237,7 @@ async function waitForServer( }); if (response.ok) return; lastError = `HTTP ${response.status}`; - } catch (error) { + } catch (error: any) { lastError = error instanceof Error ? error.message : String(error); } await sleep(500); @@ -383,14 +383,14 @@ async function patchResilience(baseUrl: string, config: Record) body: JSON.stringify(config), signal: AbortSignal.timeout(10_000), }); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 200, JSON.stringify(payload)); return payload; } async function getJson(url: string) { const response = await fetch(url, { signal: AbortSignal.timeout(10_000) }); - const json = await response.json(); + const json = (await response.json()) as any; return { response, json }; } diff --git a/tests/integration/skills-pipeline.test.ts b/tests/integration/skills-pipeline.test.ts index 136aa30cd7..f953d62096 100644 --- a/tests/integration/skills-pipeline.test.ts +++ b/tests/integration/skills-pipeline.test.ts @@ -89,7 +89,7 @@ test("skills API lists registered skills", async () => { }); const response = await skillsRouteModule.GET(); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 200); assert.ok(Array.isArray(json.skills)); @@ -170,7 +170,7 @@ test("matching tool calls execute the registered skill and return tool results", }, }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(json.choices[0].finish_reason, "tool_calls"); @@ -202,7 +202,7 @@ test("non-matching responses fall through the pipeline normally", async () => { }, }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(json.choices[0].message.content, "Normal pipeline response"); @@ -275,7 +275,7 @@ test("skill execution errors are returned gracefully in tool results", async () }, }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(json.tool_results[0].tool_call_id, "call_broken"); @@ -350,10 +350,13 @@ test("injectSkills() correctly injects skill context into a request", async () = assert.ok(Array.isArray(tools), "injectSkills should return an array"); assert.equal(tools.length, 1, "should inject exactly one skill tool"); - assert.equal(tools[0].type, "function"); - assert.equal(tools[0].function.name, "translateText@1.0.0"); - assert.equal(tools[0].function.description, "Translate text to another language"); - assert.ok(tools[0].function.parameters, "parameters should be present"); + assert.equal((tools[0] as any).type, "function"); + assert.equal((tools as any)[0].function.name, "translateText@1.0.0"); + (assert as any).equal( + (tools[0] as any).function.description, + "Translate text to another language" + ); + assert.ok((tools[0] as any).function.parameters, "parameters should be present"); }); test("injectSkills() merges with existing tools without duplicating", async () => { @@ -384,7 +387,7 @@ test("injectSkills() merges with existing tools without duplicating", async () = }); assert.equal(tools.length, 2, "should have injected skill + existing tool"); - const names = tools.map((t) => t.function?.name || t.name); + const names = tools.map((t) => (t as any).function?.name || (t as any).name); assert.ok(names.includes("calcRoute@1.0.0")); assert.ok(names.includes("preExistingTool")); }); @@ -599,7 +602,7 @@ test("skills pipeline can be disabled via skillsEnabled flag without crashing", } ), (err) => { - assert.ok(err.message.includes("disabled"), "should mention disabled in error"); + assert.ok((err as any).message.includes("disabled"), "should mention disabled in error"); return true; } ); @@ -830,7 +833,7 @@ test("web_search fallback converts built-in tools for unsupported providers and }, }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(upstreamBodies.length, 1); @@ -896,7 +899,7 @@ test("web_search fallback preserves Responses API output by appending function_c }, }) ); - const json = await response.json(); + const json = (await response.json()) as any; const functionCall = json.output.find((item) => item.type === "function_call"); const functionCallOutput = json.output.find((item) => item.type === "function_call_output"); diff --git a/tests/integration/v1-contracts-behavior.test.ts b/tests/integration/v1-contracts-behavior.test.ts index 39dea2523b..54204e7455 100644 --- a/tests/integration/v1-contracts-behavior.test.ts +++ b/tests/integration/v1-contracts-behavior.test.ts @@ -36,8 +36,8 @@ test("contract: /api/v1 and /api/v1/models return consistent model IDs", async ( assert.equal(v1Response.status, 200); assert.equal(v1ModelsResponse.status, 200); - const v1Body = await v1Response.json(); - const v1ModelsBody = await v1ModelsResponse.json(); + const v1Body = (await v1Response.json()) as any; + const v1ModelsBody = (await v1ModelsResponse.json()) as any; assert.equal(v1Body.object, "list"); assert.equal(v1ModelsBody.object, "list"); @@ -55,7 +55,7 @@ test("contract: /api/v1/models returns OpenAI-compatible model shape", async () const response = await getV1Models(new Request(`${BASE_URL}/api/v1/models`, { method: "GET" })); assert.equal(response.status, 200); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(body.object, "list"); assert.ok(Array.isArray(body.data)); @@ -75,7 +75,7 @@ test("contract: /api/v1/embeddings GET returns embedding model listing shape", a const response = await getEmbeddings(); assert.equal(response.status, 200); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(body.object, "list"); assert.ok(Array.isArray(body.data)); @@ -93,7 +93,7 @@ test("contract: /api/v1/images/generations GET returns image model listing shape const response = await getImageModels(); assert.equal(response.status, 200); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(body.object, "list"); assert.ok(Array.isArray(body.data)); @@ -118,7 +118,7 @@ test("contract: /api/v1/messages/count_tokens returns 400 on invalid JSON", asyn ); assert.equal(response.status, 400); - const body = await response.json(); + const body = (await response.json()) as any; assert.ok(body.error, "error payload should exist"); assert.ok( typeof body.error === "string" || typeof body.error === "object", @@ -138,7 +138,7 @@ test("contract: /api/v1/messages/count_tokens rejects empty messages payload", a ); assert.equal(response.status, 400); - const body = await response.json(); + const body = (await response.json()) as any; assert.ok(body.error, "error payload should exist"); assert.ok( typeof body.error === "string" || typeof body.error === "object", @@ -168,6 +168,6 @@ test("contract: /api/v1/messages/count_tokens computes token estimate from text ); assert.equal(response.status, 200); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(body.input_tokens, 3); }); diff --git a/tests/translator/testFromFile.ts b/tests/translator/testFromFile.ts index cb22f8dfa6..f1053bcdcd 100755 --- a/tests/translator/testFromFile.ts +++ b/tests/translator/testFromFile.ts @@ -139,7 +139,7 @@ console.log(` Stream: ${body.stream || false}`); console.log(`\n\n✅ Received ${chunkCount} chunks`); } else { - const responseData = await response.json(); + const responseData = (await response.json()) as any; console.log("\n📦 Response:"); console.log(JSON.stringify(responseData, null, 2)); } diff --git a/tests/unit/acp-agents-route.test.ts b/tests/unit/acp-agents-route.test.ts index 58f9881644..699cb607bd 100644 --- a/tests/unit/acp-agents-route.test.ts +++ b/tests/unit/acp-agents-route.test.ts @@ -69,7 +69,7 @@ test("GET /api/acp/agents requires authentication when login is enabled", async process.env.INITIAL_PASSWORD = "route-auth-required"; const response = await routeModule.GET(makeRequest("GET")); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 401); assert.equal(body.error, "Unauthorized"); @@ -95,7 +95,7 @@ test("POST /api/acp/agents rejects unsafe version commands for authenticated ses token ) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 400); assert.match(body.error, /Invalid versionCommand/i); diff --git a/tests/unit/admin-audit-events.test.ts b/tests/unit/admin-audit-events.test.ts index 073f6d6bf8..79520eb5be 100644 --- a/tests/unit/admin-audit-events.test.ts +++ b/tests/unit/admin-audit-events.test.ts @@ -139,7 +139,7 @@ test("provider create/update/delete routes emit sanitized credential audit event ); assert.equal(createResponse.status, 201); - const createBody = await createResponse.json(); + const createBody = (await createResponse.json()) as any; const connectionId = createBody.connection.id; assert.equal(typeof connectionId, "string"); @@ -179,23 +179,23 @@ test("provider create/update/delete routes emit sanitized credential audit event assert.equal(createdEvent.resourceType, "provider_credentials"); assert.equal(createdEvent.requestId, "req-provider-create"); assert.equal(createdEvent.target, "openai:Primary OpenAI"); - assert.equal("apiKey" in createdEvent.metadata.connection, false); + assert.equal("apiKey" in (createdEvent.metadata as any).connection, false); const updatedEvent = compliance.getAuditLog({ action: "provider.credentials.updated" })[0]; assert.equal(updatedEvent.requestId, "req-provider-update"); - assert.deepEqual(updatedEvent.metadata.changedFields.sort(), [ + assert.deepEqual((updatedEvent as any).metadata.changedFields.sort(), [ "defaultModel", "isActive", "name", ]); - assert.equal(updatedEvent.metadata.before.name, "Primary OpenAI"); - assert.equal(updatedEvent.metadata.after.name, "Primary OpenAI Updated"); - assert.equal("apiKey" in updatedEvent.metadata.before, false); - assert.equal("apiKey" in updatedEvent.metadata.after, false); + assert.equal((updatedEvent as any).metadata.before.name, "Primary OpenAI"); + (assert as any).equal((updatedEvent.metadata as any).after.name, "Primary OpenAI Updated"); + (assert as any).equal("apiKey" in (updatedEvent.metadata as any).before, false); + (assert as any).equal("apiKey" in (updatedEvent.metadata as any).after, false); const revokedEvent = compliance.getAuditLog({ action: "provider.credentials.revoked" })[0]; assert.equal(revokedEvent.requestId, "req-provider-delete"); assert.equal(revokedEvent.target, "openai:Primary OpenAI Updated"); assert.equal(revokedEvent.status, "success"); - assert.equal("apiKey" in revokedEvent.metadata.connection, false); + assert.equal("apiKey" in (revokedEvent.metadata as any).connection, false); }); diff --git a/tests/unit/api-key-policy.test.ts b/tests/unit/api-key-policy.test.ts index ce62eeda00..9225abd417 100644 --- a/tests/unit/api-key-policy.test.ts +++ b/tests/unit/api-key-policy.test.ts @@ -40,7 +40,7 @@ async function resetStorage() { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } break; - } catch (error) { + } catch (error: any) { if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) { await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); } else { @@ -73,7 +73,7 @@ function makePolicyRequest(apiKey) { } async function readErrorMessage(response) { - const body = await response.json(); + const body = (await response.json()) as any; return body.error.message; } diff --git a/tests/unit/api-key-reveal-route.test.ts b/tests/unit/api-key-reveal-route.test.ts index 0095995dda..437c49a215 100644 --- a/tests/unit/api-key-reveal-route.test.ts +++ b/tests/unit/api-key-reveal-route.test.ts @@ -44,7 +44,7 @@ test("GET /api/keys stays masked even when reveal is enabled", async () => { const created = await apiKeysDb.createApiKey("Primary Key", MACHINE_ID); const response = await listRoute.GET(new Request("http://localhost/api/keys")); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.allowKeyReveal, true); @@ -59,7 +59,7 @@ test("GET /api/keys falls back to default pagination for invalid query params", const response = await listRoute.GET( new Request("http://localhost/api/keys?limit=abc&offset=xyz") ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.allowKeyReveal, false); @@ -92,7 +92,7 @@ test("GET /api/keys returns 500 when key loading fails unexpectedly", async () = try { const response = await listRoute.GET(new Request("http://localhost/api/keys")); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 500); assert.equal(body.error, "Failed to fetch keys"); @@ -114,7 +114,7 @@ test("GET /api/keys/[id]/reveal rejects requests when reveal is disabled", async const response = await revealRoute.GET(request, { params: Promise.resolve({ id: created.id }), }); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 403); assert.equal(body.error, "API key reveal is disabled"); @@ -128,7 +128,7 @@ test("GET /api/keys/[id]/reveal returns the full key when reveal is enabled", as const response = await revealRoute.GET(request, { params: Promise.resolve({ id: created.id }), }); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.key, created.key); @@ -141,7 +141,7 @@ test("GET /api/keys/[id]/reveal returns 404 for unknown keys even when reveal is const response = await revealRoute.GET(request, { params: Promise.resolve({ id: "missing" }), }); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 404); assert.equal(body.error, "Key not found"); @@ -154,7 +154,7 @@ test("GET /api/keys/[id]/reveal returns 500 when params resolution fails", async const response = await revealRoute.GET(request, { params: Promise.reject(new Error("params exploded")), }); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 500); assert.equal(body.error, "Failed to reveal key"); diff --git a/tests/unit/audio-speech-handler.test.ts b/tests/unit/audio-speech-handler.test.ts index 3d6aced845..8dc6fcd809 100644 --- a/tests/unit/audio-speech-handler.test.ts +++ b/tests/unit/audio-speech-handler.test.ts @@ -8,7 +8,7 @@ test("handleAudioSpeech requires model", async () => { body: { input: "hello" }, credentials: { apiKey: "x" }, }); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 400); assert.equal(payload.error.message, "model is required"); @@ -19,7 +19,7 @@ test("handleAudioSpeech requires input text", async () => { body: { model: "openai/tts-1" }, credentials: { apiKey: "x" }, }); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 400); assert.equal(payload.error.message, "input is required"); @@ -121,7 +121,7 @@ test("handleAudioSpeech rejects invalid ElevenLabs voice identifiers", async () }, credentials: { apiKey: "xi-key" }, }); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 400); assert.equal(payload.error.message, "Invalid voice ID"); @@ -216,7 +216,7 @@ test("handleAudioSpeech requires credentials for authenticated providers", async }, credentials: null, }); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 401); assert.equal(payload.error.message, "No credentials for speech provider: openai"); @@ -299,7 +299,7 @@ test("handleAudioSpeech validates HuggingFace model identifiers", async () => { }, credentials: { apiKey: "hf-key" }, }); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 400); assert.equal(payload.error.message, "Invalid model ID"); @@ -455,7 +455,7 @@ test("handleAudioSpeech surfaces parsed upstream error messages", async () => { }, credentials: { apiKey: "openai-key" }, }); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 429); assert.equal(payload.error.message, "quota exceeded"); @@ -479,7 +479,7 @@ test("handleAudioSpeech returns a 500 when the provider request throws", async ( }, credentials: { apiKey: "openai-key" }, }); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 500); assert.equal(payload.error.message, "Speech request failed: socket hang up"); diff --git a/tests/unit/audio-transcription-handler.test.ts b/tests/unit/audio-transcription-handler.test.ts index 441c974805..5ce2cbd59b 100644 --- a/tests/unit/audio-transcription-handler.test.ts +++ b/tests/unit/audio-transcription-handler.test.ts @@ -17,7 +17,7 @@ test("handleAudioTranscription requires model", async () => { formData.append("file", buildFile("abc", "audio.wav", "audio/wav")); const response = await handleAudioTranscription({ formData, credentials: { apiKey: "x" } }); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 400); assert.equal(payload.error.message, "model is required"); @@ -28,7 +28,7 @@ test("handleAudioTranscription requires a file upload", async () => { formData.append("model", "openai/whisper-1"); const response = await handleAudioTranscription({ formData, credentials: { apiKey: "x" } }); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 400); assert.equal(payload.error.message, "file is required"); @@ -119,7 +119,7 @@ test("handleAudioTranscription routes Deepgram with binary upload and language p formData, credentials: { apiKey: "dg-key" }, }); - const payload = await response.json(); + const payload = (await response.json()) as any; const url = new URL(capturedUrl); assert.equal(url.origin + url.pathname, "https://api.deepgram.com/v1/listen"); @@ -213,7 +213,7 @@ test("handleAudioTranscription rejects invalid HuggingFace model paths", async ( formData, credentials: { apiKey: "hf-key" }, }); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 400); assert.equal(payload.error.message, "Invalid model ID"); @@ -225,7 +225,7 @@ test("handleAudioTranscription requires credentials for authenticated providers" formData.append("file", buildFile("abc", "clip.wav", "audio/wav")); const response = await handleAudioTranscription({ formData, credentials: null }); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 401); assert.equal(payload.error.message, "No credentials for transcription provider: openai"); @@ -338,7 +338,7 @@ test("handleAudioTranscription returns an error when AssemblyAI reports a termin formData, credentials: { apiKey: "assembly-key" }, }); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 500); assert.equal(payload.error.message, "corrupt audio payload"); @@ -397,7 +397,7 @@ test("handleAudioTranscription rejects unsupported providers", async () => { formData, credentials: { apiKey: "x" }, }); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 400); assert.match( @@ -424,7 +424,7 @@ test("handleAudioTranscription surfaces parsed upstream errors for OpenAI-compat formData, credentials: { apiKey: "openai-key" }, }); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 429); assert.equal(payload.error.message, "too many requests"); @@ -449,7 +449,7 @@ test("handleAudioTranscription returns a 500 when upstream fetch throws", async formData, credentials: { apiKey: "openai-key" }, }); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 500); assert.equal(payload.error.message, "Transcription request failed: network timeout"); diff --git a/tests/unit/auth-clear-account-error.test.ts b/tests/unit/auth-clear-account-error.test.ts index 129d16ddbb..1131ae6370 100644 --- a/tests/unit/auth-clear-account-error.test.ts +++ b/tests/unit/auth-clear-account-error.test.ts @@ -45,9 +45,9 @@ test("clearAccountError clears stale provider error metadata after recovery", as assert.equal(credentials.errorCode, "refresh_failed"); assert.equal(credentials.lastErrorType, "token_refresh_failed"); assert.equal(credentials.lastErrorSource, "oauth"); - await auth.clearAccountError(created.id, credentials); + await auth.clearAccountError((created as any).id, credentials); - const updated = await providersDb.getProviderConnectionById(created.id); + const updated = await providersDb.getProviderConnectionById((created as any).id); assert.equal(updated.testStatus, "active"); assert.equal(updated.lastError, undefined); assert.equal(updated.lastErrorType, undefined); @@ -68,7 +68,7 @@ test("clearAccountError is a no-op when the connection is already clean", async testStatus: "active", }); - await auth.clearAccountError(created.id, { + await auth.clearAccountError((created as any).id, { connectionId: created.id, testStatus: "active", lastError: null, @@ -78,7 +78,7 @@ test("clearAccountError is a no-op when the connection is already clean", async lastErrorSource: null, }); - const updated = await providersDb.getProviderConnectionById(created.id); + const updated = await providersDb.getProviderConnectionById((created as any).id); assert.equal(updated.testStatus, "active"); assert.equal(updated.backoffLevel, 0); assert.equal(updated.lastError, undefined); @@ -114,7 +114,7 @@ test("clearRecoveredProviderState ignores empty payloads and clears recoverable rateLimitedUntil: new Date(Date.now() + 60_000).toISOString(), }); - const updated = await providersDb.getProviderConnectionById(created.id); + const updated = await providersDb.getProviderConnectionById((created as any).id); assert.equal(updated.testStatus, "active"); assert.equal(updated.lastError, undefined); assert.equal(updated.lastErrorType, undefined); diff --git a/tests/unit/auth-login-route.test.ts b/tests/unit/auth-login-route.test.ts index b7aecb1f75..f6402a2047 100644 --- a/tests/unit/auth-login-route.test.ts +++ b/tests/unit/auth-login-route.test.ts @@ -82,7 +82,10 @@ test("auth login route lazily migrates INITIAL_PASSWORD to a persisted hash befo assert.equal(setCalls.length, 1); assert.equal(managementPassword.isBcryptHash(settings.password), true); assert.equal( - await managementPassword.verifyManagementPassword("bootstrap-secret", settings.password), + await managementPassword.verifyManagementPassword( + "bootstrap-secret", + (settings as any).password + ), true ); }); diff --git a/tests/unit/auth-terminal-status.test.ts b/tests/unit/auth-terminal-status.test.ts index e3cad8eea0..66069b8256 100644 --- a/tests/unit/auth-terminal-status.test.ts +++ b/tests/unit/auth-terminal-status.test.ts @@ -96,7 +96,7 @@ test("markAccountUnavailable does not overwrite terminal status", async () => { }); const result = await auth.markAccountUnavailable( - conn.id, + (conn as any).id, 503, "temporary upstream error", "openai", @@ -106,7 +106,7 @@ test("markAccountUnavailable does not overwrite terminal status", async () => { assert.equal(result.shouldFallback, true); assert.equal(result.cooldownMs, 0); - const after = await providersDb.getProviderConnectionById(conn.id); + const after = await providersDb.getProviderConnectionById((conn as any).id); assert.equal(after.testStatus, "credits_exhausted"); }); @@ -122,13 +122,13 @@ test("markAccountUnavailable marks 401 connections as expired without adding coo }); const result = await auth.markAccountUnavailable( - conn.id, + (conn as any).id, 401, "unauthorized", "openai", "gpt-4.1" ); - const after = await providersDb.getProviderConnectionById(conn.id); + const after = await providersDb.getProviderConnectionById((conn as any).id); assert.equal(result.shouldFallback, true); assert.equal(result.cooldownMs, 0); @@ -148,13 +148,13 @@ test("markAccountUnavailable marks 402 connections as credits_exhausted without }); const result = await auth.markAccountUnavailable( - conn.id, + (conn as any).id, 402, "payment required", "openai", "gpt-4.1" ); - const after = await providersDb.getProviderConnectionById(conn.id); + const after = await providersDb.getProviderConnectionById((conn as any).id); assert.equal(result.shouldFallback, true); assert.equal(result.cooldownMs, 0); @@ -173,8 +173,14 @@ test("markAccountUnavailable marks 403 connections as banned without adding cool testStatus: "active", }); - const result = await auth.markAccountUnavailable(conn.id, 403, "forbidden", "openai", "gpt-4.1"); - const after = await providersDb.getProviderConnectionById(conn.id); + const result = await auth.markAccountUnavailable( + (conn as any).id, + 403, + "forbidden", + "openai", + "gpt-4.1" + ); + const after = await providersDb.getProviderConnectionById((conn as any).id); assert.equal(result.shouldFallback, true); assert.equal(result.cooldownMs, 0); @@ -194,13 +200,13 @@ test("markAccountUnavailable keeps project-route 403 errors non-terminal", async }); const result = await auth.markAccountUnavailable( - conn.id, + (conn as any).id, 403, "The service has not been used in project", "openai", "gpt-4.1" ); - const after = await providersDb.getProviderConnectionById(conn.id); + const after = await providersDb.getProviderConnectionById((conn as any).id); assert.equal(result.shouldFallback, true); assert.equal(result.cooldownMs, 0); @@ -221,13 +227,13 @@ test("markAccountUnavailable keeps oauth-invalid 401 errors non-terminal", async }); const result = await auth.markAccountUnavailable( - conn.id, + (conn as any).id, 401, "Invalid authentication credentials provided", "openai", "gpt-4.1" ); - const after = await providersDb.getProviderConnectionById(conn.id); + const after = await providersDb.getProviderConnectionById((conn as any).id); assert.equal(result.shouldFallback, true); assert.equal(result.cooldownMs, 0); diff --git a/tests/unit/batch_api.test.ts b/tests/unit/batch_api.test.ts index bff2c3075e..808dacc5cb 100644 --- a/tests/unit/batch_api.test.ts +++ b/tests/unit/batch_api.test.ts @@ -22,8 +22,10 @@ const { getTerminalBatches, } = await import("../../src/lib/localDb.ts"); const { getDbInstance } = await import("../../src/lib/db/core.ts"); -const { initBatchProcessor, stopBatchProcessor } = +const { initBatchProcessor, stopBatchProcessor, processPendingBatches } = await import("../../open-sse/services/batchProcessor.ts"); +const batchesRoute = await import("../../src/app/api/v1/batches/route.ts"); +const filesRoute = await import("../../src/app/api/v1/files/route.ts"); test("Batch API and Processing", async () => { // 0. Setup environment, mock provider and API key @@ -255,6 +257,128 @@ test("Batch handles and counts failures correctly", async () => { } }); +test("Batch dispatches non-chat endpoints through the matching route handler", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ + object: "list", + data: [{ object: "embedding", embedding: [0.1, 0.2], index: 0 }], + usage: { prompt_tokens: 2, total_tokens: 2 }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + + initBatchProcessor(); + try { + await createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "Mock OpenAI Embeddings", + apiKey: "sk-mock-embeddings-key", + isActive: true, + }); + + const batchItems = [ + JSON.stringify({ + custom_id: "embed-request", + method: "POST", + url: "/v1/embeddings", + body: { + model: "openai/text-embedding-3-small", + input: "Hello embeddings", + }, + }), + ].join("\n"); + + const file = createFile({ + bytes: Buffer.byteLength(batchItems), + filename: "embeddings_batch.jsonl", + purpose: "batch", + content: Buffer.from(batchItems), + apiKeyId: null, + }); + + const batch = createBatch({ + endpoint: "/v1/embeddings", + completionWindow: "24h", + inputFileId: file.id, + apiKeyId: null, + }); + + let maxAttempts = 20; + let currentBatch = getBatch(batch.id); + while ( + maxAttempts > 0 && + currentBatch?.status !== "completed" && + currentBatch?.status !== "failed" + ) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + currentBatch = getBatch(batch.id); + maxAttempts--; + } + + assert.strictEqual(currentBatch?.status, "completed", "embedding batch should complete"); + assert.strictEqual(currentBatch?.requestCountsCompleted, 1); + assert.ok(currentBatch?.outputFileId, "embedding batch should produce an output file"); + + const outputContent = getFileContent(currentBatch.outputFileId!); + const result = JSON.parse(outputContent.toString()); + assert.strictEqual(result.response.status_code, 200); + assert.ok(Array.isArray(result.response.body.data)); + assert.strictEqual(result.response.body.object, "list"); + } finally { + stopBatchProcessor(); + globalThis.fetch = originalFetch; + } +}); + +test("Batch rejects input lines whose url does not match the batch endpoint", async () => { + initBatchProcessor(); + try { + const batchItems = [ + JSON.stringify({ + custom_id: "wrong-endpoint", + method: "POST", + url: "/v1/embeddings", + body: { + model: "openai/text-embedding-3-small", + input: "Wrong endpoint", + }, + }), + ].join("\n"); + + const file = createFile({ + bytes: Buffer.byteLength(batchItems), + filename: "wrong_endpoint_batch.jsonl", + purpose: "batch", + content: Buffer.from(batchItems), + apiKeyId: null, + }); + + const batch = createBatch({ + endpoint: "/v1/chat/completions", + completionWindow: "24h", + inputFileId: file.id, + apiKeyId: null, + }); + + await processPendingBatches(); + + const currentBatch = getBatch(batch.id); + assert.strictEqual(currentBatch?.status, "failed"); + assert.match( + String(currentBatch?.errors?.[0]?.message || ""), + /does not match batch endpoint/i + ); + } finally { + stopBatchProcessor(); + } +}); + test("Batch forces stream: false for all requests", async () => { initBatchProcessor(); try { @@ -424,7 +548,8 @@ test("List batches pagination and response format", async () => { apiKeyId: apiKey.id, }); - const batchIds: string[] = []; + const batchOrder: Array<{ createdAt: number; id: string }> = []; + const baseCreatedAt = Math.floor(Date.now() / 1000) - 10_000; for (let i = 0; i < 5; i++) { const b = createBatch({ endpoint: "/v1/chat/completions", @@ -433,11 +558,12 @@ test("List batches pagination and response format", async () => { apiKeyId: apiKey.id, metadata: { index: i }, }); - batchIds.push(b.id); + updateBatch(b.id, { createdAt: baseCreatedAt + i }); + batchOrder.push({ createdAt: baseCreatedAt + i, id: b.id }); } - // Sort batchIds in descending order as listBatches returns them by ID DESC - batchIds.sort().reverse(); + batchOrder.sort((a, b) => b.createdAt - a.createdAt || b.id.localeCompare(a.id)); + const batchIds = batchOrder.map((entry) => entry.id); // 2. Test listBatches logic (direct DB call) const { listBatches } = await import("../../src/lib/localDb"); @@ -476,6 +602,92 @@ test("List batches pagination and response format", async () => { assert.strictEqual(data3[0].id, batchIds[4]); }); +test("Batch cleanup honors output_expires_after for output artifacts", async () => { + const apiKey = await createApiKey("Batch Retention Key", "test-machine"); + const now = Math.floor(Date.now() / 1000); + + const inputFile = createFile({ + bytes: 10, + filename: "retention_input.jsonl", + purpose: "batch", + content: Buffer.from("{}"), + apiKeyId: apiKey.id, + }); + const outputFile = createFile({ + bytes: 10, + filename: "retention_output.jsonl", + purpose: "batch_output", + content: Buffer.from("{}"), + apiKeyId: apiKey.id, + status: "completed", + }); + const errorFile = createFile({ + bytes: 10, + filename: "retention_error.jsonl", + purpose: "batch_output", + content: Buffer.from("{}"), + apiKeyId: apiKey.id, + status: "completed", + }); + + const batch = createBatch({ + endpoint: "/v1/chat/completions", + completionWindow: "24h", + inputFileId: inputFile.id, + apiKeyId: apiKey.id, + outputExpiresAfterSeconds: 3600, + outputExpiresAfterAnchor: "created_at", + }); + + updateBatch(batch.id, { + status: "completed", + createdAt: now - 3700, + completedAt: now - 30, + outputFileId: outputFile.id, + errorFileId: errorFile.id, + }); + + await processPendingBatches(); + + assert.ok(getFile(inputFile.id), "input file should still follow completion_window retention"); + assert.equal(getFile(outputFile.id), null); + assert.equal(getFile(errorFile.id), null); +}); + +test("Batch list route rejects missing API key when REQUIRE_API_KEY is enabled", async () => { + const previous = process.env.REQUIRE_API_KEY; + process.env.REQUIRE_API_KEY = "true"; + + try { + const response = await batchesRoute.GET(new Request("http://localhost/api/v1/batches")); + const json = await response.json(); + + assert.strictEqual(response.status, 401); + assert.strictEqual(json.error.message, "Missing API key"); + } finally { + process.env.REQUIRE_API_KEY = previous ?? "false"; + } +}); + +test("Files list route rejects invalid API key when REQUIRE_API_KEY is enabled", async () => { + const previous = process.env.REQUIRE_API_KEY; + process.env.REQUIRE_API_KEY = "true"; + + try { + const response = await filesRoute.GET( + new Request("http://localhost/api/v1/files", { + headers: { Authorization: "Bearer invalid-test-key" }, + }) + ); + const json = await response.json(); + + assert.strictEqual(response.status, 401); + assert.strictEqual(json.error.message, "Invalid API key"); + } finally { + process.env.REQUIRE_API_KEY = previous ?? "false"; + } +}); + test("Batch Cancel API", async () => { const apiKey = await createApiKey("Cancel Test Key", "test-machine"); @@ -704,62 +916,66 @@ test("Retrieve file content spec compliance", async () => { }); test("Batch dispatches to embeddings handler for /v1/embeddings URL", async () => { - initBatchProcessor(); - try { - const batchItems = [ - JSON.stringify({ - custom_id: "embed-request-1", - method: "POST", - url: "/v1/embeddings", - body: { model: "mistral/mistral-embed", input: "The food was delicious." } - }) - ].join("\n"); + initBatchProcessor(); + try { + const batchItems = [ + JSON.stringify({ + custom_id: "embed-request-1", + method: "POST", + url: "/v1/embeddings", + body: { model: "mistral/mistral-embed", input: "The food was delicious." }, + }), + ].join("\n"); - const file = createFile({ - bytes: Buffer.byteLength(batchItems), - filename: "embed_batch.jsonl", - purpose: "batch", - content: Buffer.from(batchItems), - apiKeyId: null - }); + const file = createFile({ + bytes: Buffer.byteLength(batchItems), + filename: "embed_batch.jsonl", + purpose: "batch", + content: Buffer.from(batchItems), + apiKeyId: null, + }); - const batch = createBatch({ - endpoint: "/v1/embeddings", - completionWindow: "24h", - inputFileId: file.id, - apiKeyId: null - }); + const batch = createBatch({ + endpoint: "/v1/embeddings", + completionWindow: "24h", + inputFileId: file.id, + apiKeyId: null, + }); - let maxAttempts = 20; - let currentBatch = getBatch(batch.id); - while (maxAttempts > 0 && currentBatch?.status !== "completed" && currentBatch?.status !== "failed") { - await new Promise(resolve => setTimeout(resolve, 1000)); - currentBatch = getBatch(batch.id); - maxAttempts--; - } - - assert.ok( - currentBatch?.status === "completed" || currentBatch?.status === "failed", - "Batch should reach a terminal state" - ); - assert.strictEqual(currentBatch?.requestCountsTotal, 1); - - // Verify the batch item was dispatched to the embeddings handler, not the chat handler. - // The chat handler would return errors about missing "messages", "Missing model", etc. - // The embeddings handler returns errors about missing credentials or invalid embedding models. - const outputFileId = currentBatch?.outputFileId || currentBatch?.errorFileId; - assert.ok(outputFileId, "Should have an output or error file"); - const outputContent = getFileContent(outputFileId!); - assert.ok(outputContent, "Output file should have content"); - const result = JSON.parse(outputContent.toString()); - const errorMsg = result.response?.body?.error?.message || ""; - assert.ok( - !errorMsg.includes("messages") && !errorMsg.includes("Missing model"), - `Error should not be a chat-specific error. Got: ${errorMsg}` - ); - } finally { - stopBatchProcessor(); + let maxAttempts = 20; + let currentBatch = getBatch(batch.id); + while ( + maxAttempts > 0 && + currentBatch?.status !== "completed" && + currentBatch?.status !== "failed" + ) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + currentBatch = getBatch(batch.id); + maxAttempts--; } + + assert.ok( + currentBatch?.status === "completed" || currentBatch?.status === "failed", + "Batch should reach a terminal state" + ); + assert.strictEqual(currentBatch?.requestCountsTotal, 1); + + // Verify the batch item was dispatched to the embeddings handler, not the chat handler. + // The chat handler would return errors about missing "messages", "Missing model", etc. + // The embeddings handler returns errors about missing credentials or invalid embedding models. + const outputFileId = currentBatch?.outputFileId || currentBatch?.errorFileId; + assert.ok(outputFileId, "Should have an output or error file"); + const outputContent = getFileContent(outputFileId!); + assert.ok(outputContent, "Output file should have content"); + const result = JSON.parse(outputContent.toString()); + const errorMsg = result.response?.body?.error?.message || ""; + assert.ok( + !errorMsg.includes("messages") && !errorMsg.includes("Missing model"), + `Error should not be a chat-specific error. Got: ${errorMsg}` + ); + } finally { + stopBatchProcessor(); + } }); test("getTerminalBatches returns only terminal statuses ordered oldest first", async () => { diff --git a/tests/unit/build-next-isolated.test.ts b/tests/unit/build-next-isolated.test.ts index ef372eb8c6..64f22ede3e 100644 --- a/tests/unit/build-next-isolated.test.ts +++ b/tests/unit/build-next-isolated.test.ts @@ -85,7 +85,7 @@ test("movePath rethrows non-EXDEV rename failures", async () => { throw new Error("remove fallback should not run"); }, }), - (error) => error?.code === "EACCES" + (error) => (error as any).code === "EACCES" ); }); }); diff --git a/tests/unit/bypass-handler.test.ts b/tests/unit/bypass-handler.test.ts index 89697d8ef9..28eeeb7394 100644 --- a/tests/unit/bypass-handler.test.ts +++ b/tests/unit/bypass-handler.test.ts @@ -41,7 +41,7 @@ test("handleBypassRequest returns a canned JSON response for warmup bypasses", a assert.equal(result.success, true); assert.equal(result.response.headers.get("content-type"), "application/json"); - const payload = await result.response.json(); + const payload = (await result.response.json()) as any; assert.equal(payload.model, "gpt-5-mini"); assert.equal(payload.choices[0].message.role, "assistant"); assert.match(payload.choices[0].message.content, /clear terminal/i); @@ -79,7 +79,7 @@ test("handleBypassRequest bypasses single-message count probes", async () => { ); assert.ok(result); - const payload = await result.response.json(); + const payload = (await result.response.json()) as any; assert.equal(payload.usage.total_tokens, 2); assert.equal(payload.choices[0].finish_reason, "stop"); }); diff --git a/tests/unit/call-log-cap.test.ts b/tests/unit/call-log-cap.test.ts index 3de6810d46..7c093a2a56 100644 --- a/tests/unit/call-log-cap.test.ts +++ b/tests/unit/call-log-cap.test.ts @@ -117,9 +117,9 @@ test("saveCallLog stores only summary metadata in SQLite and writes detailed art assert.equal(detail?.comboStepId, "step-openai-a"); assert.equal(detail?.comboExecutionKey, "combo-a:0:step-openai-a"); assert.equal(detail?.pipelinePayloads?.clientRawRequest?.body?.raw, true); - assert.equal(detail?.pipelinePayloads?.providerRequest?.body?.translated, true); - assert.equal(detail?.pipelinePayloads?.providerResponse?.body?.upstream, true); - assert.equal(detail?.pipelinePayloads?.clientResponse?.body?.final, true); + assert.equal((detail?.pipelinePayloads?.providerRequest as any).body?.translated, true); + assert.equal((detail?.pipelinePayloads as any).providerResponse?.body?.upstream, true); + assert.equal((detail?.pipelinePayloads as any).clientResponse?.body?.final, true); assert.match( detail?.artifactRelPath || "", /^2026-03-30\/2026-03-30T12-34-56\.789Z_req_artifact_1\.json$/ @@ -129,7 +129,7 @@ test("saveCallLog stores only summary metadata in SQLite and writes detailed art const columns = db .prepare("SELECT name FROM pragma_table_info('call_logs') ORDER BY cid") .all() - .map((row) => row.name); + .map((row) => (row as any).name); assert.equal(columns.includes("request_body"), false); assert.equal(columns.includes("response_body"), false); assert.equal(columns.includes("error"), false); @@ -142,12 +142,12 @@ test("saveCallLog stores only summary metadata in SQLite and writes detailed art ` ) .get(logId); - assert.equal(summaryRow.detail_state, "ready"); - assert.equal(summaryRow.cache_source, "semantic"); - assert.equal(summaryRow.has_request_body, 1); - assert.equal(summaryRow.has_response_body, 1); - assert.equal(summaryRow.has_pipeline_details, 1); - assert.equal(typeof summaryRow.artifact_relpath, "string"); + (assert as any).equal((summaryRow as any).detail_state, "ready"); + assert.equal((summaryRow as any).cache_source, "semantic"); + assert.equal((summaryRow as any).has_request_body, 1); + assert.equal((summaryRow as any).has_response_body, 1); + assert.equal((summaryRow as any).has_pipeline_details, 1); + assert.equal(typeof (summaryRow as any).artifact_relpath, "string"); const artifactPath = path.join(TEST_DATA_DIR, "call_logs", detail.artifactRelPath); const artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8")); @@ -192,12 +192,14 @@ test("rotateCallLogs removes expired rows and orphaned artifacts but keeps fresh .getDbInstance() .prepare("SELECT artifact_relpath FROM call_logs WHERE id = ?") .get("fresh-log"); - const freshAbsPath = path.join(TEST_DATA_DIR, "call_logs", freshRow.artifact_relpath); + const freshAbsPath = path.join(TEST_DATA_DIR, "call_logs", (freshRow as any).artifact_relpath); assert.equal( - core - .getDbInstance() - .prepare("SELECT COUNT(*) AS cnt FROM call_logs WHERE id = ?") - .get("expired-log").cnt, + ( + core + .getDbInstance() + .prepare("SELECT COUNT(*) AS cnt FROM call_logs WHERE id = ?") + .get("expired-log") as any + ).cnt, 0 ); assert.equal(fs.existsSync(oldAbsPath), false); @@ -207,7 +209,7 @@ test("rotateCallLogs removes expired rows and orphaned artifacts but keeps fresh const db = core.getDbInstance(); assert.equal( - db.prepare("SELECT COUNT(*) AS cnt FROM call_logs WHERE id = ?").get("fresh-log").cnt, + (db.prepare("SELECT COUNT(*) AS cnt FROM call_logs WHERE id = ?").get("fresh-log") as any).cnt, 1 ); assert.equal(fs.existsSync(freshAbsPath), true); @@ -365,9 +367,18 @@ test("getCallLogById falls back to legacy inline rows and request_detail_logs", assert.deepEqual(detail?.responseBody, { recovered: "response" }); assert.deepEqual(detail?.error, { message: "legacy-error" }); assert.equal(detail?.pipelinePayloads?.clientRequest?.body?.from, "detail-client"); - assert.equal(detail?.pipelinePayloads?.providerRequest?.body?.from, "detail-provider-request"); - assert.equal(detail?.pipelinePayloads?.providerResponse?.body?.from, "detail-provider-response"); - assert.equal(detail?.pipelinePayloads?.clientResponse?.body?.from, "detail-client-response"); + assert.equal( + (detail?.pipelinePayloads?.providerRequest as any).body?.from, + "detail-provider-request" + ); + (assert as any).equal( + (detail?.pipelinePayloads?.providerResponse as any).body?.from, + "detail-provider-response" + ); + assert.equal( + (detail?.pipelinePayloads?.clientResponse as any).body?.from, + "detail-client-response" + ); assert.equal(detail?.hasPipelineDetails, true); }); @@ -394,8 +405,8 @@ test("getCallLogById marks missing artifacts explicitly and clears stale DB poin const row = db .prepare("SELECT artifact_relpath, detail_state FROM call_logs WHERE id = ?") .get("missing-artifact"); - assert.equal(row.artifact_relpath, null); - assert.equal(row.detail_state, "missing"); + assert.equal((row as any).artifact_relpath, null); + assert.equal((row as any).detail_state, "missing"); }); test("saveCallLog keeps large payloads out of SQLite while preserving explicit detail export", async () => { @@ -424,12 +435,12 @@ test("saveCallLog keeps large payloads out of SQLite while preserving explicit d ` ) .get("artifact-only-large-payload"); - assert.equal(row.detail_state, "ready"); - assert.equal(row.has_request_body, 1); - assert.equal(typeof row.artifact_relpath, "string"); - assert.equal(row.error_summary, "upstream unavailable"); + assert.equal((row as any).detail_state, "ready"); + assert.equal((row as any).has_request_body, 1); + (assert as any).equal(typeof (row as any).artifact_relpath, "string"); + assert.equal((row as any).error_summary, "upstream unavailable"); - const artifactPath = path.join(TEST_DATA_DIR, "call_logs", row.artifact_relpath); + const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as any).artifact_relpath); const artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8")); assert.equal(artifact.requestBody.payload.length, requestBody.payload.length); @@ -438,7 +449,7 @@ test("saveCallLog keeps large payloads out of SQLite while preserving explicit d const exported = await callLogs.exportCallLogsSince("2026-03-31T00:00:00.000Z"); assert.equal(exported.length, 1); - assert.equal(exported[0].requestBody.payload.length, requestBody.payload.length); + assert.equal((exported[0] as any).requestBody.payload.length, requestBody.payload.length); }); test("saveCallLog logs and returns when sqlite persistence throws unexpectedly", async () => { diff --git a/tests/unit/call-log-file-rotation.test.ts b/tests/unit/call-log-file-rotation.test.ts index 8530f595cb..d6f2e3ade1 100644 --- a/tests/unit/call-log-file-rotation.test.ts +++ b/tests/unit/call-log-file-rotation.test.ts @@ -33,7 +33,7 @@ async function resetTestDataDir() { const db = core.getDbInstance(); db.prepare("DELETE FROM call_logs").run(); return; - } catch (error) { + } catch (error: any) { lastError = error; await new Promise((resolve) => setTimeout(resolve, 25)); } @@ -176,7 +176,7 @@ test("call log file rotation honors both retention days and file count", () => { const db = core.getDbInstance(); assert.equal( - db.prepare("SELECT COUNT(*) AS cnt FROM call_logs WHERE id = ?").get("old-log").cnt, + (db.prepare("SELECT COUNT(*) AS cnt FROM call_logs WHERE id = ?").get("old-log") as any).cnt, 0 ); assert.equal(fs.existsSync(path.join(CALL_LOGS_DIR, oldRelPath)), false); @@ -184,8 +184,8 @@ test("call log file rotation honors both retention days and file count", () => { const keepARow = db .prepare("SELECT detail_state, artifact_relpath FROM call_logs WHERE id = ?") .get("keep-a"); - assert.equal(keepARow.detail_state, "missing"); - assert.equal(keepARow.artifact_relpath, null); + assert.equal((keepARow as any).detail_state, "missing"); + (assert as any).equal((keepARow as any).artifact_relpath, null); assert.equal(fs.existsSync(path.join(CALL_LOGS_DIR, keepARelPath)), false); assert.equal(fs.existsSync(path.join(CALL_LOGS_DIR, keepBRelPath)), true); diff --git a/tests/unit/call-log-startup.test.ts b/tests/unit/call-log-startup.test.ts index b22d19c10b..9d556f7a87 100644 --- a/tests/unit/call-log-startup.test.ts +++ b/tests/unit/call-log-startup.test.ts @@ -15,7 +15,7 @@ async function removeTestDataDir() { core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); return; - } catch (error) { + } catch (error: any) { lastError = error; await new Promise((resolve) => setTimeout(resolve, 25)); } diff --git a/tests/unit/cc-compatible-model-catalog.test.ts b/tests/unit/cc-compatible-model-catalog.test.ts index d227e17d62..8453abed62 100644 --- a/tests/unit/cc-compatible-model-catalog.test.ts +++ b/tests/unit/cc-compatible-model-catalog.test.ts @@ -55,14 +55,14 @@ test("v1 models exposes CC-compatible fallback models under the provider node pr ); assert.equal(response.status, 200); - const body = await response.json(); + const body = (await response.json()) as any; const ids = new Set(body.data.map((item) => item.id)); assert.ok(ids.has("cm/claude-opus-4-7")); assert.ok(ids.has("cm/claude-opus-4-6")); assert.ok(ids.has("cm/claude-sonnet-4-6")); assert.equal( - [...ids].some((id) => id.startsWith("anthropic-compatible-cc-cm/")), + [...ids].some((id) => (id as any).startsWith("anthropic-compatible-cc-cm/")), false ); }); diff --git a/tests/unit/cc-compatible-provider.test.ts b/tests/unit/cc-compatible-provider.test.ts index c9be1396f5..af9b47d59a 100644 --- a/tests/unit/cc-compatible-provider.test.ts +++ b/tests/unit/cc-compatible-provider.test.ts @@ -117,15 +117,15 @@ test("buildClaudeCodeCompatibleRequest keeps prior role history while dropping t { role: "user", text: "u2" }, ] ); - assert.equal(payload.messages[0].content.at(-1).cache_control, undefined); - assert.equal(payload.messages[1].content.at(-1).cache_control, undefined); - assert.equal(payload.messages[2].content.at(-1).cache_control, undefined); + assert.equal((payload.messages[0].content.at(-1) as any).cache_control, undefined); + assert.equal((payload.messages[1] as any).content.at(-1).cache_control, undefined); + assert.equal((payload.messages as any)[2].content.at(-1).cache_control, undefined); assert.equal(payload.system.length, 2); - assert.match(payload.system[0].text, /Claude Agent SDK/); - assert.equal(payload.system[0].cache_control, undefined); - assert.equal(payload.system[1].cache_control, undefined); + assert.match((payload as any).system[0].text, /Claude Agent SDK/); + (assert as any).equal((payload.system[0] as any).cache_control, undefined); + assert.equal((payload.system[1] as any).cache_control, undefined); assert.equal(payload.system[1].text, "sys"); - assert.equal(payload.system[1].cache_control, undefined); + assert.equal((payload.system[1] as any).cache_control, undefined); assert.equal(payload.tools.length, 1); assert.deepEqual(payload.tools[0], { name: "lookup_weather", @@ -139,8 +139,8 @@ test("buildClaudeCodeCompatibleRequest keeps prior role history while dropping t }, }); assert.deepEqual(payload.tool_choice, { type: "any" }); - assert.equal(payload.context_management, undefined); - assert.equal(JSON.parse(payload.metadata.user_id).session_id, "session-1"); + assert.equal(payload.context_management, undefined as any); + assert.equal(JSON.parse((payload as any).metadata.user_id).session_id, "session-1"); }); test("buildClaudeCodeCompatibleRequest preserves xhigh for Claude models that support it", () => { @@ -212,13 +212,15 @@ test("buildClaudeCodeCompatibleRequest preserves Claude cache markers when reque preserveCacheControl: true, }); - assert.deepEqual(payload.system[0].cache_control, { type: "ephemeral", ttl: "5m" }); - assert.deepEqual(payload.messages[0].content[0].cache_control, { type: "ephemeral" }); - assert.deepEqual(payload.messages[1].content[0].cache_control, { + assert.deepEqual((payload.system[0] as any).cache_control, { type: "ephemeral", ttl: "5m" }); + (assert as any).deepEqual((payload.messages[0].content[0] as any).cache_control, { + type: "ephemeral", + }); + assert.deepEqual((payload.messages[1].content[0] as any).cache_control, { type: "ephemeral", ttl: "10m", }); - assert.equal(payload.messages[2].content[0].cache_control, undefined); + assert.equal((payload.messages[2].content[0] as any).cache_control, undefined); assert.deepEqual(payload.tools[0].cache_control, { type: "ephemeral", ttl: "30m" }); }); @@ -263,11 +265,11 @@ test("buildClaudeCodeCompatibleRequest does not supplement missing Claude cache preserveCacheControl: true, }); - assert.equal(payload.system[0].cache_control, undefined); - assert.equal(payload.messages[0].content[0].cache_control, undefined); - assert.equal(payload.messages[1].content[0].cache_control, undefined); - assert.equal(payload.messages[2].content[0].cache_control, undefined); - assert.equal(payload.system.at(-1).cache_control, undefined); + assert.equal((payload.system[0] as any).cache_control, undefined); + assert.equal((payload.messages[0].content[0] as any).cache_control, undefined); + assert.equal((payload.messages[1].content[0] as any).cache_control, undefined); + assert.equal((payload.messages[2].content[0] as any).cache_control, undefined); + assert.equal((payload.system.at(-1) as any).cache_control, undefined); assert.equal(payload.tools[0].cache_control, undefined); }); @@ -293,8 +295,8 @@ test("buildClaudeCodeCompatibleRequest keeps built-in system blocks untagged whe preserveCacheControl: true, }); - assert.deepEqual(payload.system[0].cache_control, { type: "ephemeral" }); - assert.deepEqual(payload.system[1].cache_control, { type: "ephemeral", ttl: "1h" }); + assert.deepEqual((payload.system[0] as any).cache_control, { type: "ephemeral" }); + assert.deepEqual((payload.system[1] as any).cache_control, { type: "ephemeral", ttl: "1h" }); }); test("buildClaudeCodeCompatibleRequest does not add cache markers in non-preserve mode", () => { @@ -321,12 +323,12 @@ test("buildClaudeCodeCompatibleRequest does not add cache markers in non-preserv preserveCacheControl: false, }); - assert.equal(payload.system.length, 2); - assert.equal(payload.system[0].cache_control, undefined); - assert.equal(payload.system[1].cache_control, undefined); - assert.equal(payload.messages[0].content[0].cache_control, undefined); - assert.equal(payload.messages[1].content[0].cache_control, undefined); - assert.equal(payload.messages[2].content[0].cache_control, undefined); + (assert as any).equal(payload.system.length, 2); + assert.equal((payload as any).system[0].cache_control, undefined); + assert.equal((payload as any).system[1].cache_control, undefined); + assert.equal((payload as any).messages[0].content[0].cache_control, undefined); + assert.equal((payload as any).messages[1].content[0].cache_control, undefined); + assert.equal((payload.messages[2].content[0] as any).cache_control, undefined); }); test("buildClaudeCodeCompatibleRequest falls back to a user turn when the source only has assistant/model text", () => { @@ -557,7 +559,7 @@ test("handleChatCore forces SSE upstream for CC compatible providers while retur assert.equal(calls[0].body.stream, true); assert.equal(JSON.stringify(calls[0].body).includes('"cache_control"'), false); - const payload = await result.response.json(); + const payload = (await result.response.json()) as any; assert.equal(payload.choices[0].message.content, "Hello from CC"); assert.equal(payload.choices[0].finish_reason, "stop"); assert.equal(payload.usage.prompt_tokens, 2007); @@ -725,7 +727,7 @@ test("provider-nodes create route creates CC node with dedicated prefix when ena ); assert.equal(response.status, 201); - const data = await response.json(); + const data = (await response.json()) as any; assert.match(data.node.id, /^anthropic-compatible-cc-/); assert.equal(data.node.baseUrl, "https://proxy.example.com"); assert.equal(data.node.chatPath, CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH); @@ -780,7 +782,7 @@ test("provider-nodes validate route rejects invalid JSON and schema errors", asy ); assert.equal(invalidBodyResponse.status, 400); - const invalidBodyPayload = await invalidBodyResponse.json(); + const invalidBodyPayload = (await invalidBodyResponse.json()) as any; assert.equal(invalidBodyPayload.error.message, "Invalid request"); assert.equal(invalidBodyPayload.error.details.length >= 1, true); }); @@ -1053,7 +1055,7 @@ test("provider-nodes list route exposes CC flag state from server env", async () const response = await providerNodesRoute.GET(); assert.equal(response.status, 200); - const data = await response.json(); + const data = (await response.json()) as any; assert.equal(data.ccCompatibleProviderEnabled, true); }); diff --git a/tests/unit/chat-combo-live-test.test.ts b/tests/unit/chat-combo-live-test.test.ts index 31c99f7517..12f416d7c4 100644 --- a/tests/unit/chat-combo-live-test.test.ts +++ b/tests/unit/chat-combo-live-test.test.ts @@ -122,7 +122,7 @@ test("combo live test bypasses connection cooldown and breaker state to perform const liveResponse = await chatRoute.POST( makeRequest({ "X-Internal-Test": "combo-health-check" }) ); - const liveBody = await liveResponse.json(); + const liveBody = (await liveResponse.json()) as any; assert.equal(liveResponse.status, 200); assert.equal(fetchCalls.length, 1); @@ -130,7 +130,7 @@ test("combo live test bypasses connection cooldown and breaker state to perform assert.equal(fetchCalls[0].init.headers.Authorization, "Bearer sk-live-test"); assert.equal(liveBody.choices[0].message.content, "OK"); - const updated = await providersDb.getProviderConnectionById(created.id); + const updated = await providersDb.getProviderConnectionById((created as any).id); assert.equal(updated.testStatus, "active"); }); @@ -174,7 +174,7 @@ test("combo live test bypasses semantic cache and forces a fresh upstream reques try { const cachedResponse = await chatRoute.POST(makeRequest()); - const cachedBody = await cachedResponse.json(); + const cachedBody = (await cachedResponse.json()) as any; assert.equal(cachedResponse.status, 200); assert.equal(fetchCalls.length, 0); @@ -187,7 +187,7 @@ test("combo live test bypasses semantic cache and forces a fresh upstream reques "X-Request-Id": "combo-test-cache-bypass", }) ); - const liveBody = await liveResponse.json(); + const liveBody = (await liveResponse.json()) as any; assert.equal(liveResponse.status, 200); assert.equal(fetchCalls.length, 1); diff --git a/tests/unit/chat-context-relay.test.ts b/tests/unit/chat-context-relay.test.ts index 2e932be269..168acf6a9e 100644 --- a/tests/unit/chat-context-relay.test.ts +++ b/tests/unit/chat-context-relay.test.ts @@ -159,7 +159,7 @@ test("handleChat generates and injects context-relay handoffs across Codex accou }, }) ); - const firstJson = await firstResponse.json(); + const firstJson = (await firstResponse.json()) as any; assert.equal(firstResponse.status, 200); assert.equal(firstJson.choices[0].message.content, "relay-success"); @@ -171,7 +171,7 @@ test("handleChat generates and injects context-relay handoffs across Codex accou assert.equal(summaryBodies.length, 1); assert.match(summaryBodies[0].serializedBody, /You are a context summarizer/); - await providersDb.updateProviderConnection(primary.id, { + await providersDb.updateProviderConnection((primary as any).id, { rateLimitedUntil: new Date(Date.now() + 60_000).toISOString(), }); @@ -185,7 +185,7 @@ test("handleChat generates and injects context-relay handoffs across Codex accou }, }) ); - const secondJson = await secondResponse.json(); + const secondJson = (await secondResponse.json()) as any; assert.equal(secondResponse.status, 200); assert.equal(secondJson.choices[0].message.content, "relay-success"); diff --git a/tests/unit/chat-cooldown-aware-retry.test.ts b/tests/unit/chat-cooldown-aware-retry.test.ts index e49f5c63d4..cab0138158 100644 --- a/tests/unit/chat-cooldown-aware-retry.test.ts +++ b/tests/unit/chat-cooldown-aware-retry.test.ts @@ -75,7 +75,7 @@ test("handleChat waits for a short cooldown and retries once within the configur }) ); const elapsedMs = Date.now() - startedAt; - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(fetchCalls, 1); @@ -126,7 +126,7 @@ test("handleChat recovers from a real 429 once the connection cooldown expires", }) ); const elapsedMs = Date.now() - startedAt; - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(fetchCalls, 4); @@ -161,7 +161,7 @@ test("handleChat does not wait when the cooldown exceeds maxRetryIntervalSec", a }, }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(fetchCalls, 0); assert.equal(response.status, 503); @@ -181,9 +181,15 @@ test("handleChat returns model_cooldown when every credential for the requested maxRetryIntervalSec: 0, }); - await auth.markAccountUnavailable(first.id, 429, "too many requests", "gemini", "gemini-2.5-pro"); await auth.markAccountUnavailable( - second.id, + (first as any).id, + 429, + "too many requests", + "gemini", + "gemini-2.5-pro" + ); + await auth.markAccountUnavailable( + (second as any).id, 429, "too many requests", "gemini", @@ -205,7 +211,7 @@ test("handleChat returns model_cooldown when every credential for the requested }, }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(fetchCalls, 0); assert.equal(response.status, 429); @@ -247,7 +253,7 @@ test("handleChat aborts the pending cooldown wait when the client disconnects", controller.signal ) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(fetchCalls, 0); assert.equal(response.status, 499); diff --git a/tests/unit/chat-helpers.test.ts b/tests/unit/chat-helpers.test.ts index 102ccfd84c..168ed13ca5 100644 --- a/tests/unit/chat-helpers.test.ts +++ b/tests/unit/chat-helpers.test.ts @@ -58,7 +58,7 @@ test("resolveModelOrError rejects ambiguous aliases without a provider prefix", assert.ok(result.error); assert.equal(result.error.status, 400); - const json = await result.error.json(); + const json = (await result.error.json()) as any; assert.match(json.error.message, /Ambiguous model/i); }); @@ -71,7 +71,7 @@ test("resolveModelOrError rejects ambiguous slashful canonical ids instead of mi assert.ok(result.error); assert.equal(result.error.status, 400); - const json = await result.error.json(); + const json = (await result.error.json()) as any; assert.match(json.error.message, /Ambiguous model/i); assert.match(json.error.message, /openai\/gpt-oss-120b/i); }); @@ -85,7 +85,7 @@ test("resolveModelOrError rejects malformed model strings", async () => { assert.ok(result.error); assert.equal(result.error.status, 400); - const json = await result.error.json(); + const json = (await result.error.json()) as any; assert.match(json.error.message, /Invalid model format/i); }); @@ -101,7 +101,7 @@ test("checkPipelineGates blocks providers with an open circuit breaker", async ( resetTimeoutMs: 5_000, }, }); - const json = await response.json(); + const json = (await response.json()) as any; const retryAfter = Number(response.headers.get("Retry-After")); assert.equal(response.status, 503); @@ -143,8 +143,8 @@ test("handleNoCredentials reports missing provider credentials and exhausted acc 500 ); - const missingJson = await missing.json(); - const exhaustedJson = await exhausted.json(); + const missingJson = (await missing.json()) as any; + const exhaustedJson = (await exhausted.json()) as any; assert.equal(missing.status, 400); assert.match(missingJson.error.message, /No credentials for provider: openai/); @@ -168,7 +168,7 @@ test("handleNoCredentials returns Retry-After when every account is rate limited null, null ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 429); assert.ok(Number(response.headers.get("Retry-After")) >= 1); @@ -193,7 +193,7 @@ test("handleNoCredentials returns structured model_cooldown when every credentia null, null ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 429); assert.equal(Number(response.headers.get("Retry-After")) >= 1, true); @@ -207,7 +207,7 @@ test("handleNoCredentials returns structured model_cooldown when every credentia test("safeResolveProxy returns the direct route when no proxy config is present", async () => { const connection = await seedConnection("openai", { apiKey: "sk-openai-direct" }); - const resolved = await safeResolveProxy(connection.id); + const resolved = await safeResolveProxy((connection as any).id); assert.deepEqual(resolved, { proxy: null, @@ -230,7 +230,10 @@ test("executeChatWithBreaker converts proxy fast-fail errors", async () => { apiKey: "sk-openai-helper", providerSpecificData: {}, }; + const breaker = getCircuitBreaker("openai"); const proxyResult = await executeChatWithBreaker({ + bypassCircuitBreaker: false, + breaker, body: { model: "openai/gpt-4o-mini" }, provider: "openai", model: "gpt-4o-mini", diff --git a/tests/unit/chat-rate-limit-body-lock.test.ts b/tests/unit/chat-rate-limit-body-lock.test.ts index 20876e5516..00585835d4 100644 --- a/tests/unit/chat-rate-limit-body-lock.test.ts +++ b/tests/unit/chat-rate-limit-body-lock.test.ts @@ -54,7 +54,7 @@ test("handleChat applies body-derived retry-after to the runtime limiter", async }, }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 429); assert.match(body.error.message, /retry after 20s/i); @@ -86,7 +86,7 @@ test("handleChat tolerates non-JSON rate-limit bodies without breaking fallback }, }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 429); assert.match(body.error.message, /rate limit exceeded but body is not json/i); diff --git a/tests/unit/chat-route-coverage.test.ts b/tests/unit/chat-route-coverage.test.ts index f38085ff94..0ad2389e05 100644 --- a/tests/unit/chat-route-coverage.test.ts +++ b/tests/unit/chat-route-coverage.test.ts @@ -84,7 +84,7 @@ test("handleChat returns 400 for malformed JSON payloads", async () => { body: "{bad-json", }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 400); assert.match(json.error.message, /Invalid JSON body/i); @@ -106,7 +106,7 @@ test("handleChat rejects suspicious prompt-injection payloads before routing", a }, }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 400); assert.match(json.error.message, /suspicious content detected/i); @@ -132,7 +132,7 @@ test("handleChat redacts PII before sending the upstream request", async () => { }, }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(fetchCalls.length, 1); @@ -199,7 +199,7 @@ test("handleChat rejects requests without a model", async () => { }, }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 400); assert.match(json.error.message, /Missing model/i); @@ -232,7 +232,7 @@ test("handleChat applies task-aware routing when a semantic override is enabled" }, }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 200); assert.deepEqual(seenAuthHeaders, ["Bearer sk-deepseek-task-route"]); @@ -279,7 +279,7 @@ test("handleChat routes exact combo names and can recover via global fallback", }, }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(attempts, 2); @@ -320,7 +320,7 @@ test("handleChat keeps the combo error when the global fallback throws", async ( }, }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 503); assert.equal(attempts, 2); @@ -337,7 +337,7 @@ test("handleChat returns 400 when no provider credentials exist", async () => { }, }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 400); assert.match(json.error.message, /No credentials for provider: openai/); @@ -358,7 +358,7 @@ test("handleChat returns 503 for cooled-down connections and 503 for open circui }, }) ); - const cooldownJson = await cooldownResponse.json(); + const cooldownJson = (await cooldownResponse.json()) as any; assert.equal(cooldownResponse.status, 503); assert.ok(Number(cooldownResponse.headers.get("Retry-After")) >= 1); assert.match(cooldownJson.error.message, /\[openai\/gpt-4o-mini\]/i); @@ -377,7 +377,7 @@ test("handleChat returns 503 for cooled-down connections and 503 for open circui }, }) ); - const breakerJson = await breakerBlocked.json(); + const breakerJson = (await breakerBlocked.json()) as any; assert.equal(breakerBlocked.status, 503); assert.equal(breakerBlocked.headers.get("X-OmniRoute-Provider-Breaker"), "open"); @@ -403,7 +403,7 @@ test("handleChat maps upstream timeouts to HTTP 504", async () => { }, }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 504); assert.match(json.error.message, /\[504\]: upstream timed out/); @@ -440,7 +440,7 @@ test("handleChat uses the emergency fallback model on budget exhaustion", async }, }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(seenBodies.length, 2); @@ -483,7 +483,7 @@ test("handleChat returns the primary budget error when emergency fallback also f }, }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 402); assert.deepEqual(seenModels, ["gpt-4o-mini", "openai/gpt-oss-120b", "openai/gpt-oss-120b"]); @@ -506,7 +506,7 @@ test("handleChat rejects models that are not allowed by the caller API key polic }, }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 403); assert.match(json.error.message, /not allowed|model restriction|forbidden/i); diff --git a/tests/unit/chat-route-edge-cases.test.ts b/tests/unit/chat-route-edge-cases.test.ts index a9bf6ca72f..073ec72b3f 100644 --- a/tests/unit/chat-route-edge-cases.test.ts +++ b/tests/unit/chat-route-edge-cases.test.ts @@ -115,7 +115,7 @@ test("Test 3: handleChat returns cached response directly for Semantic Cache hit }); const res2 = await handleChat(req2); - const json2 = await res2.json(); + const json2 = (await res2.json()) as any; assert.equal(fetchCount, 1, "Should have hit the semantic cache without calling fetch again"); assert.equal(json2.choices[0].message.content, "Cache Generation 1"); @@ -207,7 +207,7 @@ test("handleChat returns cached response directly for Idempotency hits", async ( }) ); - const json2 = await response2.json(); + const json2 = (await response2.json()) as any; assert.equal(response2.status, 200); assert.equal(response2.headers.get("X-OmniRoute-Idempotent"), "true"); assert.equal(json2.choices[0].message.content, "Original response"); @@ -243,7 +243,7 @@ test("Test 6: handleChat correctly sets isResponsesEndpoint for /v1/responses", }) ); - const json = await response.json(); + const json = (await response.json()) as any; assert.equal(response.status, 200); const responseText = json.output_text || json.output?.[0]?.content?.[0]?.text; assert.equal(responseText, "Responses OK"); @@ -269,7 +269,7 @@ test("handleChat returns Semantic Cache hit", async () => { // Second request: should hit semantic cache const response2 = await handleChat(buildRequest({ body: reqBody })); - const json2 = await response2.json(); + const json2 = (await response2.json()) as any; assert.equal(response2.status, 200); assert.equal(response2.headers.get("X-OmniRoute-Cache"), "HIT"); assert.equal(json2.choices[0].message.content, "Semantic API response"); diff --git a/tests/unit/chatcore-compression-integration.test.ts b/tests/unit/chatcore-compression-integration.test.ts index a34ee21408..8cc3a8532c 100644 --- a/tests/unit/chatcore-compression-integration.test.ts +++ b/tests/unit/chatcore-compression-integration.test.ts @@ -41,7 +41,7 @@ test("chatCore integration: compressContext called proactively when context exce `Final tokens ${result.stats.final} should fit within limit ${contextLimit}` ); assert.equal( - result.body.messages[result.body.messages.length - 1].content, + result.body.messages[(result.body.messages as any).length - 1].content, "Final question?", "Latest user turn should be preserved after compression" ); @@ -133,7 +133,7 @@ test("chatCore integration: compression handles tool messages", async () => { assert.ok(result.compressed, "Context should be compressed"); - const toolMessage = result.body.messages.find((m: any) => m.role === "tool"); + const toolMessage = (result.body as any).messages.find((m: any) => m.role === "tool"); assert.ok(toolMessage, "Tool message should exist"); assert.ok(toolMessage.content.length < longToolOutput.length, "Tool message should be truncated"); assert.ok( diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index dc173d0fc9..0977d807e6 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -404,7 +404,7 @@ test("chatCore honors providerSpecificData.apiType for legacy openai-compatible responseFormat: "openai-responses", }); - const payload = await result.response.json(); + const payload = (await result.response.json()) as any; assert.equal(result.success, true); assert.match(call.url, /\/responses$/); assert.ok(call.body.input); @@ -770,7 +770,7 @@ test("chatCore restores prefixed Claude passthrough tool names in upstream respo }, }); - const payload = await result.response.json(); + const payload = (await result.response.json()) as any; assert.equal(result.success, true); assert.equal(payload.content[0].name, "Bash"); }); @@ -850,7 +850,7 @@ test("chatCore surfaces typed translation errors with the declared error type", assert.equal(result.success, false); assert.equal(result.status, 422); - const payload = await result.response.json(); + const payload = (await result.response.json()) as any; assert.equal(payload.error.type, "unsupported_feature"); assert.equal(payload.error.code, "unsupported_feature"); }); @@ -931,7 +931,7 @@ test("chatCore refreshes GitHub credentials after 401 and retries with the refre }, }); - const payload = await result.response.json(); + const payload = (await result.response.json()) as any; const providerCalls = calls.filter((entry) => entry.url.startsWith("https://api.githubcopilot.com/") ); @@ -1134,7 +1134,7 @@ test("chatCore serves a cached idempotent response without hitting the provider assert.equal(second.result.success, true); assert.equal(second.result.response.headers.get("X-OmniRoute-Idempotent"), "true"); - const payload = await second.result.response.json(); + const payload = (await second.result.response.json()) as any; assert.equal(payload.choices[0].message.content, "ok"); }); @@ -1175,7 +1175,7 @@ test("chatCore returns a semantic cache HIT for repeated deterministic requests" assert.equal(second.result.response.headers.get("X-OmniRoute-Cache"), "HIT"); assert.equal(upstreamHits, 1); - const payload = await second.result.response.json(); + const payload = (await second.result.response.json()) as any; assert.equal(payload.choices[0].message.content, "cached-once"); await waitForAsyncSideEffects(); @@ -1230,7 +1230,7 @@ test("chatCore skips semantic cache when disabled in settings", async () => { assert.equal(first.result.response.headers.get("X-OmniRoute-Cache"), "MISS"); assert.equal(second.result.response.headers.get("X-OmniRoute-Cache"), "MISS"); - const payload = await second.result.response.json(); + const payload = (await second.result.response.json()) as any; assert.equal(payload.choices[0].message.content, "fresh-2"); }); @@ -1301,7 +1301,7 @@ test("chatCore normalizes tool finish reasons and estimates usage when upstream }, }); - const payload = await result.response.json(); + const payload = (await result.response.json()) as any; assert.equal(result.success, true); assert.equal(payload.choices[0].finish_reason, "tool_calls"); assert.ok(payload.usage.total_tokens > 0); @@ -1319,7 +1319,7 @@ test("chatCore bypasses Claude CLI warmup probes before touching the provider", }, }); - const payload = await result.response.json(); + const payload = (await result.response.json()) as any; assert.equal(result.success, true); assert.equal(calls.length, 0); assert.match(payload.choices[0].message.content, /CLI Command Execution/); @@ -1380,7 +1380,7 @@ test("chatCore retries Qwen quota 429 responses before succeeding", async () => }, }); - const payload = await result.response.json(); + const payload = (await result.response.json()) as any; assert.equal(result.success, true); assert.equal(calls.length, 2); assert.equal(payload.choices[0].message.content, "qwen recovered"); @@ -1489,13 +1489,16 @@ test("chatCore persists Codex quota headers and scope cooldown on 429 responses" }, }); - const updated = await providersDb.getProviderConnectionById(connection.id); + const updated = await providersDb.getProviderConnectionById((connection as any).id); assert.equal(result.success, false); assert.equal(result.status, 429); - assert.equal(updated.providerSpecificData.codexQuotaState.limit5h, 100); - assert.equal(updated.providerSpecificData.codexQuotaState.scope, "codex"); - assert.equal(typeof updated.providerSpecificData.codexScopeRateLimitedUntil.codex, "string"); - assert.equal(updated.providerSpecificData.codexExhaustedWindow, "5h"); + assert.equal((updated as any).providerSpecificData.codexQuotaState.limit5h, 100); + assert.equal((updated as any).providerSpecificData.codexQuotaState.scope, "codex"); + assert.equal( + typeof (updated as any).providerSpecificData.codexScopeRateLimitedUntil.codex, + "string" + ); + (assert as any).equal((updated.providerSpecificData as any).codexExhaustedWindow, "5h"); }); test("chatCore falls back to the next family model when the requested model is unavailable", async () => { @@ -1518,7 +1521,7 @@ test("chatCore falls back to the next family model when the requested model is u }, }); - const payload = await result.response.json(); + const payload = (await result.response.json()) as any; assert.equal(result.success, true); assert.equal(calls.length, 2); assert.equal(calls[1].body.model, "gpt-5.1-mini"); @@ -1553,7 +1556,7 @@ test("chatCore falls back to a larger-context sibling when the request overflows }, }); - const payload = await result.response.json(); + const payload = (await result.response.json()) as any; assert.equal(result.success, true); assert.equal(calls.length, 2); assert.equal(calls[1].body.model, "gpt-4o"); @@ -1574,7 +1577,7 @@ test("chatCore parses upstream SSE payloads for non-streaming requests", async ( }, }); - const payload = await result.response.json(); + const payload = (await result.response.json()) as any; assert.equal(result.success, true); assert.equal(payload.choices[0].message.content, "sse json"); }); @@ -1657,7 +1660,7 @@ test("chatCore falls back after an empty-content success response", async () => }, }); - const payload = await result.response.json(); + const payload = (await result.response.json()) as any; assert.equal(result.success, true); assert.equal(calls.length, 2); assert.equal(calls[1].body.model, "gpt-5.1-mini"); @@ -1813,7 +1816,7 @@ test("chatCore serves emergency fallback responses for budget errors on non-stre }, }); - const payload = await result.response.json(); + const payload = (await result.response.json()) as any; assert.equal(result.success, true); assert.equal(calls.length, 2); @@ -1938,7 +1941,7 @@ test("chatCore caches streaming response and serves cache HIT on repeat", async assert.equal(second.calls.length, 0, "second request should not reach upstream"); assert.equal(second.result.response.headers.get("X-OmniRoute-Cache"), "HIT"); - const payload = await second.result.response.json(); + const payload = (await second.result.response.json()) as any; assert.ok(payload.choices, "cached response should have choices"); assert.equal(payload.choices[0].message.content, "streamed-once"); }); @@ -2070,6 +2073,6 @@ test("chatCore returns cache HIT as JSON even when client requests SSE", async ( "cache HIT should return JSON regardless of stream flag" ); - const payload = await second.result.response.json(); + const payload = (await second.result.response.json()) as any; assert.equal(payload.choices[0].message.content, "cached-json"); }); diff --git a/tests/unit/claude-code-compatible-helpers.test.ts b/tests/unit/claude-code-compatible-helpers.test.ts index 724ad1a859..18c45e5781 100644 --- a/tests/unit/claude-code-compatible-helpers.test.ts +++ b/tests/unit/claude-code-compatible-helpers.test.ts @@ -99,7 +99,7 @@ test("buildClaudeCodeCompatibleValidationPayload produces the expected smoke-tes }); assert.equal(payload.tools.length, 0); assert.equal(payload.system.length, 1); - assert.match(payload.system[0].text, /Claude Agent SDK/); - assert.ok(JSON.parse(payload.metadata.user_id).session_id); + assert.match((payload as any).system[0].text, /Claude Agent SDK/); + assert.ok((JSON as any).parse(payload.metadata.user_id).session_id); assert.ok(CLAUDE_CODE_COMPATIBLE_DEFAULT_MAX_TOKENS > payload.max_tokens); }); diff --git a/tests/unit/claude-code-compatible-request.test.ts b/tests/unit/claude-code-compatible-request.test.ts index 19c94c3065..1279d384b5 100644 --- a/tests/unit/claude-code-compatible-request.test.ts +++ b/tests/unit/claude-code-compatible-request.test.ts @@ -115,7 +115,7 @@ test("buildClaudeCodeCompatibleRequest covers normalized OpenAI-style messages, }, ]); assert.equal(payload.system.length, 3); - assert.match(payload.system[0].text, /Claude Agent SDK/); + assert.match((payload as any).system[0].text, /Claude Agent SDK/); assert.equal(payload.system[1].text, "system note"); assert.equal(payload.system[2].text, "developer note"); assert.equal(payload.tools.length, 1); @@ -194,17 +194,17 @@ test("buildClaudeCodeCompatibleRequest covers Claude-native bodies and cache-con }); assert.equal(stripped.stream, true); - assert.equal(JSON.parse(stripped.metadata.user_id).session_id, "explicit-session"); + assert.equal((JSON as any).parse(stripped.metadata.user_id).session_id, "explicit-session"); assert.equal(stripped.messages.at(-1).role, "user"); - assert.equal(stripped.system[0].cache_control, undefined); - assert.equal(stripped.messages[0].content[0].cache_control, undefined); + assert.equal((stripped as any).system[0].cache_control, undefined); + assert.equal((stripped as any).messages[0].content[0].cache_control, undefined); assert.equal(stripped.tools[0].cache_control, undefined); assert.deepEqual(stripped.thinking, { type: "enabled", budget_tokens: 12 }); assert.deepEqual(stripped.output_config, { effort: "high", format: "compact" }); assert.equal(stripped.metadata.foo, "bar"); - assert.deepEqual(preserved.system[0].cache_control, { type: "ephemeral" }); - assert.equal(preserved.messages[0].content[0].cache_control.type, "ephemeral"); - assert.equal(preserved.tools[0].cache_control.type, "ephemeral"); + (assert as any).deepEqual((preserved.system[0] as any).cache_control, { type: "ephemeral" }); + (assert as any).equal((preserved.messages[0].content[0] as any).cache_control.type, "ephemeral"); + assert.equal((preserved.tools[0].cache_control as any).type, "ephemeral"); }); test("buildClaudeCodeCompatibleRequest omits tool choice when there are no tools", () => { @@ -222,8 +222,8 @@ test("buildClaudeCodeCompatibleRequest omits tool choice when there are no tools assert.equal(payload.tools.length, 0); assert.equal("tool_choice" in payload, false); assert.equal(payload.output_config.effort, "high"); - assert.equal(payload.system.length, 1); - assert.match(payload.system[0].text, /Claude Agent SDK/); + (assert as any).equal(payload.system.length, 1); + assert.match((payload as any).system[0].text, /Claude Agent SDK/); }); test("buildClaudeCodeCompatibleRequest covers string system input, non-array Claude fields and tool choice variants", () => { @@ -264,8 +264,8 @@ test("buildClaudeCodeCompatibleRequest covers string system input, non-array Cla }); assert.deepEqual(anyChoice.tool_choice, { type: "any" }); - assert.equal(anyChoice.tools.length, 2); - assert.equal(anyChoice.tools[0].input_schema.properties.q.type, "string"); + assert.equal((anyChoice as any).tools.length, 2); + assert.equal((anyChoice.tools[0].input_schema as any).properties.q.type, "string"); assert.equal(anyChoice.tools[1].description, ""); assert.equal(stringSystem.messages.length, 0); assert.equal(stringSystem.tools.length, 0); diff --git a/tests/unit/cli-runtime-extended.test.ts b/tests/unit/cli-runtime-extended.test.ts index ac31f4e714..c4aadfe3dc 100644 --- a/tests/unit/cli-runtime-extended.test.ts +++ b/tests/unit/cli-runtime-extended.test.ts @@ -50,7 +50,7 @@ test.afterEach(() => { restoreEnv(); for (const dir of tempDirs) { - fs.rmSync(dir, { recursive: true, force: true }); + fs.rmSync(dir as any, { recursive: true, force: true }); } tempDirs.clear(); }); diff --git a/tests/unit/cli-tools-auth-hardening.test.ts b/tests/unit/cli-tools-auth-hardening.test.ts new file mode 100644 index 0000000000..28891dcb69 --- /dev/null +++ b/tests/unit/cli-tools-auth-hardening.test.ts @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const THIS_FILE = fileURLToPath(import.meta.url); +const TESTS_DIR = path.dirname(THIS_FILE); +const REPO_ROOT = path.resolve(TESTS_DIR, "../.."); +const CLI_TOOLS_DIR = path.join(REPO_ROOT, "src", "app", "api", "cli-tools"); + +function listCliToolRouteFiles(dir: string): string[] { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...listCliToolRouteFiles(fullPath)); + continue; + } + if (entry.isFile() && entry.name === "route.ts") { + files.push(fullPath); + } + } + + return files; +} +test("all cli-tools route handlers require the shared management auth guard", () => { + const routeFiles = listCliToolRouteFiles(CLI_TOOLS_DIR) + .map((fullPath) => path.relative(REPO_ROOT, fullPath).replace(/\\/g, "/")) + .sort(); + + assert.ok(routeFiles.length > 0, "expected at least one cli-tools route"); + + for (const relPath of routeFiles) { + const fullPath = path.join(REPO_ROOT, relPath); + const content = fs.readFileSync(fullPath, "utf8"); + const handlerCount = (content.match(/export async function (GET|POST|PUT|DELETE)\(/g) || []) + .length; + const authCount = ( + content.match(/const authError = await requireCliToolsAuth\(request\);/g) || [] + ).length; + const returnCount = (content.match(/if \(authError\) return authError;/g) || []).length; + + assert.ok(handlerCount > 0, `${relPath} should export at least one route handler`); + assert.ok( + content.includes('from "@/lib/api/requireCliToolsAuth"'), + `${relPath} should import requireCliToolsAuth` + ); + assert.equal( + authCount, + handlerCount, + `${relPath} should guard every exported handler before host access` + ); + assert.equal( + returnCount, + handlerCount, + `${relPath} should return the auth error from every exported handler` + ); + } +}); + +test("cli-tools auth helper delegates to management auth", () => { + const helperPath = path.join(REPO_ROOT, "src/lib/api/requireCliToolsAuth.ts"); + const content = fs.readFileSync(helperPath, "utf8"); + + assert.ok( + content.includes('from "@/lib/api/requireManagementAuth"'), + "requireCliToolsAuth should reuse the shared management auth helper" + ); + assert.ok( + content.includes("return requireManagementAuth(request);"), + "requireCliToolsAuth should delegate directly to requireManagementAuth" + ); +}); diff --git a/tests/unit/cloud-sync.test.ts b/tests/unit/cloud-sync.test.ts index 31d4ae1fcc..60129f3848 100644 --- a/tests/unit/cloud-sync.test.ts +++ b/tests/unit/cloud-sync.test.ts @@ -210,8 +210,8 @@ test("cloudSync syncs data upstream and refreshes only locally stale provider to const cloudSync = await loadCloudSync("sync-success"); const result = await cloudSync.syncToCloud("machine-1", "created-key-1"); - const staleAfter = await providersDb.getProviderConnectionById(stale.id); - const freshAfter = await providersDb.getProviderConnectionById(fresh.id); + const staleAfter = await providersDb.getProviderConnectionById((stale as any).id); + const freshAfter = await providersDb.getProviderConnectionById((fresh as any).id); assert.equal(Array.isArray(postedBody.providers), true); assert.equal(Array.isArray(postedBody.apiKeys), true); diff --git a/tests/unit/cloudflaredTunnel-extended.test.ts b/tests/unit/cloudflaredTunnel-extended.test.ts index 805a5a2492..f34f7516ab 100644 --- a/tests/unit/cloudflaredTunnel-extended.test.ts +++ b/tests/unit/cloudflaredTunnel-extended.test.ts @@ -47,7 +47,7 @@ async function readJsonFileWithRetry(filePath, attempts = 100) { try { return JSON.parse(trimmed); - } catch (error) { + } catch (error: any) { const snapshots = trimmed .split(/\n(?=\{)/) .map((entry) => entry.trim()) @@ -69,7 +69,7 @@ async function readJsonFileWithRetry(filePath, attempts = 100) { for (let attempt = 0; attempt < attempts; attempt += 1) { try { return parseJsonSnapshot(await fs.readFile(filePath, "utf8")); - } catch (error) { + } catch (error: any) { lastError = error; await new Promise((resolve) => setTimeout(resolve, 10)); } @@ -101,7 +101,7 @@ test.afterEach(async () => { restoreEnv(); for (const dir of tempDirs) { - await fs.rm(dir, { recursive: true, force: true }); + await fs.rm(dir as any, { recursive: true, force: true }); } tempDirs.clear(); }); diff --git a/tests/unit/codex-connection-defaults.test.ts b/tests/unit/codex-connection-defaults.test.ts index b9bd0ab5ce..eca1033276 100644 --- a/tests/unit/codex-connection-defaults.test.ts +++ b/tests/unit/codex-connection-defaults.test.ts @@ -69,20 +69,20 @@ test("migration backfills Codex request defaults, preserves existing providerSpe assert.equal(firstRun.migrated, true); assert.deepEqual(firstRun.updatedConnectionIds.sort(), [first.id, second.id].sort()); - assert.deepEqual(byId.get(first.id).providerSpecificData.requestDefaults, { + assert.deepEqual((byId.get(first.id).providerSpecificData as any).requestDefaults, { reasoningEffort: "medium", serviceTier: "priority", }); - assert.deepEqual(byId.get(second.id).providerSpecificData.requestDefaults, { + assert.deepEqual((byId.get(second.id) as any).providerSpecificData.requestDefaults, { reasoningEffort: "high", serviceTier: "priority", }); - assert.deepEqual(byId.get(untouched.id).providerSpecificData.requestDefaults, { + assert.deepEqual((byId.get(untouched.id) as any).providerSpecificData.requestDefaults, { reasoningEffort: "low", serviceTier: "priority", }); - assert.equal(byId.get(first.id).providerSpecificData.tag, "team-a"); - assert.deepEqual(byId.get(first.id).providerSpecificData.codexLimitPolicy, { + assert.equal((byId.get((first as any).id).providerSpecificData as any).tag, "team-a"); + assert.deepEqual((byId as any).get(first.id).providerSpecificData.codexLimitPolicy, { use5h: false, useWeekly: true, }); @@ -110,26 +110,26 @@ test("provider connection persistence normalizes request defaults without droppi }, }); - assert.deepEqual(created.providerSpecificData.requestDefaults, { + (assert as any).deepEqual((created.providerSpecificData as any).requestDefaults, { reasoningEffort: "high", serviceTier: "priority", customFlag: "keep-me", }); - assert.equal(created.providerSpecificData.openaiStoreEnabled, true); - assert.equal(created.providerSpecificData.workspaceId, "ws-normalize"); - assert.equal(created.providerSpecificData.tag, "team-z"); + assert.equal((created.providerSpecificData as any).openaiStoreEnabled, true); + assert.equal((created.providerSpecificData as any).workspaceId, "ws-normalize"); + assert.equal((created.providerSpecificData as any).tag, "team-z"); - const updated = await providersDb.updateProviderConnection(created.id, { + const updated = await providersDb.updateProviderConnection((created as any).id, { providerSpecificData: { ...created.providerSpecificData, requestDefaults: { reasoningEffort: "medium" }, }, }); - assert.deepEqual(updated.providerSpecificData.requestDefaults, { + assert.deepEqual((updated.providerSpecificData as any).requestDefaults, { reasoningEffort: "medium", }); - assert.equal(updated.providerSpecificData.openaiStoreEnabled, true); - assert.equal(updated.providerSpecificData.workspaceId, "ws-normalize"); - assert.equal(updated.providerSpecificData.tag, "team-z"); + assert.equal((updated.providerSpecificData as any).openaiStoreEnabled, true); + assert.equal((updated.providerSpecificData as any).workspaceId, "ws-normalize"); + assert.equal((updated.providerSpecificData as any).tag, "team-z"); }); diff --git a/tests/unit/codex-stream-false.test.ts b/tests/unit/codex-stream-false.test.ts index fd8f78f140..50d2988d1b 100644 --- a/tests/unit/codex-stream-false.test.ts +++ b/tests/unit/codex-stream-false.test.ts @@ -227,7 +227,7 @@ test("chatCore converts Responses-style SSE fallback into JSON when stream=false responseFactory: () => buildResponsesSse("Brasilia"), }); - const payload = await result.response.json(); + const payload = (await result.response.json()) as any; assert.equal(result.success, true); assert.equal(call.headers.Accept || call.headers.accept, "application/json"); @@ -250,7 +250,7 @@ test("chatCore converts Responses-style NDJSON fallback into JSON when stream=fa responseFactory: () => buildResponsesNdjson("Brasilia"), }); - const payload = await result.response.json(); + const payload = (await result.response.json()) as any; assert.equal(result.success, true); assert.equal(call.headers.Accept || call.headers.accept, "application/json"); @@ -300,7 +300,7 @@ test("handleComboChat validates non-stream quality using the original client str allCombos: null, }); - const payload = await result.json(); + const payload = (await result.json()) as any; assert.equal(result.ok, true); assert.deepEqual(seenModels, ["codex/gpt-5.4", "openai/gpt-4o-mini"]); diff --git a/tests/unit/combo-builder-options-route.test.ts b/tests/unit/combo-builder-options-route.test.ts index b6c4536a04..de2137c447 100644 --- a/tests/unit/combo-builder-options-route.test.ts +++ b/tests/unit/combo-builder-options-route.test.ts @@ -125,7 +125,7 @@ test("combo builder options route aggregates providers, connections, models and }); const response = await route.GET(); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.schemaVersion, 2); @@ -212,7 +212,7 @@ test("combo builder options route exposes compatible provider nodes with node me await modelsDb.addCustomModel("openai-compatible-demo", "gpt-custom", "GPT Custom"); const response = await route.GET(); - const body = await response.json(); + const body = (await response.json()) as any; const provider = body.providers.find((entry) => entry.providerId === "openai-compatible-demo"); assert.equal(response.status, 200); diff --git a/tests/unit/combo-health-route.test.ts b/tests/unit/combo-health-route.test.ts index f76a96e7cd..eb73ea3976 100644 --- a/tests/unit/combo-health-route.test.ts +++ b/tests/unit/combo-health-route.test.ts @@ -113,7 +113,7 @@ test("combo health route exposes step-level target health for structured combos" const response = await route.GET( new Request(`http://localhost/api/usage/combo-health?range=24h&comboId=${combo.id}`) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.combos.length, 1); @@ -234,7 +234,7 @@ test("combo health route prefers historical call log target metrics over volatil const response = await route.GET( new Request(`http://localhost/api/usage/combo-health?range=24h&comboId=${combo.id}`) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.combos.length, 1); diff --git a/tests/unit/combo-provider-cooldown.test.ts b/tests/unit/combo-provider-cooldown.test.ts index 451c5b723b..650b0682a5 100644 --- a/tests/unit/combo-provider-cooldown.test.ts +++ b/tests/unit/combo-provider-cooldown.test.ts @@ -91,7 +91,7 @@ test("combo failover skips the cooled provider target on the next request", asyn }, }) ); - const firstBody = await firstResponse.json(); + const firstBody = (await firstResponse.json()) as any; const secondResponse = await handleChat( buildRequest({ @@ -102,7 +102,7 @@ test("combo failover skips the cooled provider target on the next request", asyn }, }) ); - const secondBody = await secondResponse.json(); + const secondBody = (await secondResponse.json()) as any; assert.equal(firstResponse.status, 200); assert.equal(secondResponse.status, 200); diff --git a/tests/unit/combo-routes-composite-tiers.test.ts b/tests/unit/combo-routes-composite-tiers.test.ts index b0c3f1be9a..5694aedbee 100644 --- a/tests/unit/combo-routes-composite-tiers.test.ts +++ b/tests/unit/combo-routes-composite-tiers.test.ts @@ -96,7 +96,7 @@ test("POST /api/combos rejects composite tiers that point to unknown steps", asy }, }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 400); assert.deepEqual(body, { @@ -114,12 +114,12 @@ test("POST /api/combos rejects composite tiers that point to unknown steps", asy test("POST /api/combos persists valid composite tiers", async () => { const response = await createRoute.POST(makeCreateRequest(createTieredComboInput())); - const body = await response.json(); + const body = (await response.json()) as any; const stored = await combosDb.getComboByName("tiered-codex"); assert.equal(response.status, 201); assert.equal(body.config.compositeTiers.defaultTier, "primary"); - assert.equal(stored.config.compositeTiers.tiers.primary.stepId, "step-primary"); + assert.equal((stored.config as any).compositeTiers.tiers.primary.stepId, "step-primary"); assert.equal(stored.models[0].id, "step-primary"); }); @@ -137,7 +137,7 @@ test("POST /api/combos preserves legacy string combo refs during normalization", models: ["child-ref"], }) ); - const body = await response.json(); + const body = (await response.json()) as any; const stored = await combosDb.getComboByName("parent-ref"); assert.equal(response.status, 201); @@ -164,7 +164,7 @@ test("PUT /api/combos rejects updates that orphan an existing composite tier ste }), { params: Promise.resolve({ id: combo.id }) } ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 400); assert.deepEqual(body, { @@ -198,8 +198,8 @@ test("PUT /api/combos preserves legacy string combo refs during normalization", }), { params: Promise.resolve({ id: combo.id }) } ); - const body = await response.json(); - const stored = await combosDb.getComboById(combo.id); + const body = (await response.json()) as any; + const stored = await combosDb.getComboById((combo as any).id); assert.equal(response.status, 200); assert.equal(body.models[0].kind, "combo-ref"); diff --git a/tests/unit/combo-routing-engine.test.ts b/tests/unit/combo-routing-engine.test.ts index c8dd07e2ac..0cb75bde1a 100644 --- a/tests/unit/combo-routing-engine.test.ts +++ b/tests/unit/combo-routing-engine.test.ts @@ -113,7 +113,7 @@ async function cleanupTestDataDir() { core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); return; - } catch (error) { + } catch (error: any) { lastError = error; await new Promise((resolve) => setTimeout(resolve, 25)); } @@ -622,7 +622,7 @@ test("handleComboChat preserves the first failure status but surfaces the last e relayOptions: null, }); - const payload = await result.json(); + const payload = (await result.json()) as any; assert.equal(result.status, 500); assert.equal(payload.error.message, "fail:model-b"); @@ -982,7 +982,7 @@ test("handleComboChat returns the earliest retry-after when all priority targets relayOptions: null, }); - const payload = await result.json(); + const payload = (await result.json()) as any; assert.equal(result.status, 429); assert.match(payload.error.message, /limited:model-b/); @@ -1013,7 +1013,7 @@ test("handleComboChat returns 404 model_not_found when a combo has no executable relayOptions: null, }); - const payload = await result.json(); + const payload = (await result.json()) as any; assert.equal(result.status, 404); assert.equal(payload.error.code, "model_not_found"); @@ -1046,7 +1046,7 @@ test("handleComboChat round-robin returns 404 when no models are configured", as relayOptions: null, }); - const payload = await result.json(); + const payload = (await result.json()) as any; assert.equal(result.status, 404); assert.equal(payload.error.code, "model_not_found"); @@ -1137,7 +1137,7 @@ test("handleComboChat round-robin surfaces retry-after metadata after exhausting relayOptions: null, }); - const payload = await result.json(); + const payload = (await result.json()) as any; assert.equal(result.status, 429); assert.match(payload.error.message, /rr-limited:model-b/); @@ -1222,7 +1222,7 @@ test("handleComboChat round-robin falls through provider-scoped 400s and returns relayOptions: null, }); - const payload = await result.json(); + const payload = (await result.json()) as any; assert.equal(result.status, 400); assert.equal(payload.error.message, "rr-final-fail"); assert.deepEqual(calls, ["model-a", "model-b"]); @@ -1388,7 +1388,7 @@ test("handleComboChat returns a 503 when every model is unavailable before execu relayOptions: null, }); - const payload = await result.json(); + const payload = (await result.json()) as any; assert.equal(result.status, 503); assert.equal(payload.error.code, "ALL_ACCOUNTS_INACTIVE"); }); @@ -1694,7 +1694,7 @@ test("handleComboChat context cache protection pins the model and tags tool-call relayOptions: null, }); - const payload = await result.json(); + const payload = (await result.json()) as any; assert.equal(result.ok, true); assert.deepEqual(calls, ["claude/claude-sonnet-4-6"]); assert.match( @@ -1809,7 +1809,7 @@ test("handleComboChat round-robin resolves nested combos and returns inactive wh ], }); - const payload = await result.json(); + const payload = (await result.json()) as any; assert.equal(result.status, 503); assert.equal(payload.error.code, "ALL_ACCOUNTS_INACTIVE"); }); @@ -1947,7 +1947,7 @@ test("handleComboChat falls back to next model when first model returns all-acco relayOptions: null, }); - const payload = await result.json(); + const payload = (await result.json()) as any; // First model returns 503 with "unavailable" → combo should try model-b next // If the fix is not applied, combo would abort here and return 503 immediately assert.equal(result.ok, true); @@ -1988,7 +1988,7 @@ test("handleComboChat round-robin falls back when all-accounts-rate-limited 503 relayOptions: null, }); - const payload = await result.json(); + const payload = (await result.json()) as any; assert.equal(result.ok, true); assert.deepEqual(calls, ["model-a", "model-b"]); assert.equal(payload.choices[0].message.content, "ok"); @@ -2022,7 +2022,7 @@ test("handleComboChat aborts combo when 503 response does NOT contain the unavai relayOptions: null, }); - const payload = await result.json(); + const payload = (await result.json()) as any; // Without the fix, combo would abort (still 503). With the fix, it's still 503 because // the signal check filters out non-JSON or non-"unavailable" responses. assert.equal(result.status, 503); diff --git a/tests/unit/combo-strategies.test.ts b/tests/unit/combo-strategies.test.ts index 3dd09549bb..3a4acc84d0 100644 --- a/tests/unit/combo-strategies.test.ts +++ b/tests/unit/combo-strategies.test.ts @@ -48,7 +48,7 @@ test("handleComboChat with 'usage' strategy hits sortModelsByUsage", async () => try { const res = await handleComboChat(id, req, stubConnection()); assert.equal(res.status >= 200, true); - } catch (e) { + } catch (e: any) { // Expect error as fetch is not globally mocked for this quick edge branch test, that's fine } }); @@ -70,7 +70,7 @@ test("handleComboChat with 'context' strategy hits sortModelsByContextSize", asy try { const res = await handleComboChat(id, req, stubConnection()); - } catch (e) {} + } catch (e: any) {} }); test("handleComboChat hits extractPromptForIntent edge cases", async () => { @@ -99,8 +99,8 @@ test("handleComboChat hits extractPromptForIntent edge cases", async () => { try { await handleComboChat(id, reqNull, stubConnection()); - } catch (e) {} + } catch (e: any) {} try { await handleComboChat(id, reqEmpty, stubConnection()); - } catch (e) {} + } catch (e: any) {} }); diff --git a/tests/unit/combo-test-route.test.ts b/tests/unit/combo-test-route.test.ts index d1a0c0e31e..ecb1cc6875 100644 --- a/tests/unit/combo-test-route.test.ts +++ b/tests/unit/combo-test-route.test.ts @@ -74,12 +74,12 @@ test("combo test route validates request payloads and combo existence", async () body: JSON.stringify({ comboName: "" }), }) ); - const invalidBody = await invalidBodyResponse.json(); + const invalidBody = (await invalidBodyResponse.json()) as any; assert.equal(invalidBodyResponse.status, 400); assert.equal(invalidBody.error.message, "Invalid request"); const missingResponse = await route.POST(makeRequest("missing-combo")); - const missingBody = await missingResponse.json(); + const missingBody = (await missingResponse.json()) as any; assert.equal(missingResponse.status, 404); assert.equal(missingBody.error, "Combo not found"); }); @@ -120,7 +120,7 @@ test("combo test route marks a model healthy only when it returns assistant text } finally { Math.random = originalRandom; } - const body = await response.json(); + const body = (await response.json()) as any; const forwardedBody = JSON.parse(fetchCalls[0].init.body); assert.equal(response.status, 200); @@ -163,7 +163,7 @@ test("combo test route treats empty successful responses as failures", async () ); const response = await route.POST(makeRequest()); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.resolvedBy, null); @@ -203,7 +203,7 @@ test("combo test route accepts reasoning-only completions as healthy smoke-test ); const response = await route.POST(makeRequest()); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.resolvedBy, "openrouter/openai/gpt-5.4"); @@ -228,7 +228,7 @@ test("combo test route surfaces provider errors instead of downgrading them to r ); const response = await route.POST(makeRequest()); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.resolvedBy, null); @@ -284,7 +284,7 @@ test("combo test route launches model probes concurrently while preserving combo ); const response = await responsePromise; - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.resolvedBy, "provider/first"); @@ -343,7 +343,7 @@ test("combo test route preserves structured step metadata for repeated model/acc }; const response = await route.POST(makeRequest()); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(fetchCalls.length, 2); @@ -366,7 +366,7 @@ test("combo test route rejects empty combos and respects forwarded base URLs", a await createTestCombo([]); const emptyResponse = await route.POST(makeRequest()); - const emptyBody = await emptyResponse.json(); + const emptyBody = (await emptyResponse.json()) as any; assert.equal(emptyResponse.status, 400); assert.equal(emptyBody.error, "Combo has no models"); @@ -422,7 +422,7 @@ test("combo test route handles upstream timeouts and non-JSON error bodies", asy }; const response = await route.POST(makeRequest()); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.resolvedBy, null); diff --git a/tests/unit/compliance-audit-route.test.ts b/tests/unit/compliance-audit-route.test.ts index 74fe2abd2d..51e4fc9c09 100644 --- a/tests/unit/compliance-audit-route.test.ts +++ b/tests/unit/compliance-audit-route.test.ts @@ -60,7 +60,7 @@ test("compliance audit route keeps array payloads and exposes total count with s assert.equal(response.status, 200); assert.equal(response.headers.get("x-total-count"), "1"); assert.equal(response.headers.get("x-page-limit"), "10"); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(Array.isArray(payload), true); assert.equal(payload.length, 1); assert.equal(payload[0].action, "provider.validation.ssrf_blocked"); diff --git a/tests/unit/compliance-index.test.ts b/tests/unit/compliance-index.test.ts index f9394636ae..8469d862f5 100644 --- a/tests/unit/compliance-index.test.ts +++ b/tests/unit/compliance-index.test.ts @@ -233,13 +233,14 @@ test("cleanupExpiredLogs removes stale rows across all log tables and records an ); const result = compliance.cleanupExpiredLogs(); - const usageCount = db.prepare("SELECT COUNT(*) as count FROM usage_history").get().count; - const callCount = db.prepare("SELECT COUNT(*) as count FROM call_logs").get().count; - const proxyCount = db.prepare("SELECT COUNT(*) as count FROM proxy_logs").get().count; - const requestDetailCount = db - .prepare("SELECT COUNT(*) as count FROM request_detail_logs") - .get().count; - const mcpAuditCount = db.prepare("SELECT COUNT(*) as count FROM mcp_tool_audit").get().count; + const usageCount = (db.prepare("SELECT COUNT(*) as count FROM usage_history").get() as any).count; + const callCount = (db.prepare("SELECT COUNT(*) as count FROM call_logs").get() as any).count; + const proxyCount = (db.prepare("SELECT COUNT(*) as count FROM proxy_logs").get() as any).count; + const requestDetailCount = ( + db.prepare("SELECT COUNT(*) as count FROM request_detail_logs") as any + ).get().count; + const mcpAuditCount = (db.prepare("SELECT COUNT(*) as count FROM mcp_tool_audit").get() as any) + .count; const auditEntries = compliance.getAuditLog(); const auditActions = auditEntries.map((entry) => entry.action); diff --git a/tests/unit/console-log-levels.test.ts b/tests/unit/console-log-levels.test.ts index 014f04814b..531a26c329 100644 --- a/tests/unit/console-log-levels.test.ts +++ b/tests/unit/console-log-levels.test.ts @@ -44,7 +44,7 @@ test("console log API normalizes numeric pino levels correctly", async () => { const response = await route.GET( new Request("http://localhost/api/logs/console?level=info&limit=10") ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.deepEqual( @@ -89,7 +89,7 @@ test("console log API filters by component, time window, and result limit", asyn const response = await route.GET( new Request("http://localhost/api/logs/console?level=warn&component=router&limit=1") ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.length, 1); @@ -111,7 +111,7 @@ test("console log API returns an empty list for a missing file and surfaces read try { const brokenResponse = await route.GET(new Request("http://localhost/api/logs/console")); assert.equal(brokenResponse.status, 500); - const payload = await brokenResponse.json(); + const payload = (await brokenResponse.json()) as any; assert.equal(typeof payload.error, "string"); assert.equal(payload.error.length > 0, true); } finally { diff --git a/tests/unit/context-manager.test.ts b/tests/unit/context-manager.test.ts index 25e966a2c6..7cfa47fab8 100644 --- a/tests/unit/context-manager.test.ts +++ b/tests/unit/context-manager.test.ts @@ -82,7 +82,7 @@ test("compressContext: Layer 1 — trims long tool messages", () => { // Use target limit that allows the truncated tool message (~1000 tokens) to survive const result = compressContext(body, { maxTokens: 2000, reserveTokens: 100 }); assert.ok(result.compressed); - const toolMsg = result.body.messages.find((m: any) => m.role === "tool"); + const toolMsg = (result.body.messages as any).find((m: any) => m.role === "tool"); assert.ok(toolMsg.content.length < longContent.length); assert.ok(toolMsg.content.includes("[truncated]")); }); @@ -111,7 +111,7 @@ test("compressContext: Layer 2 — compresses thinking in old messages", () => { }; const result = compressContext(body, { maxTokens: 2000, reserveTokens: 500 }); // First assistant should have thinking removed - const firstAssistant = result.body.messages.find((m: any) => m.role === "assistant"); + const firstAssistant = (result.body as any).messages.find((m: any) => m.role === "assistant"); if (Array.isArray(firstAssistant.content)) { const hasThinking = firstAssistant.content.some((b: any) => b.type === "thinking"); assert.equal(hasThinking, false); @@ -129,7 +129,7 @@ test("compressContext: Layer 3 — drops old messages to fit", () => { const body = { model: "test", messages }; const result = compressContext(body, { maxTokens: 3000, reserveTokens: 500 }); assert.ok(result.compressed); - assert.ok(result.body.messages.length < messages.length); + assert.ok((result as any).body.messages.length < messages.length); assert.equal(result.body.messages[0].role, "system"); }); @@ -243,7 +243,7 @@ test("Layer 3: preserves intact tool_use/tool_result pairs after compression", ( ]; const body = { model: "test", messages }; const result = compressContext(body, { maxTokens: 50000, reserveTokens: 10000 }); - const toolMsg = result.body.messages.find( + const toolMsg = (result.body.messages as any).find( (m: any) => m.role === "tool" && m.tool_call_id === "call_1" ); assert.ok(toolMsg, "tool_result for call_1 should survive compression"); diff --git a/tests/unit/context-pinning-tool-calls.test.ts b/tests/unit/context-pinning-tool-calls.test.ts index 045e6d2f24..9d804446ef 100644 --- a/tests/unit/context-pinning-tool-calls.test.ts +++ b/tests/unit/context-pinning-tool-calls.test.ts @@ -28,7 +28,7 @@ describe("Context pinning — tool call responses (#721)", () => { assert.equal(result.length, 3, "Should have 3 messages (original 2 + synthetic)"); assert.equal(result[2].role, "assistant"); assert.ok( - result[2].content.includes("ollamacloud/glm-5"), + (result[2].content as any).includes("ollamacloud/glm-5"), "Synthetic message should contain the pin tag" ); }); @@ -50,7 +50,7 @@ describe("Context pinning — tool call responses (#721)", () => { // Array content → should append synthetic message assert.equal(result.length, 3); assert.equal(result[2].role, "assistant"); - assert.ok(result[2].content.includes("nvidia/llama-3.4-70b")); + assert.ok((result[2] as any).content.includes("nvidia/llama-3.4-70b")); }); test("extractPinnedModel finds tag in synthetic message after tool_calls", () => { @@ -79,8 +79,8 @@ describe("Context pinning — tool call responses (#721)", () => { const result = injectModelTag(messages, "openai/gpt-4o"); assert.equal(result.length, 2, "Should not add a new message"); - assert.ok(result[1].content.includes("openai/gpt-4o")); - assert.ok(result[1].content.startsWith("Hi there!")); + assert.ok((result as any)[1].content.includes("openai/gpt-4o")); + (assert as any).ok((result[1].content as any).startsWith("Hi there!")); }); test("roundtrip: inject → extract works for tool-call messages", () => { diff --git a/tests/unit/db-apikeys-crud.test.ts b/tests/unit/db-apikeys-crud.test.ts index 7b615978fe..a175b2fa86 100644 --- a/tests/unit/db-apikeys-crud.test.ts +++ b/tests/unit/db-apikeys-crud.test.ts @@ -21,7 +21,7 @@ async function resetStorage() { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } break; - } catch (error) { + } catch (error: any) { if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) { await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); } else { diff --git a/tests/unit/db-backup-extended.test.ts b/tests/unit/db-backup-extended.test.ts index c1e342be80..127eb97999 100644 --- a/tests/unit/db-backup-extended.test.ts +++ b/tests/unit/db-backup-extended.test.ts @@ -126,7 +126,7 @@ test("restoreDbBackup restores SQLite contents and returns entity counts", async assert.equal(restored.nodeCount, 0); assert.equal(restored.comboCount, 0); assert.equal(restored.apiKeyCount, 0); - assert.equal(row.cnt, 1); + assert.equal((row as any).cnt, 1); }); test("cleanupDbBackups removes overflow families and orphaned sidecars", async () => { diff --git a/tests/unit/db-combos-crud.test.ts b/tests/unit/db-combos-crud.test.ts index 0102d6d1ac..69b754d693 100644 --- a/tests/unit/db-combos-crud.test.ts +++ b/tests/unit/db-combos-crud.test.ts @@ -19,7 +19,7 @@ async function resetStorage() { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } break; - } catch (error) { + } catch (error: any) { if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) { await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); } else { @@ -58,7 +58,7 @@ test("createCombo stores default strategy and supports lookup by id and name", a weight: 0, }, ]); - assert.deepEqual(await combosDb.getComboById(combo.id), combo); + assert.deepEqual(await combosDb.getComboById((combo as any).id), combo); assert.deepEqual(await combosDb.getComboByName("Priority Combo"), combo); }); @@ -91,7 +91,7 @@ test("updateCombo merges fields while preserving immutable data", async () => { config: { retries: 1 }, }); - const updated = await combosDb.updateCombo(combo.id, { + const updated = await combosDb.updateCombo((combo as any).id, { strategy: "round-robin", config: { retries: 3, timeoutMs: 2000 }, isHidden: true, @@ -103,7 +103,7 @@ test("updateCombo merges fields while preserving immutable data", async () => { assert.deepEqual(updated.config, { retries: 3, timeoutMs: 2000 }); assert.equal(updated.strategy, "round-robin"); assert.equal(updated.isHidden, true); - assert.deepEqual(await combosDb.getComboById(combo.id), updated); + assert.deepEqual(await combosDb.getComboById((combo as any).id), updated); }); test("reorderCombos persists manual combo ordering in sqlite", async () => { @@ -130,9 +130,9 @@ test("reorderCombos persists manual combo ordering in sqlite", async () => { reordered.map((combo) => combo.sortOrder), [1, 2, 3] ); - assert.equal((await combosDb.getComboById(charlie.id))?.sortOrder, 1); - assert.equal((await combosDb.getComboById(alpha.id))?.sortOrder, 2); - assert.equal((await combosDb.getComboById(bravo.id))?.sortOrder, 3); + assert.equal((await combosDb.getComboById((charlie as any).id))?.sortOrder, 1); + assert.equal((await combosDb.getComboById((alpha as any).id))?.sortOrder, 2); + assert.equal((await combosDb.getComboById((bravo as any).id))?.sortOrder, 3); }); test("deleteCombo reports missing ids and removes existing rows", async () => { @@ -142,8 +142,8 @@ test("deleteCombo reports missing ids and removes existing rows", async () => { }); assert.equal(await combosDb.deleteCombo("missing-combo"), false); - assert.equal(await combosDb.deleteCombo(combo.id), true); - assert.equal(await combosDb.getComboById(combo.id), null); + assert.equal(await combosDb.deleteCombo((combo as any).id), true); + assert.equal(await combosDb.getComboById((combo as any).id), null); }); test("getCombos upgrades legacy persisted entries to version 2 and resolves combo refs", async () => { diff --git a/tests/unit/db-core-migration.test.ts b/tests/unit/db-core-migration.test.ts index a09871c024..8069214877 100644 --- a/tests/unit/db-core-migration.test.ts +++ b/tests/unit/db-core-migration.test.ts @@ -95,18 +95,18 @@ test("Test 2: migrateFromJson migrates data to SQLite successfully", () => { const pc = db.prepare("SELECT * FROM provider_connections WHERE id = 'test-conn'").get(); assert.ok(pc); - assert.equal(pc.provider, "openai"); - assert.equal(pc.is_active, 0); + assert.equal((pc as any).provider, "openai"); + (assert as any).equal((pc as any).is_active, 0); const key = db.prepare("SELECT * FROM api_keys WHERE id = 'test-key'").get(); assert.ok(key); - assert.equal(key.name, "Key 1"); + (assert as any).equal((key as any).name, "Key 1"); const kv = db .prepare("SELECT * FROM key_value WHERE namespace = 'settings' AND key = 'globalFallbackModel'") .get(); assert.ok(kv); - assert.equal(JSON.parse(kv.value), "openai/gpt-4o"); + (assert as any).equal(JSON.parse((kv as any).value), "openai/gpt-4o"); core.resetDbInstance(); }); diff --git a/tests/unit/db-detailed-logs.test.ts b/tests/unit/db-detailed-logs.test.ts index fc53cf4adf..acdcc088b3 100644 --- a/tests/unit/db-detailed-logs.test.ts +++ b/tests/unit/db-detailed-logs.test.ts @@ -30,7 +30,7 @@ async function resetStorage() { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } break; - } catch (error) { + } catch (error: any) { if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) { await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); } else { @@ -107,9 +107,9 @@ test("saveRequestDetailLog persists protected payloads and compacted stream summ token: "[REDACTED]", }); assert.deepEqual(row.translated_request, { message: "hello" }); - assert.equal(row.provider_response.id, "resp_123"); - assert.equal(row.provider_response.output_text, "Hello world"); - assert.deepEqual(row.provider_response._omniroute_stream, { + assert.equal((row.provider_response as any).id, "resp_123"); + assert.equal((row as any).provider_response.output_text, "Hello world"); + assert.deepEqual((row as any).provider_response._omniroute_stream, { format: "sse-json", stage: "provider-response", eventCount: 1, diff --git a/tests/unit/db-health-check.test.ts b/tests/unit/db-health-check.test.ts index f0228e5b70..52f0f0a7a6 100644 --- a/tests/unit/db-health-check.test.ts +++ b/tests/unit/db-health-check.test.ts @@ -72,9 +72,12 @@ test("runDbHealthCheck reports issues without mutating when autoRepair is disabl assert.equal(result.isHealthy, false); assert.equal(result.repairedCount, 0); assert.equal(result.issues.length, 6); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM quota_snapshots").get().count, 2); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_budgets").get().count, 1); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_fallback_chains").get().count, 1); + assert.equal((db.prepare("SELECT COUNT(*) AS count FROM quota_snapshots").get() as any).count, 2); + assert.equal((db.prepare("SELECT COUNT(*) AS count FROM domain_budgets").get() as any).count, 1); + assert.equal( + (db.prepare("SELECT COUNT(*) AS count FROM domain_fallback_chains").get() as any).count, + 1 + ); }); test("runDbHealthCheck tolerates databases without a combos table", async () => { @@ -102,14 +105,26 @@ test("runDbHealthCheck auto-repairs orphan rows and invalid JSON payloads", asyn assert.equal(result.isHealthy, false); assert.equal(result.backupCreated, true); assert.equal(result.repairedCount, 7); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM quota_snapshots").get().count, 0); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_budgets").get().count, 0); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_cost_history").get().count, 0); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_fallback_chains").get().count, 0); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_lockout_state").get().count, 0); + assert.equal((db.prepare("SELECT COUNT(*) AS count FROM quota_snapshots").get() as any).count, 0); + assert.equal((db.prepare("SELECT COUNT(*) AS count FROM domain_budgets").get() as any).count, 0); assert.equal( - db.prepare("SELECT options FROM domain_circuit_breakers WHERE name = ?").get("broken-breaker") - .options, + (db.prepare("SELECT COUNT(*) AS count FROM domain_cost_history").get() as any).count, + 0 + ); + assert.equal( + (db.prepare("SELECT COUNT(*) AS count FROM domain_fallback_chains").get() as any).count, + 0 + ); + assert.equal( + (db.prepare("SELECT COUNT(*) AS count FROM domain_lockout_state").get() as any).count, + 0 + ); + assert.equal( + ( + db + .prepare("SELECT options FROM domain_circuit_breakers WHERE name = ?") + .get("broken-breaker") as any + ).options, null ); }); @@ -192,10 +207,10 @@ test("runDbHealthCheck repairs broken combo payloads, combo refs and stale conne createBackupBeforeRepair: () => false, }); const invalidCombo = JSON.parse( - db.prepare("SELECT data FROM combos WHERE id = ?").get("combo-invalid").data + (db.prepare("SELECT data FROM combos WHERE id = ?").get("combo-invalid") as any).data ); const repairedCombo = JSON.parse( - db.prepare("SELECT data FROM combos WHERE id = ?").get("combo-broken").data + (db.prepare("SELECT data FROM combos WHERE id = ?").get("combo-broken") as any).data ); assert.equal( @@ -273,14 +288,26 @@ test("getDbInstance can auto-repair persisted broken rows when startup repair is } } - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM quota_snapshots").get().count, 0); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_budgets").get().count, 0); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_cost_history").get().count, 0); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_fallback_chains").get().count, 0); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_lockout_state").get().count, 0); + assert.equal((db.prepare("SELECT COUNT(*) AS count FROM quota_snapshots").get() as any).count, 0); + assert.equal((db.prepare("SELECT COUNT(*) AS count FROM domain_budgets").get() as any).count, 0); assert.equal( - db.prepare("SELECT options FROM domain_circuit_breakers WHERE name = ?").get("broken-breaker") - .options, + (db.prepare("SELECT COUNT(*) AS count FROM domain_cost_history").get() as any).count, + 0 + ); + assert.equal( + (db.prepare("SELECT COUNT(*) AS count FROM domain_fallback_chains").get() as any).count, + 0 + ); + assert.equal( + (db.prepare("SELECT COUNT(*) AS count FROM domain_lockout_state").get() as any).count, + 0 + ); + assert.equal( + ( + db + .prepare("SELECT options FROM domain_circuit_breakers WHERE name = ?") + .get("broken-breaker") as any + ).options, null ); }); @@ -292,11 +319,20 @@ test("getDbInstance skips automatic startup repair during tests unless forced", db = core.getDbInstance(); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM quota_snapshots").get().count, 2); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_budgets").get().count, 1); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_cost_history").get().count, 1); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_fallback_chains").get().count, 1); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_lockout_state").get().count, 1); + assert.equal((db.prepare("SELECT COUNT(*) AS count FROM quota_snapshots").get() as any).count, 2); + assert.equal((db.prepare("SELECT COUNT(*) AS count FROM domain_budgets").get() as any).count, 1); + assert.equal( + (db.prepare("SELECT COUNT(*) AS count FROM domain_cost_history").get() as any).count, + 1 + ); + assert.equal( + (db.prepare("SELECT COUNT(*) AS count FROM domain_fallback_chains").get() as any).count, + 1 + ); + assert.equal( + (db.prepare("SELECT COUNT(*) AS count FROM domain_lockout_state").get() as any).count, + 1 + ); }); test("runDbHealthCheck repairs a drifted db_meta schema version", async () => { @@ -313,7 +349,7 @@ test("runDbHealthCheck repairs a drifted db_meta schema version", async () => { true ); assert.equal( - db.prepare("SELECT value FROM db_meta WHERE key = 'schema_version'").get().value, + (db.prepare("SELECT value FROM db_meta WHERE key = 'schema_version'").get() as any).value, "1" ); }); @@ -333,14 +369,19 @@ test("deleteApiKey removes domain budget and cost history rows for that key", as assert.equal(await apiKeysDb.deleteApiKey(created.id), true); assert.equal( - db.prepare("SELECT COUNT(*) AS count FROM domain_budgets WHERE api_key_id = ?").get(created.id) - .count, + ( + db + .prepare("SELECT COUNT(*) AS count FROM domain_budgets WHERE api_key_id = ?") + .get(created.id) as any + ).count, 0 ); assert.equal( - db - .prepare("SELECT COUNT(*) AS count FROM domain_cost_history WHERE api_key_id = ?") - .get(created.id).count, + ( + db + .prepare("SELECT COUNT(*) AS count FROM domain_cost_history WHERE api_key_id = ?") + .get((created as any).id) as any + ).count, 0 ); }); @@ -371,19 +412,21 @@ test("deleteProviderConnection and bulk delete remove related quota snapshots", VALUES (?, ?, ?, ?, ?, ?)` ).run("openai", second.id, "monthly", 40, 0, new Date().toISOString()); - assert.equal(await providersDb.deleteProviderConnection(first.id), true); + assert.equal(await providersDb.deleteProviderConnection((first as any).id), true); assert.equal( - db - .prepare("SELECT COUNT(*) AS count FROM quota_snapshots WHERE connection_id = ?") - .get(first.id).count, + ( + db.prepare("SELECT COUNT(*) AS count FROM quota_snapshots WHERE connection_id = ?") as any + ).get(first.id).count, 0 ); await providersDb.deleteProviderConnectionsByProvider("openai"); assert.equal( - db - .prepare("SELECT COUNT(*) AS count FROM quota_snapshots WHERE connection_id = ?") - .get(second.id).count, + ( + db + .prepare("SELECT COUNT(*) AS count FROM quota_snapshots WHERE connection_id = ?") + .get(second.id) as any + ).count, 0 ); }); diff --git a/tests/unit/db-health-route.test.ts b/tests/unit/db-health-route.test.ts index 91eb01b9d5..fa8c136e20 100644 --- a/tests/unit/db-health-route.test.ts +++ b/tests/unit/db-health-route.test.ts @@ -53,7 +53,7 @@ test("GET /api/v1/db/health requires authentication", async () => { try { const response = await routeModule.GET(makeRequest("GET")); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 401); assert.equal(body.error.message, "Authentication required"); @@ -72,13 +72,13 @@ test("GET /api/v1/db/health diagnoses without mutating database rows", async () insertBrokenRows(db); const response = await routeModule.GET(makeRequest("GET", authKey.key)); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.isHealthy, false); assert.equal(body.repairedCount, 0); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM quota_snapshots").get().count, 1); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_budgets").get().count, 1); + assert.equal((db.prepare("SELECT COUNT(*) AS count FROM quota_snapshots").get() as any).count, 1); + assert.equal((db.prepare("SELECT COUNT(*) AS count FROM domain_budgets").get() as any).count, 1); }); test("POST /api/v1/db/health repairs broken rows for authenticated callers", async () => { @@ -87,11 +87,11 @@ test("POST /api/v1/db/health repairs broken rows for authenticated callers", asy insertBrokenRows(db); const response = await routeModule.POST(makeRequest("POST", authKey.key)); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.isHealthy, false); assert.equal(body.repairedCount, 2); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM quota_snapshots").get().count, 0); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM domain_budgets").get().count, 0); + assert.equal((db.prepare("SELECT COUNT(*) AS count FROM quota_snapshots").get() as any).count, 0); + assert.equal((db.prepare("SELECT COUNT(*) AS count FROM domain_budgets").get() as any).count, 0); }); diff --git a/tests/unit/db-models-crud.test.ts b/tests/unit/db-models-crud.test.ts index 198a0ec2a8..7f8f63f76c 100644 --- a/tests/unit/db-models-crud.test.ts +++ b/tests/unit/db-models-crud.test.ts @@ -19,7 +19,7 @@ async function resetStorage() { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } break; - } catch (error) { + } catch (error: any) { if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) { await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); } else { @@ -97,7 +97,7 @@ test("custom models can be added once and queried by provider", async () => { assert.equal(duplicate.id, created.id); assert.equal(providerModels.length, 1); assert.deepEqual(providerModels[0], created); - assert.equal(allModels.openrouter.length, 1); + assert.equal((allModels.openrouter as any).length, 1); }); test("replaceCustomModels preserves compat fields and respects the empty-list guard", async () => { diff --git a/tests/unit/db-providers-crud.test.ts b/tests/unit/db-providers-crud.test.ts index b2ed7ea931..811e66ddea 100644 --- a/tests/unit/db-providers-crud.test.ts +++ b/tests/unit/db-providers-crud.test.ts @@ -19,7 +19,7 @@ async function resetStorage() { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } break; - } catch (error) { + } catch (error: any) { if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) { await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); } else { @@ -136,7 +136,7 @@ test("codex workspace uniqueness uses workspaceId alongside email", async () => assert.equal(workspaceAUpdate.id, workspaceA.id); assert.notEqual(workspaceB.id, workspaceA.id); assert.equal(rows.length, 2); - assert.deepEqual(rows.map((row) => row.providerSpecificData.workspaceId).sort(), [ + assert.deepEqual(rows.map((row) => (row.providerSpecificData as any).workspaceId).sort(), [ "ws-a", "ws-b", ]); @@ -162,7 +162,7 @@ test("updateProviderConnection reorders priorities and returns decrypted payload apiKey: "third-key", }); - const updated = await providersDb.updateProviderConnection(third.id, { + const updated = await providersDb.updateProviderConnection((third as any).id, { priority: 0, providerSpecificData: { region: "us-east-1" }, rateLimitProtection: true, @@ -170,7 +170,7 @@ test("updateProviderConnection reorders priorities and returns decrypted payload const ordered = await providersDb.getProviderConnections({ provider: "openai" }); - assert.equal(updated.providerSpecificData.region, "us-east-1"); + assert.equal((updated as any).providerSpecificData.region, "us-east-1"); assert.equal(updated.rateLimitProtection, true); assert.deepEqual( ordered.map((connection) => ({ @@ -205,7 +205,7 @@ test("deleteProviderConnection reorders remaining rows and bulk delete reports c apiKey: "three", }); - assert.equal(await providersDb.deleteProviderConnection(second.id), true); + assert.equal(await providersDb.deleteProviderConnection((second as any).id), true); const reordered = await providersDb.getProviderConnections({ provider: "anthropic" }); const deletedCount = await providersDb.deleteProviderConnectionsByProvider("anthropic"); @@ -238,12 +238,12 @@ test("provider node CRUD supports filter, update and delete", async () => { }); const filtered = await providersDb.getProviderNodes({ type: "custom" }); - const updated = await providersDb.updateProviderNode(customNode.id, { + const updated = await providersDb.updateProviderNode((customNode as any).id, { name: "Custom Gateway v2", chatPath: "/v1/chat/completions", modelsPath: "/v1/models", }); - const deleted = await providersDb.deleteProviderNode(openAiNode.id); + const deleted = await providersDb.deleteProviderNode((openAiNode as any).id); assert.deepEqual( filtered.map((node) => node.id), @@ -251,9 +251,9 @@ test("provider node CRUD supports filter, update and delete", async () => { ); assert.equal(updated.name, "Custom Gateway v2"); assert.equal(updated.chatPath, "/v1/chat/completions"); - assert.deepEqual(await providersDb.getProviderNodeById(customNode.id), updated); + assert.deepEqual(await providersDb.getProviderNodeById((customNode as any).id), updated); assert.equal(deleted.id, openAiNode.id); - assert.equal(await providersDb.getProviderNodeById(openAiNode.id), null); + assert.equal(await providersDb.getProviderNodeById((openAiNode as any).id), null); }); test("rate-limit helpers persist cooldown state in the database", async () => { @@ -265,9 +265,9 @@ test("rate-limit helpers persist cooldown state in the database", async () => { }); const future = Date.now() + 90_000; - providersDb.setConnectionRateLimitUntil(connection.id, future); + providersDb.setConnectionRateLimitUntil((connection as any).id, future); - assert.equal(providersDb.isConnectionRateLimited(connection.id), true); + assert.equal(providersDb.isConnectionRateLimited((connection as any).id), true); assert.deepEqual( providersDb .getRateLimitedConnections("openai") @@ -275,9 +275,9 @@ test("rate-limit helpers persist cooldown state in the database", async () => { [{ id: connection.id, rateLimitedUntil: future }] ); - providersDb.setConnectionRateLimitUntil(connection.id, null); + providersDb.setConnectionRateLimitUntil((connection as any).id, null); - assert.equal(providersDb.isConnectionRateLimited(connection.id), false); + assert.equal(providersDb.isConnectionRateLimited((connection as any).id), false); assert.deepEqual(providersDb.getRateLimitedConnections("openai"), []); }); diff --git a/tests/unit/db-proxies-crud.test.ts b/tests/unit/db-proxies-crud.test.ts index 1f9b1ee5c2..7275096871 100644 --- a/tests/unit/db-proxies-crud.test.ts +++ b/tests/unit/db-proxies-crud.test.ts @@ -20,7 +20,7 @@ async function resetStorage() { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } break; - } catch (error) { + } catch (error: any) { if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) { await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); } else { @@ -106,14 +106,18 @@ test("proxy assignments resolve by account, provider and global scope", async () await proxiesDb.assignProxyToScope("provider", "openai", providerProxy.id); const providerResolved = await proxiesDb.resolveProxyForProvider("openai"); - const beforeAccount = await proxiesDb.resolveProxyForConnectionFromRegistry(connection.id); + const beforeAccount = await proxiesDb.resolveProxyForConnectionFromRegistry( + (connection as any).id + ); - await proxiesDb.assignProxyToScope("key", connection.id, accountProxy.id); + await proxiesDb.assignProxyToScope("key", (connection as any).id, accountProxy.id); const assignmentsForAccountProxy = await proxiesDb.getProxyAssignments({ proxyId: accountProxy.id, }); - const accountResolved = await proxiesDb.resolveProxyForConnectionFromRegistry(connection.id); + const accountResolved = await proxiesDb.resolveProxyForConnectionFromRegistry( + (connection as any).id + ); const usage = await proxiesDb.getProxyWhereUsed(accountProxy.id); assert.equal(providerResolved.host, "provider.local"); @@ -220,7 +224,7 @@ test("assignProxyToScope normalizes key scope, supports removal, and blocks dele port: 8080, }); - const assignment = await proxiesDb.assignProxyToScope("key", connection.id, proxy.id); + const assignment = await proxiesDb.assignProxyToScope("key", (connection as any).id, proxy.id); assert.equal(assignment.scope, "account"); assert.equal((await proxiesDb.getProxyAssignments({ scope: "key" })).length, 1); @@ -230,7 +234,7 @@ test("assignProxyToScope normalizes key scope, supports removal, and blocks dele /Remove assignments first or use force=true/ ); - const removed = await proxiesDb.assignProxyToScope("key", connection.id, null); + const removed = await proxiesDb.assignProxyToScope("key", (connection as any).id, null); assert.equal(removed, null); assert.equal((await proxiesDb.getProxyAssignments({ scope: "account" })).length, 0); diff --git a/tests/unit/db-read-cache.test.ts b/tests/unit/db-read-cache.test.ts index 83074f6217..4a8a7879f3 100644 --- a/tests/unit/db-read-cache.test.ts +++ b/tests/unit/db-read-cache.test.ts @@ -26,7 +26,7 @@ async function resetStorage() { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } break; - } catch (error) { + } catch (error: any) { if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) { await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); } else { diff --git a/tests/unit/db-secrets.test.ts b/tests/unit/db-secrets.test.ts index e44c42bd4a..0d085c2176 100644 --- a/tests/unit/db-secrets.test.ts +++ b/tests/unit/db-secrets.test.ts @@ -19,7 +19,7 @@ async function resetStorage() { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } break; - } catch (error) { + } catch (error: any) { if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) { await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); } else { diff --git a/tests/unit/db-settings-crud.test.ts b/tests/unit/db-settings-crud.test.ts index 9f1fdebb0b..41813d5adb 100644 --- a/tests/unit/db-settings-crud.test.ts +++ b/tests/unit/db-settings-crud.test.ts @@ -23,7 +23,7 @@ async function resetStorage() { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } break; - } catch (error) { + } catch (error: any) { if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) { await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); } else { @@ -437,7 +437,7 @@ test("proxy helpers resolve key, provider, global, and direct paths while tolera }); db.prepare("UPDATE combos SET data = ? WHERE id = ?").run("{not-json", combo.id); - const providerResolved = await settingsDb.resolveProxyForConnection(connection.id); + const providerResolved = await settingsDb.resolveProxyForConnection((connection as any).id); assert.equal(providerResolved.level, "provider"); assert.equal(providerResolved.proxy.host, "provider.local"); @@ -449,26 +449,26 @@ test("proxy helpers resolve key, provider, global, and direct paths while tolera await settingsDb.deleteProxyForLevel("provider", "openai"); - const globalResolved = await settingsDb.resolveProxyForConnection(connection.id); + const globalResolved = await settingsDb.resolveProxyForConnection((connection as any).id); assert.equal(globalResolved.level, "global"); assert.equal(globalResolved.proxy.host, "global.local"); - await settingsDb.setProxyForLevel("key", connection.id, { + await settingsDb.setProxyForLevel("key", (connection as any).id, { type: "http", host: "key.local", port: 3128, }); - const keyResolved = await settingsDb.resolveProxyForConnection(connection.id); + const keyResolved = await settingsDb.resolveProxyForConnection((connection as any).id); assert.equal(keyResolved.level, "key"); assert.equal(keyResolved.proxy.host, "key.local"); - await settingsDb.deleteProxyForLevel("key", connection.id); + await settingsDb.deleteProxyForLevel("key", (connection as any).id); await settingsDb.deleteProxyForLevel("global", null); - const directResolved = await settingsDb.resolveProxyForConnection(connection.id); + const directResolved = await settingsDb.resolveProxyForConnection((connection as any).id); assert.equal(directResolved.level, "direct"); assert.equal(directResolved.proxy, null); @@ -494,14 +494,14 @@ test("proxy resolution skips combos without serialized data and falls back to pr models: ["claude/claude-3-5-sonnet"], strategy: "priority", }); - await settingsDb.setProxyForLevel("combo", combo.id, { + await settingsDb.setProxyForLevel("combo" as any, (combo as any).id, { type: "http", host: "combo-null.local", port: 8080, }); db.prepare("UPDATE combos SET data = ? WHERE id = ?").run(0, combo.id); - const resolved = await settingsDb.resolveProxyForConnection(connection.id); + const resolved = await settingsDb.resolveProxyForConnection((connection as any).id); assert.equal(resolved.level, "provider"); assert.equal(resolved.proxy.host, "provider-claude.local"); @@ -520,13 +520,13 @@ test("proxy resolution matches combo proxies through aliased model entries", asy models: [{ model: "cc/claude-3-5-sonnet" }], strategy: "priority", }); - await settingsDb.setProxyForLevel("combo", combo.id, { + await settingsDb.setProxyForLevel("combo", (combo as any).id, { type: "https", host: "combo-alias.local", port: 443, }); - const resolved = await settingsDb.resolveProxyForConnection(connection.id); + const resolved = await settingsDb.resolveProxyForConnection((connection as any).id); assert.equal(resolved.level, "combo"); assert.equal(resolved.levelId, combo.id); diff --git a/tests/unit/display-and-error-utils.test.ts b/tests/unit/display-and-error-utils.test.ts index 5725ab6a6c..55b89c1de1 100644 --- a/tests/unit/display-and-error-utils.test.ts +++ b/tests/unit/display-and-error-utils.test.ts @@ -76,7 +76,7 @@ test("createErrorResponse: infers error types from status and preserves details" message: "Conflict detected", details: { field: "name" }, }); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 409); assert.equal(body.error.message, "Conflict detected"); @@ -91,7 +91,7 @@ test("createErrorResponse: uses explicit type when provided", async () => { message: "teapot", type: "not_found", }); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(body.error.type, "not_found"); }); @@ -103,7 +103,7 @@ test("createErrorResponseFromUnknown: normalizes typed errors", async () => { type: "server_error", details: { retryable: true }, }); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 503); assert.equal(body.error.message, "db exploded"); @@ -113,7 +113,7 @@ test("createErrorResponseFromUnknown: normalizes typed errors", async () => { test("createErrorResponseFromUnknown: falls back for non-object errors", async () => { const response = createErrorResponseFromUnknown("boom", "fallback message"); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 500); assert.equal(body.error.message, "fallback message"); diff --git a/tests/unit/domain-branch-hardening.test.ts b/tests/unit/domain-branch-hardening.test.ts index 8f6071dbee..bf2319d440 100644 --- a/tests/unit/domain-branch-hardening.test.ts +++ b/tests/unit/domain-branch-hardening.test.ts @@ -37,7 +37,7 @@ async function resetStorage() { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } break; - } catch (error) { + } catch (error: any) { if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) { await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); } else { diff --git a/tests/unit/domain-cost-rules.test.ts b/tests/unit/domain-cost-rules.test.ts index 63216800cf..874ba257ee 100644 --- a/tests/unit/domain-cost-rules.test.ts +++ b/tests/unit/domain-cost-rules.test.ts @@ -21,7 +21,7 @@ async function resetStorage() { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } break; - } catch (error) { + } catch (error: any) { if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) { await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); } else { diff --git a/tests/unit/domain-fallback-policy.test.ts b/tests/unit/domain-fallback-policy.test.ts index 0f1cd28e93..28730aebc6 100644 --- a/tests/unit/domain-fallback-policy.test.ts +++ b/tests/unit/domain-fallback-policy.test.ts @@ -20,7 +20,7 @@ async function resetStorage() { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } break; - } catch (error) { + } catch (error: any) { if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) { await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); } else { diff --git a/tests/unit/domain-lockout-policy.test.ts b/tests/unit/domain-lockout-policy.test.ts index 7f62a6fb88..758742dc87 100644 --- a/tests/unit/domain-lockout-policy.test.ts +++ b/tests/unit/domain-lockout-policy.test.ts @@ -19,7 +19,7 @@ async function resetStorage() { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } break; - } catch (error) { + } catch (error: any) { if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) { await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); } else { diff --git a/tests/unit/empty-tool-name-loop.test.ts b/tests/unit/empty-tool-name-loop.test.ts index 533d718b1b..f5a84a85f3 100644 --- a/tests/unit/empty-tool-name-loop.test.ts +++ b/tests/unit/empty-tool-name-loop.test.ts @@ -18,7 +18,7 @@ test("openaiResponsesToOpenAIRequest: skips function_call items with empty name" }; const result = openaiResponsesToOpenAIRequest("gpt-4", body, true, {}); - const messages = result.messages; + const messages = (result as any).messages; // Should have only the user message, no assistant message with empty tool call const assistantMsgs = messages.filter((m) => m.role === "assistant"); @@ -35,7 +35,7 @@ test("openaiResponsesToOpenAIRequest: skips function_call items with whitespace- }; const result = openaiResponsesToOpenAIRequest("gpt-4", body, true, {}); - const messages = result.messages; + const messages = (result as any).messages; const assistantMsgs = messages.filter((m) => m.role === "assistant"); assert.equal(assistantMsgs.length, 0, "whitespace-only name function_call should be skipped"); @@ -56,7 +56,7 @@ test("openaiResponsesToOpenAIRequest: keeps function_call items with valid name" }; const result = openaiResponsesToOpenAIRequest("gpt-4", body, true, {}); - const messages = result.messages; + const messages = (result as any).messages; const assistantMsgs = messages.filter((m) => m.role === "assistant"); assert.equal(assistantMsgs.length, 1, "valid function_call should produce assistant message"); @@ -81,7 +81,7 @@ test("openaiResponsesToOpenAIRequest: mixed valid and empty names keeps only val }; const result = openaiResponsesToOpenAIRequest("gpt-4", body, true, {}); - const messages = result.messages; + const messages = (result as any).messages; const assistantMsgs = messages.filter((m) => m.role === "assistant"); assert.equal(assistantMsgs.length, 1); @@ -106,7 +106,7 @@ test("openaiToOpenAIResponsesRequest: skips tool_calls with empty function name" }; const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, {}); - const fnCalls = result.input.filter((i) => i.type === "function_call"); + const fnCalls = (result as any).input.filter((i) => i.type === "function_call"); assert.equal(fnCalls.length, 0, "empty-name tool_call should be skipped"); }); @@ -125,7 +125,7 @@ test("openaiToOpenAIResponsesRequest: skips tool_calls with whitespace-only func }; const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, {}); - const fnCalls = result.input.filter((i) => i.type === "function_call"); + const fnCalls = (result as any).input.filter((i) => i.type === "function_call"); assert.equal(fnCalls.length, 0, "whitespace-only name tool_call should be skipped"); }); @@ -148,7 +148,7 @@ test("openaiToOpenAIResponsesRequest: keeps tool_calls with valid function name" }; const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, {}); - const fnCalls = result.input.filter((i) => i.type === "function_call"); + const fnCalls = (result as any).input.filter((i) => i.type === "function_call"); assert.equal(fnCalls.length, 1); assert.equal(fnCalls[0].name, "get_weather"); }); @@ -174,7 +174,7 @@ test("openaiToOpenAIResponsesRequest: mixed valid and empty names keeps only val }; const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, {}); - const fnCalls = result.input.filter((i) => i.type === "function_call"); + const fnCalls = (result as any).input.filter((i) => i.type === "function_call"); assert.equal(fnCalls.length, 1, "only valid tool call should remain"); assert.equal(fnCalls[0].name, "get_weather"); }); diff --git a/tests/unit/executor-antigravity.test.ts b/tests/unit/executor-antigravity.test.ts index eeeffcbedd..0e9eed230b 100644 --- a/tests/unit/executor-antigravity.test.ts +++ b/tests/unit/executor-antigravity.test.ts @@ -153,7 +153,7 @@ test("AntigravityExecutor.transformRequest returns a structured error response w true, {} ); - const payload = await result.json(); + const payload = (await result.json()) as any; assert.equal(result.status, 422); assert.equal(payload.error.code, "missing_project_id"); @@ -231,7 +231,7 @@ test("AntigravityExecutor.collectStreamToResponse turns SSE Gemini chunks into a { Authorization: "Bearer ag-token" }, { request: {} } ); - const payload = await result.response.json(); + const payload = (await result.response.json()) as any; assert.equal(result.response.status, 200); assert.equal(payload.object, "chat.completion"); @@ -316,7 +316,7 @@ test("AntigravityExecutor.execute auto-retries short 429 responses and collects credentials: { accessToken: "token", projectId: "project-1" }, log: { debug() {}, warn() {} }, }); - const payload = await result.response.json(); + const payload = (await result.response.json()) as any; assert.equal(calls.length, 2); assert.equal(result.response.status, 200); @@ -358,7 +358,7 @@ test("AntigravityExecutor.execute embeds retryAfterMs when the upstream asks for credentials: { accessToken: "token", projectId: "project-1" }, log: { debug() {}, warn() {} }, }); - const payload = await result.response.json(); + const payload = (await result.response.json()) as any; assert.equal(result.response.status, 429); assert.equal(payload.retryAfterMs, 7_200_000); diff --git a/tests/unit/executor-cursor-extended.test.ts b/tests/unit/executor-cursor-extended.test.ts index 6944a3e508..0f008bf9c9 100644 --- a/tests/unit/executor-cursor-extended.test.ts +++ b/tests/unit/executor-cursor-extended.test.ts @@ -197,7 +197,7 @@ test("CursorExecutor.transformProtobufToJSON aggregates text and split tool call ]); const response = executor.transformProtobufToJSON(buffer, "cursor-small", body); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(payload.object, "chat.completion"); @@ -223,7 +223,7 @@ test("CursorExecutor.transformProtobufToJSON finalizes incomplete tool calls whe "cursor-small", { messages: [{ role: "user", content: "hi" }] } ); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(payload.choices[0].finish_reason, "tool_calls"); assert.equal(payload.choices[0].message.tool_calls[0].id, "call_2"); @@ -245,7 +245,7 @@ test("CursorExecutor.transformProtobufToJSON keeps prior content when an error f "cursor-small", { messages: [{ role: "user", content: "hi" }] } ); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(payload.choices[0].message.content, "Partial answer"); @@ -259,7 +259,7 @@ test("CursorExecutor.transformProtobufToJSON decompresses gzip frames", async () "cursor-small", { messages: [{ role: "user", content: "hi" }] } ); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(payload.choices[0].message.content, "Compressed answer"); }); @@ -329,7 +329,7 @@ test("CursorExecutor.transformProtobufToSSE returns a JSON error before any cont "cursor-small", { messages: [{ role: "user", content: "hi" }] } ); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 429); assert.equal(payload.error.type, "rate_limit_error"); @@ -407,7 +407,7 @@ test("CursorExecutor.transformProtobufToSSE converts JSON error frames into rate "cursor-small", { messages: [{ role: "user", content: "hi" }] } ); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 429); assert.equal(payload.error.type, "rate_limit_error"); @@ -435,7 +435,7 @@ test("CursorExecutor.execute returns transformed JSON for non-stream responses", providerSpecificData: { machineId: "machine-1" }, }, }); - const payload = await result.response.json(); + const payload = (await result.response.json()) as any; assert.equal( result.url, @@ -494,7 +494,7 @@ test("CursorExecutor.execute maps non-200 upstream responses to OpenAI-style err providerSpecificData: { machineId: "machine-1" }, }, }); - const payload = await result.response.json(); + const payload = (await result.response.json()) as any; assert.equal(result.response.status, 403); assert.equal(payload.error.type, "invalid_request_error"); @@ -517,7 +517,7 @@ test("CursorExecutor.execute maps transport failures to connection_error and ref providerSpecificData: { machineId: "machine-1" }, }, }); - const payload = await result.response.json(); + const payload = (await result.response.json()) as any; assert.equal(result.response.status, 500); assert.equal(payload.error.type, "connection_error"); diff --git a/tests/unit/executor-default-base.test.ts b/tests/unit/executor-default-base.test.ts index 5d1ce319f9..ce1d8af75a 100644 --- a/tests/unit/executor-default-base.test.ts +++ b/tests/unit/executor-default-base.test.ts @@ -428,7 +428,7 @@ test("DefaultExecutor.transformRequest neutralizes incompatible tool_choice for const result = executor.transformRequest("qwen3-coder-plus", body, true, {}); assert.notEqual(result, body); - assert.equal(result.tool_choice, "auto"); + assert.equal((result as any).tool_choice, "auto"); }); test("DefaultExecutor.transformRequest applies GLMT preset defaults without overriding explicit values", () => { @@ -440,9 +440,9 @@ test("DefaultExecutor.transformRequest applies GLMT preset defaults without over const autoResult = executor.transformRequest("glm-5.1", autoBody, true, {}); assert.notEqual(autoResult, autoBody); - assert.equal(autoResult.max_tokens, 65536); - assert.equal(autoResult.temperature, 0.2); - assert.deepEqual(autoResult.thinking, { + assert.equal((autoResult as any).max_tokens, 65536); + (assert as any).equal((autoResult as any).temperature, 0.2); + (assert as any).deepEqual((autoResult as any).thinking, { type: "enabled", budget_tokens: 24576, }); @@ -456,9 +456,9 @@ test("DefaultExecutor.transformRequest applies GLMT preset defaults without over const explicitResult = executor.transformRequest("glm-5.1", explicitBody, true, {}); assert.notEqual(explicitResult, explicitBody); - assert.equal(explicitResult.max_tokens, 4096); - assert.equal(explicitResult.temperature, 0.7); - assert.deepEqual(explicitResult.thinking, { + assert.equal((explicitResult as any).max_tokens, 4096); + assert.equal((explicitResult as any).temperature, 0.7); + assert.deepEqual((explicitResult as any).thinking, { type: "enabled", budget_tokens: 4095, }); @@ -621,8 +621,8 @@ test("BaseExecutor.execute returns response metadata and merges headers", async assert.equal(result.url, "https://primary.example/v1/chat/completions"); assert.equal(result.response.status, 200); - assert.equal(result.transformedBody.transformed, true); - assert.equal(result.transformedBody.model, "gpt-4.1"); + (assert as any).equal((result.transformedBody as any).transformed, true); + assert.equal((result.transformedBody as any).model, "gpt-4.1"); assert.equal(result.headers.Authorization, "Bearer override"); assert.equal(result.headers["User-Agent"], "UpstreamAgent/2.0"); assert.equal(result.headers["user-agent"], undefined); diff --git a/tests/unit/executor-kiro.test.ts b/tests/unit/executor-kiro.test.ts index a886b4bef8..1a6bc1c2ee 100644 --- a/tests/unit/executor-kiro.test.ts +++ b/tests/unit/executor-kiro.test.ts @@ -110,7 +110,10 @@ test("KiroExecutor.transformRequest removes the top-level model field", () => { const result = executor.transformRequest("kiro-model", body, true, {}); assert.equal("model" in result, false); - assert.equal(result.conversationState.currentMessage.userInputMessage.modelId, "kiro-model"); + assert.equal( + (result as any).conversationState.currentMessage.userInputMessage.modelId, + "kiro-model" + ); }); test("KiroExecutor.transformEventStreamToSSE converts text, tool calls, usage and DONE", async () => { diff --git a/tests/unit/fetch-timeout.test.ts b/tests/unit/fetch-timeout.test.ts index b709643245..8e90d03927 100644 --- a/tests/unit/fetch-timeout.test.ts +++ b/tests/unit/fetch-timeout.test.ts @@ -64,9 +64,9 @@ test("fetchWithTimeout converts both pre-aborted and externally aborted requests }), (error) => { assert.equal(error instanceof mod.FetchTimeoutError, true); - assert.equal(error.timeoutMs, 9); - assert.equal(error.url, "https://example.test/pre-aborted"); - assert.match(error.message, /timed out after 9ms/); + assert.equal((error as any).timeoutMs, 9); + (assert as any).equal((error as any).url, "https://example.test/pre-aborted"); + (assert as any).match((error as any).message, /timed out after 9ms/); return true; } ); @@ -91,8 +91,8 @@ test("fetchWithTimeout converts both pre-aborted and externally aborted requests (error) => { assert.equal(fetchSawSignal, true); assert.equal(error instanceof mod.FetchTimeoutError, true); - assert.equal(error.timeoutMs, 15); - assert.equal(error.url, "https://example.test/external-abort"); + assert.equal((error as any).timeoutMs, 15); + assert.equal((error as any).url, "https://example.test/external-abort"); return true; } ); diff --git a/tests/unit/fixes-p1.test.ts b/tests/unit/fixes-p1.test.ts index 12133ed3fd..9fa9b92004 100644 --- a/tests/unit/fixes-p1.test.ts +++ b/tests/unit/fixes-p1.test.ts @@ -50,7 +50,7 @@ async function resetStorage() { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } break; - } catch (err) { + } catch (err: any) { if ((err?.code === "EBUSY" || err?.code === "EPERM") && attempt < 9) { await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); } else { @@ -140,7 +140,7 @@ test( const row = reopenedDb .prepare("SELECT COUNT(*) AS cnt FROM provider_connections WHERE id = ?") .get("restore-test-conn"); - assert.equal(row.cnt, 1); + assert.equal((row as any).cnt, 1); } ); @@ -164,7 +164,7 @@ test("closeDbInstance checkpoints WAL changes into the primary SQLite file", asy const row = snapshotDb .prepare("SELECT name FROM provider_connections WHERE id = ?") .get("checkpoint-test-conn"); - assert.equal(row?.name, "checkpoint-test"); + (assert as any).equal((row as any).name, "checkpoint-test"); } finally { snapshotDb.close(); } @@ -273,13 +273,13 @@ test("provider connection persists rateLimitProtection across reopen", async () apiKey: "sk-test", }); - await providersDb.updateProviderConnection(created.id, { rateLimitProtection: true }); + await providersDb.updateProviderConnection((created as any).id, { rateLimitProtection: true }); - const firstRead = await providersDb.getProviderConnectionById(created.id); + const firstRead = await providersDb.getProviderConnectionById((created as any).id); assert.equal(firstRead.rateLimitProtection, true); core.resetDbInstance(); - const secondRead = await providersDb.getProviderConnectionById(created.id); + const secondRead = await providersDb.getProviderConnectionById((created as any).id); assert.equal(secondRead.rateLimitProtection, true); }); @@ -336,7 +336,7 @@ test('provider connection migration adds "group" column for existing databases', const reopened = core.getDbInstance(); const columns = reopened.prepare("PRAGMA table_info(provider_connections)").all(); - const names = new Set(columns.map((column) => column.name)); + const names = new Set(columns.map((column) => (column as any).name)); assert.equal(names.has("group"), true); }); @@ -358,13 +358,13 @@ test("resolveProxyForConnection applies combo proxy for object/string model entr models: ["openai/gpt-5", { model: "cc/claude-sonnet-4-5-20250929", weight: 100 }], }); - await settingsDb.setProxyForLevel("combo", combo.id, { + await settingsDb.setProxyForLevel("combo", (combo as any).id, { type: "http", host: "127.0.0.1", port: "8080", }); - const resolved = await settingsDb.resolveProxyForConnection(conn.id); + const resolved = await settingsDb.resolveProxyForConnection((conn as any).id); assert.equal(resolved.level, "combo"); assert.equal(resolved.levelId, combo.id); }); @@ -464,7 +464,7 @@ test("proxy settings route blocks socks5 with backend flag disabled", async () = }); const response = await proxySettingsRoute.PUT(request); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 400); assert.match(payload.error.message, /SOCKS5 proxy is disabled/i); }); @@ -488,7 +488,7 @@ test("proxy settings route accepts socks5 with backend flag enabled", async () = }); const response = await proxySettingsRoute.PUT(request); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(payload.global.type, "socks5"); }); @@ -509,7 +509,7 @@ test("proxy test route rejects socks5 when backend flag is disabled", async () = }); const response = await proxyTestRoute.POST(request); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 400); assert.match(payload.error.message, /SOCKS5 proxy is disabled/i); @@ -531,7 +531,7 @@ test("proxy test route runs socks5 test when backend flag is enabled", async () }); const response = await proxyTestRoute.POST(request); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.notEqual(response.status, 400); assert.equal(payload.success, false); @@ -549,7 +549,7 @@ test("proxy test route validates JSON, schema, and proxy types before dispatchin body: "{", }) ); - const invalidJsonBody = await invalidJsonResponse.json(); + const invalidJsonBody = (await invalidJsonResponse.json()) as any; assert.equal(invalidJsonResponse.status, 400); assert.equal(invalidJsonBody.error.message, "Invalid JSON body"); @@ -560,7 +560,7 @@ test("proxy test route validates JSON, schema, and proxy types before dispatchin body: JSON.stringify({ proxy: { port: "8080" } }), }) ); - const invalidBody = await invalidBodyResponse.json(); + const invalidBody = (await invalidBodyResponse.json()) as any; assert.equal(invalidBodyResponse.status, 400); assert.equal(invalidBody.error.message, "Invalid request"); @@ -577,7 +577,7 @@ test("proxy test route validates JSON, schema, and proxy types before dispatchin }), }) ); - const socks4Body = await socks4Response.json(); + const socks4Body = (await socks4Response.json()) as any; assert.equal(socks4Response.status, 400); assert.match(socks4Body.error.message, /proxy\.type must be http or https/i); @@ -594,7 +594,7 @@ test("proxy test route validates JSON, schema, and proxy types before dispatchin }), }) ); - const unsupportedBody = await unsupportedResponse.json(); + const unsupportedBody = (await unsupportedResponse.json()) as any; assert.equal(unsupportedResponse.status, 400); assert.match(unsupportedBody.error.message, /proxy\.type must be http or https/i); }); @@ -615,7 +615,7 @@ test("proxy test route handles invalid proxy ports and uses stored proxy config }), }) ); - const invalidPortBody = await invalidPortResponse.json(); + const invalidPortBody = (await invalidPortResponse.json()) as any; assert.equal(invalidPortResponse.status, 400); assert.match(invalidPortBody.error.message, /invalid proxy port/i); @@ -641,7 +641,7 @@ test("proxy test route handles invalid proxy ports and uses stored proxy config }), }) ); - const proxyIdBody = await proxyIdResponse.json(); + const proxyIdBody = (await proxyIdResponse.json()) as any; assert.notEqual(proxyIdResponse.status, 400); assert.equal(proxyIdBody.success, false); assert.equal(proxyIdBody.proxyUrl, "http://127.0.0.1:1"); diff --git a/tests/unit/glm-provider-model-import-route.test.ts b/tests/unit/glm-provider-model-import-route.test.ts index 90b060acdc..c8d1dfb20e 100644 --- a/tests/unit/glm-provider-model-import-route.test.ts +++ b/tests/unit/glm-provider-model-import-route.test.ts @@ -113,7 +113,7 @@ test("GLM import uses China coding endpoint when apiRegion is china", async () = { params: { id: connection.id } } ); assert.equal(response.status, 200); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(body.provider, "glm"); assert.equal(body.models.length, 1); } finally { diff --git a/tests/unit/grok-web.test.ts b/tests/unit/grok-web.test.ts index 1c1573272f..4559ad1276 100644 --- a/tests/unit/grok-web.test.ts +++ b/tests/unit/grok-web.test.ts @@ -93,7 +93,7 @@ test("Non-streaming: simple response", async () => { log: null, }); assert.equal(result.response.status, 200); - const json = await result.response.json(); + const json = (await result.response.json()) as any; assert.equal(json.object, "chat.completion"); assert.equal(json.choices[0].message.role, "assistant"); assert.equal(json.choices[0].message.content, "Hello world!"); @@ -152,7 +152,7 @@ test("Error: 401 returns auth error", async () => { log: null, }); assert.equal(result.response.status, 401); - const json = await result.response.json(); + const json = (await result.response.json()) as any; assert.ok(json.error.message.includes("auth failed")); assert.ok(json.error.message.includes("sso")); } finally { @@ -173,7 +173,7 @@ test("Error: 429 returns rate limit message", async () => { log: null, }); assert.equal(result.response.status, 429); - const json = await result.response.json(); + const json = (await result.response.json()) as any; assert.ok(json.error.message.includes("rate limited")); } finally { restore(); @@ -206,7 +206,7 @@ test("Error: Grok stream error returns 502", async () => { log: null, }); assert.equal(result.response.status, 502); - const json = await result.response.json(); + const json = (await result.response.json()) as any; assert.ok(json.error.message.includes("Internal error")); } finally { restore(); diff --git a/tests/unit/guide-settings-route.test.ts b/tests/unit/guide-settings-route.test.ts index cb44150d98..d408e98d3b 100644 --- a/tests/unit/guide-settings-route.test.ts +++ b/tests/unit/guide-settings-route.test.ts @@ -40,7 +40,7 @@ test.afterEach(async () => { test("guide-settings POST creates new qwen settings.json if it doesn't exist", async () => { const req = buildRequest({ baseUrl: "http://my-omni", apiKey: "sk-123", model: "qwen-max" }); const response = (await guideSettingsRoute.POST(req, { params: { toolId: "qwen" } })) as Response; - const data = await response.json(); + const data = (await response.json()) as any; assert.equal(response.status, 200, "Response should be OK"); assert.equal(data.success, true); diff --git a/tests/unit/image-generation-route.test.ts b/tests/unit/image-generation-route.test.ts index 164eb93680..e3ec9e157f 100644 --- a/tests/unit/image-generation-route.test.ts +++ b/tests/unit/image-generation-route.test.ts @@ -44,13 +44,13 @@ test.after(() => { test("v1 image models GET exposes image-only modalities for image-only models", async () => { const response = await imageRoute.GET(); - const body = await response.json(); + const body = (await response.json()) as any; const byId = new Map(body.data.map((item: { id: string }) => [item.id, item])); assert.equal(response.status, 200); - assert.deepEqual(byId.get("topaz/topaz-enhance")?.input_modalities, ["image"]); - assert.deepEqual(byId.get("stability-ai/remove-background")?.input_modalities, ["image"]); - assert.deepEqual(byId.get("stability-ai/fast")?.input_modalities, ["image"]); + assert.deepEqual((byId.get("topaz/topaz-enhance") as any).input_modalities, ["image"]); + assert.deepEqual((byId.get("stability-ai/remove-background") as any).input_modalities, ["image"]); + assert.deepEqual((byId.get("stability-ai/fast") as any).input_modalities, ["image"]); }); test("v1 image generation POST accepts promptless requests for image-only models", async () => { @@ -89,7 +89,7 @@ test("v1 image generation POST accepts promptless requests for image-only models }), }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.data[0].b64_json, "BwcH"); @@ -106,7 +106,7 @@ test("v1 image generation POST still requires prompts for text-input models", as }), }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 400); assert.match(body.error.message, /Prompt is required for image model: openai\/dall-e-3/); diff --git a/tests/unit/log-retention.test.ts b/tests/unit/log-retention.test.ts index bf9510f12b..049a80abfe 100644 --- a/tests/unit/log-retention.test.ts +++ b/tests/unit/log-retention.test.ts @@ -127,12 +127,12 @@ test("cleanupExpiredLogs uses separate APP and CALL retention windows", () => { assert.equal(result.deletedMcpAuditLogs, 1); assert.deepEqual(compliance.getRetentionDays(), { app: 2, call: 1 }); - assert.equal(db.prepare("SELECT COUNT(*) AS cnt FROM usage_history").get().cnt, 1); - assert.equal(db.prepare("SELECT COUNT(*) AS cnt FROM call_logs").get().cnt, 1); - assert.equal(db.prepare("SELECT COUNT(*) AS cnt FROM proxy_logs").get().cnt, 1); - assert.equal(db.prepare("SELECT COUNT(*) AS cnt FROM request_detail_logs").get().cnt, 1); - assert.equal(db.prepare("SELECT COUNT(*) AS cnt FROM audit_log").get().cnt, 2); - assert.equal(db.prepare("SELECT COUNT(*) AS cnt FROM mcp_tool_audit").get().cnt, 1); + assert.equal((db.prepare("SELECT COUNT(*) AS cnt FROM usage_history").get() as any).cnt, 1); + assert.equal((db.prepare("SELECT COUNT(*) AS cnt FROM call_logs").get() as any).cnt, 1); + assert.equal((db.prepare("SELECT COUNT(*) AS cnt FROM proxy_logs").get() as any).cnt, 1); + assert.equal((db.prepare("SELECT COUNT(*) AS cnt FROM request_detail_logs").get() as any).cnt, 1); + assert.equal((db.prepare("SELECT COUNT(*) AS cnt FROM audit_log").get() as any).cnt, 2); + assert.equal((db.prepare("SELECT COUNT(*) AS cnt FROM mcp_tool_audit").get() as any).cnt, 1); }); test("cleanupExpiredLogs enforces row count limits", () => { @@ -166,8 +166,8 @@ test("cleanupExpiredLogs enforces row count limits", () => { ).run(`proxy-${i}`, now, "success", "direct", 1); } - assert.equal(db.prepare("SELECT COUNT(*) AS cnt FROM call_logs").get().cnt, 10); - assert.equal(db.prepare("SELECT COUNT(*) AS cnt FROM proxy_logs").get().cnt, 10); + assert.equal((db.prepare("SELECT COUNT(*) AS cnt FROM call_logs").get() as any).cnt, 10); + assert.equal((db.prepare("SELECT COUNT(*) AS cnt FROM proxy_logs").get() as any).cnt, 10); const result = compliance.cleanupExpiredLogs(); @@ -176,8 +176,8 @@ test("cleanupExpiredLogs enforces row count limits", () => { assert.equal(result.callLogsMaxRows, 5); assert.equal(result.proxyLogsMaxRows, 5); - assert.equal(db.prepare("SELECT COUNT(*) AS cnt FROM call_logs").get().cnt, 5); - assert.equal(db.prepare("SELECT COUNT(*) AS cnt FROM proxy_logs").get().cnt, 5); + assert.equal((db.prepare("SELECT COUNT(*) AS cnt FROM call_logs").get() as any).cnt, 5); + assert.equal((db.prepare("SELECT COUNT(*) AS cnt FROM proxy_logs").get() as any).cnt, 5); }); test("getCallLogsTableMaxRows returns configured value", async () => { diff --git a/tests/unit/login-bootstrap-route.test.ts b/tests/unit/login-bootstrap-route.test.ts index 440c2283c2..3ffa4f5e6e 100644 --- a/tests/unit/login-bootstrap-route.test.ts +++ b/tests/unit/login-bootstrap-route.test.ts @@ -42,7 +42,7 @@ test("public login bootstrap route exposes the metadata the login page consumes" }); const response = await route.GET(); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.deepEqual(body, { @@ -63,7 +63,7 @@ test("public login bootstrap route reports env-provided bootstrap password metad }); const response = await route.GET(); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.deepEqual(body, { @@ -83,7 +83,7 @@ test("public login bootstrap route reports stored password metadata and disabled }); const response = await route.GET(); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.deepEqual(body, { @@ -103,7 +103,7 @@ test("public login bootstrap route POST rejects invalid JSON bodies", async () = }); const response = await route.POST(request); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 400); assert.equal(body.error.message, "Invalid request"); @@ -118,7 +118,7 @@ test("public login bootstrap route POST rejects empty updates", async () => { }); const response = await route.POST(request); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 400); assert.equal(body.error.message, "Invalid request"); @@ -133,7 +133,7 @@ test("public login bootstrap route POST updates requireLogin without forcing pas }); const response = await route.POST(request); - const body = await response.json(); + const body = (await response.json()) as any; const settings = await settingsDb.getSettings(); assert.equal(response.status, 200); @@ -151,7 +151,7 @@ test("public login bootstrap route POST hashes and stores passwords", async () = }); const response = await route.POST(request); - const body = await response.json(); + const body = (await response.json()) as any; const settings = await settingsDb.getSettings(); assert.equal(response.status, 200); @@ -176,7 +176,7 @@ test("login bootstrap route POST rejects unauthenticated writes after setup is c }); const response = await route.POST(request); - const body = await response.json(); + const body = (await response.json()) as any; const settings = await settingsDb.getSettings(); assert.equal(response.status, 401); @@ -196,7 +196,7 @@ test("public login bootstrap route POST returns 500 when hashing fails", async ( }); const response = await route.POST(request); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 500); assert.deepEqual(body, { error: "hash failed" }); diff --git a/tests/unit/management-password.test.ts b/tests/unit/management-password.test.ts index 38db8a6872..7724888ec5 100644 --- a/tests/unit/management-password.test.ts +++ b/tests/unit/management-password.test.ts @@ -47,7 +47,10 @@ test("ensurePersistentManagementPasswordHash migrates INITIAL_PASSWORD into a pe assert.equal(managementPassword.isBcryptHash(settings.password), true); assert.notEqual(settings.password, "bootstrap-secret"); assert.equal( - await managementPassword.verifyManagementPassword("bootstrap-secret", settings.password), + await managementPassword.verifyManagementPassword( + "bootstrap-secret", + (settings as any).password + ), true ); assert.equal(settings.requireLogin, true); @@ -71,7 +74,10 @@ test("ensurePersistentManagementPasswordHash migrates legacy plaintext settings assert.equal(managementPassword.isBcryptHash(settings.password), true); assert.notEqual(settings.password, "legacy-password"); assert.equal( - await managementPassword.verifyManagementPassword("legacy-password", settings.password), + await managementPassword.verifyManagementPassword( + "legacy-password" as any, + (settings as any).password + ), true ); }); diff --git a/tests/unit/memory-route.test.ts b/tests/unit/memory-route.test.ts index 448318228f..f7dccce349 100644 --- a/tests/unit/memory-route.test.ts +++ b/tests/unit/memory-route.test.ts @@ -65,7 +65,7 @@ test("GET /api/memory filters by q and returns matching stats", async () => { ); assert.equal(response.status, 200); - const body = await response.json(); + const body = (await response.json()) as any; assert.deepEqual( body.data.map((memory) => memory.key), @@ -84,7 +84,7 @@ test("GET /api/memory continues to honor limit+offset requests", async () => { ); assert.equal(response.status, 200); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(body.data.length, 1); assert.equal(body.total, 3); diff --git a/tests/unit/memory-store.test.ts b/tests/unit/memory-store.test.ts index 79d65fcf26..de7009b5be 100644 --- a/tests/unit/memory-store.test.ts +++ b/tests/unit/memory-store.test.ts @@ -20,7 +20,7 @@ async function resetStorage() { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } break; - } catch (error) { + } catch (error: any) { if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) { await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); } else { diff --git a/tests/unit/memory-summarization.test.ts b/tests/unit/memory-summarization.test.ts index 22cab54c88..381e1a22bb 100644 --- a/tests/unit/memory-summarization.test.ts +++ b/tests/unit/memory-summarization.test.ts @@ -19,7 +19,7 @@ async function resetStorage() { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } break; - } catch (error) { + } catch (error: any) { if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) { await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); } else { @@ -53,7 +53,7 @@ function insertMemory({ id, apiKeyId = "key-a", sessionId = "session-a", content function getContent(id) { const db = core.getDbInstance(); - return db.prepare("SELECT content FROM memories WHERE id = ?").get(id)?.content ?? null; + return (db.prepare("SELECT content FROM memories WHERE id = ?").get(id) as any).content ?? null; } test.beforeEach(async () => { diff --git a/tests/unit/messages-count-tokens-route.test.ts b/tests/unit/messages-count-tokens-route.test.ts index 25efb03f28..5bda3cc7b4 100644 --- a/tests/unit/messages-count-tokens-route.test.ts +++ b/tests/unit/messages-count-tokens-route.test.ts @@ -68,7 +68,7 @@ test("messages/count_tokens uses real provider count when Claude-compatible upst ); assert.equal(response.status, 200); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(body.input_tokens, 321); assert.equal(body.source, "provider"); assert.equal(body.provider, "anthropic"); @@ -96,7 +96,7 @@ test("messages/count_tokens falls back to estimate when model is missing", async ); assert.equal(response.status, 200); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(body.input_tokens, 3); assert.equal(body.source, "estimated"); }); @@ -120,7 +120,7 @@ test("messages/count_tokens falls back to estimate when real upstream count fail ); assert.equal(response.status, 200); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(body.input_tokens, 1); assert.equal(body.source, "estimated"); } finally { diff --git a/tests/unit/model-alias-route.test.ts b/tests/unit/model-alias-route.test.ts index b3b512e654..4682bdd25f 100644 --- a/tests/unit/model-alias-route.test.ts +++ b/tests/unit/model-alias-route.test.ts @@ -40,7 +40,7 @@ test("model alias route resolves a stored alias and emits diagnostics headers", headers: { "x-request-id": "req-model-alias-1" }, }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(response.headers.get("X-Request-Id"), "req-model-alias-1"); @@ -54,7 +54,7 @@ test("model alias route returns typed ambiguity errors for ambiguous aliases", a const response = await route.GET( await makeManagementSessionRequest("http://localhost/api/models/alias?alias=claude-sonnet-4-6") ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 409); assert.equal(body.error.code, "MODEL_ALIAS_AMBIGUOUS"); @@ -75,8 +75,8 @@ test("model alias route requires a dashboard session when management auth is ena }) ); - const unauthenticatedBody = await unauthenticated.json(); - const invalidTokenBody = await invalidToken.json(); + const unauthenticatedBody = (await unauthenticated.json()) as any; + const invalidTokenBody = (await invalidToken.json()) as any; assert.equal(unauthenticated.status, 401); assert.equal(unauthenticatedBody.error.message, "Authentication required"); @@ -91,7 +91,7 @@ test("api models catalog route reuses the unified catalog diagnostics headers", headers: { "x-request-id": "req-model-catalog-1" }, }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(response.headers.get("X-Request-Id"), "req-model-catalog-1"); @@ -106,7 +106,7 @@ test("v1 models catalog emits diagnostics headers alongside the OpenAI-compatibl headers: { "x-request-id": "req-v1-models-1" }, }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(response.headers.get("X-Request-Id"), "req-v1-models-1"); diff --git a/tests/unit/model-sync-route.test.ts b/tests/unit/model-sync-route.test.ts index 829b1c9137..320670e36d 100644 --- a/tests/unit/model-sync-route.test.ts +++ b/tests/unit/model-sync-route.test.ts @@ -79,7 +79,7 @@ test("model sync route skips success log when fetched models do not change store ); assert.equal(response.status, 200); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(body.logged, false); assert.deepEqual(body.modelChanges, { added: 0, removed: 0, updated: 0, total: 0 }); @@ -119,7 +119,7 @@ test("model sync route stores the real provider while keeping the account label" ); assert.equal(response.status, 200); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(body.logged, true); assert.deepEqual(body.modelChanges, { added: 1, removed: 0, updated: 0, total: 1 }); assert.equal(body.provider, "openrouter"); @@ -151,7 +151,7 @@ test("model sync route requires authentication for external requests when auth i }), { params: { id: connection.id } } ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 401); assert.equal(body.error.message, "Authentication required"); @@ -195,7 +195,7 @@ test("model sync route propagates upstream failures and records an error log ent }), { params: { id: connection.id } } ); - const body = await response.json(); + const body = (await response.json()) as any; const logs = await callLogs.getCallLogs({ model: "model-sync", limit: 10 }); assert.equal(response.status, 502); @@ -228,7 +228,7 @@ test("model sync route falls back to the upstream HTTP status when the models pa }), { params: { id: connection.id } } ); - const body = await response.json(); + const body = (await response.json()) as any; const logs = await callLogs.getCallLogs({ model: "model-sync", limit: 10 }); assert.equal(response.status, 429); @@ -268,7 +268,7 @@ test("model sync route preserves previously synced models when the upstream omit }), { params: { id: connection.id } } ); - const body = await response.json(); + const body = (await response.json()) as any; const logs = await callLogs.getCallLogs({ model: "model-sync", limit: 10 }); assert.equal(response.status, 200); @@ -321,7 +321,7 @@ test("model sync route writes synced available models for Gemini connections", a }), { params: { id: connection.id } } ); - const body = await response.json(); + const body = (await response.json()) as any; const synced = await modelsDb.getSyncedAvailableModels("gemini"); const logs = await callLogs.getCallLogs({ model: "model-sync", limit: 10 }); @@ -407,7 +407,7 @@ test("model sync route records added, removed, and updated model diffs with fall }), { params: { id: connection.id } } ); - const body = await response.json(); + const body = (await response.json()) as any; const logs = await callLogs.getCallLogs({ model: "model-sync", limit: 10 }); assert.equal(response.status, 200); @@ -484,7 +484,7 @@ test("model sync route forwards cookies, filters built-ins, and syncs aliases fo }), { params: { id: connection.id } } ); - const body = await response.json(); + const body = (await response.json()) as any; const aliases = await localDb.getModelAliases(); const logs = await callLogs.getCallLogs({ model: "model-sync", limit: 10 }); @@ -549,7 +549,7 @@ test("model sync route uses provider-node prefixes when syncing compatible-provi }), { params: { id: connection.id } } ); - const body = await response.json(); + const body = (await response.json()) as any; const aliases = await localDb.getModelAliases(); assert.equal(response.status, 200); @@ -579,7 +579,7 @@ test("model sync route returns 500 and records a failure when the internal model }), { params: { id: connection.id } } ); - const body = await response.json(); + const body = (await response.json()) as any; const logs = await callLogs.getCallLogs({ model: "model-sync", limit: 10 }); assert.equal(response.status, 500); diff --git a/tests/unit/model-sync-scheduler.test.ts b/tests/unit/model-sync-scheduler.test.ts index 547e7dbf26..ecdadf22d3 100644 --- a/tests/unit/model-sync-scheduler.test.ts +++ b/tests/unit/model-sync-scheduler.test.ts @@ -21,7 +21,7 @@ async function resetStorage() { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } break; - } catch (error) { + } catch (error: any) { if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) { await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); } else { diff --git a/tests/unit/models-catalog-route.test.ts b/tests/unit/models-catalog-route.test.ts index 1924043f42..e541353786 100644 --- a/tests/unit/models-catalog-route.test.ts +++ b/tests/unit/models-catalog-route.test.ts @@ -56,7 +56,7 @@ test("v1 models catalog requires auth when the route is protected and login is e const response = await v1ModelsCatalog.getUnifiedModelsResponse( new Request("http://localhost/api/v1/models") ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 401); assert.equal(body.error.code, "invalid_api_key"); @@ -87,7 +87,7 @@ test("v1 models catalog accepts bearer API keys and filters the list by allowed headers: { Authorization: `Bearer ${key.key}` }, }) ); - const body = await response.json(); + const body = (await response.json()) as any; const ids = body.data.map((item) => item.id); assert.equal(response.status, 200); @@ -115,13 +115,13 @@ test("v1 models catalog hides models excluded by every active connection while k let response = await v1ModelsCatalog.getUnifiedModelsResponse( new Request("http://localhost/api/v1/models") ); - let body = await response.json(); + let body = (await response.json()) as any; let ids = new Set(body.data.map((item) => item.id)); assert.equal(response.status, 200); assert.equal(ids.has("openai/gpt-4o-mini"), true); - await providersDb.updateProviderConnection(second.id, { + await providersDb.updateProviderConnection((second as any).id, { providerSpecificData: { excludedModels: ["gpt-4o*"], }, @@ -130,13 +130,13 @@ test("v1 models catalog hides models excluded by every active connection while k response = await v1ModelsCatalog.getUnifiedModelsResponse( new Request("http://localhost/api/v1/models") ); - body = await response.json(); + body = (await response.json()) as any; ids = new Set(body.data.map((item) => item.id)); assert.equal(response.status, 200); assert.equal(ids.has("openai/gpt-4o-mini"), false); - await providersDb.updateProviderConnection(first.id, { + await providersDb.updateProviderConnection((first as any).id, { providerSpecificData: { excludedModels: [], }, @@ -172,7 +172,7 @@ test("v1 models catalog includes combos and custom models while excluding hidden const response = await v1ModelsCatalog.getUnifiedModelsResponse( new Request("http://localhost/api/v1/models") ); - const body = await response.json(); + const body = (await response.json()) as any; const ids = new Set(body.data.map((item) => item.id)); assert.equal(response.status, 200); @@ -181,7 +181,7 @@ test("v1 models catalog includes combos and custom models while excluding hidden assert.ok(ids.has("kiro/custom-kiro")); assert.equal(ids.has("openai/gpt-4o-mini"), false); assert.equal( - [...ids].some((id) => id.startsWith("claude/") || id.startsWith("cc/")), + [...ids].some((id) => (id as any).startsWith("claude/") || (id as any).startsWith("cc/")), false ); }); @@ -192,7 +192,7 @@ test("v1 models catalog keeps only visible combos when no providers are active", strategy: "priority", models: ["openai/gpt-4o"], }); - await combosDb.updateCombo(visible.id, { context_length: 32000 }); + await combosDb.updateCombo((visible as any).id, { context_length: 32000 }); const hidden = await combosDb.createCombo({ name: "hidden-combo", strategy: "priority", @@ -204,12 +204,12 @@ test("v1 models catalog keeps only visible combos when no providers are active", strategy: "priority", models: ["openai/gpt-4o"], }); - await combosDb.updateCombo(inactive.id, { isActive: false }); + await combosDb.updateCombo((inactive as any).id, { isActive: false }); const response = await v1ModelsCatalog.getUnifiedModelsResponse( new Request("http://localhost/api/v1/models") ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.deepEqual( @@ -238,7 +238,7 @@ test("v1 models catalog exposes claude alias and provider-prefixed built-in mode const response = await v1ModelsCatalog.getUnifiedModelsResponse( new Request("http://localhost/api/v1/models") ); - const body = await response.json(); + const body = (await response.json()) as any; const aliasModel = body.data.find((item) => item.id === "cc/claude-sonnet-4-6"); const providerModel = body.data.find((item) => item.id === "claude/claude-sonnet-4-6"); @@ -262,7 +262,7 @@ test("v1 models catalog exposes Antigravity client-visible preview aliases inste const response = await v1ModelsCatalog.getUnifiedModelsResponse( new Request("http://localhost/api/v1/models") ); - const body = await response.json(); + const body = (await response.json()) as any; const ids = new Set(body.data.map((item) => item.id)); assert.equal(response.status, 200); @@ -294,7 +294,7 @@ test("v1 models catalog uses provider-node prefixes for compatible provider cust const response = await v1ModelsCatalog.getUnifiedModelsResponse( new Request("http://localhost/api/v1/models") ); - const body = await response.json(); + const body = (await response.json()) as any; const ids = new Set(body.data.map((item) => item.id)); assert.equal(response.status, 200); @@ -308,34 +308,38 @@ test("v1 models catalog includes synced Gemini models and duplicates audio model apiKey: "gm-key", }); - await modelsDb.replaceSyncedAvailableModelsForConnection("gemini", connection.id, [ - { - id: "gemini-audio-live", - name: "Gemini Audio Live", - source: "api-sync", - supportedEndpoints: ["audio"], - inputTokenLimit: 4096, - }, - { - id: "text-embedding-004", - name: "Text Embedding 004", - source: "api-sync", - supportedEndpoints: ["embeddings"], - inputTokenLimit: 2048, - }, - { - id: "gemini-hidden", - name: "Gemini Hidden", - source: "api-sync", - supportedEndpoints: ["chat"], - }, - ]); + await modelsDb.replaceSyncedAvailableModelsForConnection( + "gemini" as any, + (connection as any).id, + [ + { + id: "gemini-audio-live", + name: "Gemini Audio Live", + source: "api-sync", + supportedEndpoints: ["audio"], + inputTokenLimit: 4096, + }, + { + id: "text-embedding-004", + name: "Text Embedding 004", + source: "api-sync", + supportedEndpoints: ["embeddings"], + inputTokenLimit: 2048, + }, + { + id: "gemini-hidden", + name: "Gemini Hidden", + source: "api-sync", + supportedEndpoints: ["chat"], + }, + ] + ); modelsDb.mergeModelCompatOverride("gemini", "gemini-hidden", { isHidden: true }); const response = await v1ModelsCatalog.getUnifiedModelsResponse( new Request("http://localhost/api/v1/models") ); - const body = await response.json(); + const body = (await response.json()) as any; const audioVariants = body.data.filter((item) => item.id === "gemini/gemini-audio-live"); const embedding = body.data.find((item) => item.id === "gemini/text-embedding-004"); @@ -355,7 +359,7 @@ test("v1 models catalog keeps Gemini chat models untyped when synced endpoints a apiKey: "gm-chat-key", }); - await modelsDb.replaceSyncedAvailableModelsForConnection("gemini", connection.id, [ + await modelsDb.replaceSyncedAvailableModelsForConnection("gemini", (connection as any).id, [ { id: "gemini-2.5-pro-live", name: "Gemini 2.5 Pro Live", @@ -367,7 +371,7 @@ test("v1 models catalog keeps Gemini chat models untyped when synced endpoints a const response = await v1ModelsCatalog.getUnifiedModelsResponse( new Request("http://localhost/api/v1/models") ); - const body = await response.json(); + const body = (await response.json()) as any; const chatModel = body.data.find((item) => item.id === "gemini/gemini-2.5-pro-live"); assert.equal(response.status, 200); @@ -389,17 +393,17 @@ test("v1 models catalog includes media, moderation, rerank, video, and music mod const response = await v1ModelsCatalog.getUnifiedModelsResponse( new Request("http://localhost/api/v1/models") ); - const body = await response.json(); + const body = (await response.json()) as any; const byId = new Map(body.data.map((item) => [item.id, item])); assert.equal(response.status, 200); - assert.equal(byId.get("openai/gpt-image-1")?.type, "image"); - assert.equal(byId.get("openai/whisper-1")?.type, "audio"); - assert.equal(byId.get("openai/whisper-1")?.subtype, "transcription"); - assert.equal(byId.get("openai/omni-moderation-latest")?.type, "moderation"); - assert.equal(byId.get("cohere/rerank-v3.5")?.type, "rerank"); - assert.equal(byId.get("comfyui/animatediff")?.type, "video"); - assert.equal(byId.get("comfyui/stable-audio-open")?.type, "music"); + assert.equal((byId.get("openai/gpt-image-1") as any).type, "image"); + assert.equal((byId.get("openai/whisper-1") as any).type, "audio"); + assert.equal((byId.get("openai/whisper-1") as any).subtype, "transcription"); + assert.equal((byId.get("openai/omni-moderation-latest") as any).type, "moderation"); + assert.equal((byId.get("cohere/rerank-v3.5") as any).type, "rerank"); + assert.equal((byId.get("comfyui/animatediff") as any).type, "video"); + assert.equal((byId.get("comfyui/stable-audio-open") as any).type, "music"); }); test("v1 models catalog exposes image model input and output modalities for advanced image providers", async () => { @@ -409,16 +413,16 @@ test("v1 models catalog exposes image model input and output modalities for adva const response = await v1ModelsCatalog.getUnifiedModelsResponse( new Request("http://localhost/api/v1/models") ); - const body = await response.json(); + const body = (await response.json()) as any; const byId = new Map(body.data.map((item) => [item.id, item])); assert.equal(response.status, 200); - assert.deepEqual(byId.get("flux-redux")?.input_modalities, ["text", "image"]); - assert.deepEqual(byId.get("flux-redux")?.output_modalities, ["image"]); - assert.equal(byId.get("flux-redux")?.type, "image"); - assert.ok(byId.get("flux-redux")?.supported_sizes?.includes("1024x1024")); - assert.deepEqual(byId.get("topaz/topaz-enhance")?.input_modalities, ["image"]); - assert.deepEqual(byId.get("topaz/topaz-enhance")?.output_modalities, ["image"]); + assert.deepEqual((byId as any).get("flux-redux")?.input_modalities, ["text", "image"]); + (assert as any).deepEqual((byId.get("flux-redux") as any).output_modalities, ["image"]); + (assert as any).equal((byId.get("flux-redux") as any).type, "image"); + assert.ok((byId.get("flux-redux") as any).supported_sizes?.includes("1024x1024")); + (assert as any).deepEqual((byId.get("topaz/topaz-enhance") as any).input_modalities, ["image"]); + assert.deepEqual((byId.get("topaz/topaz-enhance") as any).output_modalities, ["image"]); }); test("v1 models catalog tolerates custom model lookup failures and keeps builtin models available", async () => { @@ -443,7 +447,7 @@ test("v1 models catalog tolerates custom model lookup failures and keeps builtin const response = await v1ModelsCatalog.getUnifiedModelsResponse( new Request("http://localhost/api/v1/models") ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.ok(body.data.some((item) => item.id === "openai/gpt-4o")); @@ -504,7 +508,7 @@ test("v1 models catalog exposes provider-prefixed custom models, filters by raw headers: { Authorization: `Bearer ${key.key}` }, }) ); - const body = await response.json(); + const body = (await response.json()) as any; const ids = new Set(body.data.map((item) => item.id)); const shortAlias = body.data.find((item) => item.id === "cl/demo-custom"); const providerAlias = body.data.find((item) => item.id === "cline/demo-custom"); @@ -557,7 +561,7 @@ test("v1 models catalog returns 500 when model compatibility lookup crashes", as const response = await v1ModelsCatalog.getUnifiedModelsResponse( new Request("http://localhost/api/v1/models") ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 500); assert.equal(body.error.type, "server_error"); @@ -585,7 +589,7 @@ test("v1 models catalog skips duplicate built-ins and custom models from inactiv const response = await v1ModelsCatalog.getUnifiedModelsResponse( new Request("http://localhost/api/v1/models") ); - const body = await response.json(); + const body = (await response.json()) as any; const duplicateBuiltins = body.data.filter((item) => item.id === "openai/gpt-4o"); assert.equal(response.status, 200); @@ -622,7 +626,7 @@ test("v1 models catalog adds managed fallback models for Claude-compatible provi const response = await v1ModelsCatalog.getUnifiedModelsResponse( new Request("http://localhost/api/v1/models") ); - const body = await response.json(); + const body = (await response.json()) as any; const ids = new Set(body.data.map((item) => item.id)); assert.equal(response.status, 200); diff --git a/tests/unit/modelsDevSync-extended.test.ts b/tests/unit/modelsDevSync-extended.test.ts index aac461f652..b422aac182 100644 --- a/tests/unit/modelsDevSync-extended.test.ts +++ b/tests/unit/modelsDevSync-extended.test.ts @@ -130,8 +130,8 @@ test.beforeEach(async () => { test.afterEach(async () => { for (const mod of loadedModules) { - if (typeof mod.stopPeriodicSync === "function") { - mod.stopPeriodicSync(); + if (typeof (mod as any).stopPeriodicSync === "function") { + (mod as any).stopPeriodicSync(); } } loadedModules.clear(); diff --git a/tests/unit/moderations-handler.test.ts b/tests/unit/moderations-handler.test.ts index 54fcb2bc5f..f3b5378b7e 100644 --- a/tests/unit/moderations-handler.test.ts +++ b/tests/unit/moderations-handler.test.ts @@ -14,7 +14,7 @@ test("handleModeration requires input", async () => { body: { model: "openai/omni-moderation-latest" }, credentials: { apiKey: "sk-test" }, }); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 400); assert.equal(payload.error.message, "input is required"); @@ -25,7 +25,7 @@ test("handleModeration rejects unknown moderation models", async () => { body: { model: "mystery/moderation", input: "hello" }, credentials: { apiKey: "sk-test" }, }); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 400); assert.match(payload.error.message, /No moderation provider found/); @@ -36,7 +36,7 @@ test("handleModeration requires credentials for the resolved provider", async () body: { input: "hello" }, credentials: null, }); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 401); assert.equal(payload.error.message, "No credentials for moderation provider: openai"); @@ -104,7 +104,7 @@ test("handleModeration returns a 500 when the upstream request throws", async () body: { model: "openai/text-moderation-latest", input: "check this" }, credentials: { apiKey: "sk-test" }, }); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 500); assert.match(payload.error.message, /Moderation request failed: socket closed/); diff --git a/tests/unit/nanobanana-image-generation.test.ts b/tests/unit/nanobanana-image-generation.test.ts index d2d10d72a6..fc01c1ebc5 100644 --- a/tests/unit/nanobanana-image-generation.test.ts +++ b/tests/unit/nanobanana-image-generation.test.ts @@ -85,7 +85,7 @@ test("nanobanana async flow (submit->poll->url) normalizes to OpenAI-style url i headers: { "Content-Type": "application/json", Authorization: "Bearer test" }, body: JSON.stringify({ prompt: "space" }), }); - const submitData = await submit.json(); + const submitData = (await submit.json()) as any; const taskId = submitData.data.taskId; let finalData; @@ -93,7 +93,7 @@ test("nanobanana async flow (submit->poll->url) normalizes to OpenAI-style url i const poll = await fetch( `https://api.nanobananaapi.ai/api/v1/nanobanana/record-info?taskId=${encodeURIComponent(taskId)}` ); - const pollData = await poll.json(); + const pollData = (await poll.json()) as any; if (pollData.data.successFlag === 1) { finalData = pollData.data; break; diff --git a/tests/unit/observability-fase04.test.ts b/tests/unit/observability-fase04.test.ts index 5a52b5b1eb..1d86b11b34 100644 --- a/tests/unit/observability-fase04.test.ts +++ b/tests/unit/observability-fase04.test.ts @@ -123,7 +123,7 @@ test("requestTimeout: withTimeout resolves before timeout", async () => { test("requestTimeout: withTimeout rejects on timeout", async () => { await assert.rejects( () => withTimeout(() => new Promise((r) => setTimeout(r, 500)), 10, "slow-op"), - (err) => err.name === "TimeoutError" + (err) => (err as any).name === "TimeoutError" ); }); diff --git a/tests/unit/openapi-spec-route.test.ts b/tests/unit/openapi-spec-route.test.ts index c70979470b..5b133e6aa8 100644 --- a/tests/unit/openapi-spec-route.test.ts +++ b/tests/unit/openapi-spec-route.test.ts @@ -7,7 +7,7 @@ test("openapi spec route resolves the repository spec file and returns a parsed const response = await GET(); assert.equal(response.status, 200); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(typeof payload.info, "object"); assert.ok(Array.isArray(payload.endpoints)); assert.ok(Array.isArray(payload.schemas)); diff --git a/tests/unit/orphaned-tool-filter.test.ts b/tests/unit/orphaned-tool-filter.test.ts index fb91017f4f..626f6807f4 100644 --- a/tests/unit/orphaned-tool-filter.test.ts +++ b/tests/unit/orphaned-tool-filter.test.ts @@ -18,7 +18,7 @@ test("openaiResponsesToOpenAIRequest: filters orphaned tool messages", () => { ], }; const result = openaiResponsesToOpenAIRequest("gpt-4", body, true, null); - const toolMessages = result.messages.filter((m) => m.role === "tool"); + const toolMessages = (result as any).messages.filter((m) => m.role === "tool"); assert.equal(toolMessages.length, 1, "should have exactly 1 tool message"); assert.equal(toolMessages[0].tool_call_id, "call_valid_1"); }); @@ -35,7 +35,7 @@ test("openaiResponsesToOpenAIRequest: preserves all messages when no orphans", ( ], }; const result = openaiResponsesToOpenAIRequest("gpt-4", body, true, null); - const toolMessages = result.messages.filter((m) => m.role === "tool"); + const toolMessages = (result as any).messages.filter((m) => m.role === "tool"); assert.equal(toolMessages.length, 2, "both valid tool results should be preserved"); }); @@ -56,7 +56,7 @@ test("openaiToOpenAIResponsesRequest: filters orphaned function_call_output", () ], }; const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, null); - const outputs = result.input.filter((i) => i.type === "function_call_output"); + const outputs = (result as any).input.filter((i) => i.type === "function_call_output"); assert.equal(outputs.length, 1, "should have exactly 1 function_call_output"); assert.equal(outputs[0].call_id, "call_valid_2"); }); @@ -74,7 +74,7 @@ test("openaiToOpenAIResponsesRequest: preserves all items when no orphans", () = ], }; const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, null); - const outputs = result.input.filter((i) => i.type === "function_call_output"); + const outputs = (result as any).input.filter((i) => i.type === "function_call_output"); assert.equal(outputs.length, 1, "valid function_call_output should be preserved"); }); diff --git a/tests/unit/payload-rules-route.test.ts b/tests/unit/payload-rules-route.test.ts index a846c1e441..9db6cf0117 100644 --- a/tests/unit/payload-rules-route.test.ts +++ b/tests/unit/payload-rules-route.test.ts @@ -59,7 +59,7 @@ test.after(async () => { test("payload rules route returns the neutral config by default", async () => { const response = await route.GET(new Request("http://localhost/api/settings/payload-rules")); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.deepEqual(body, { @@ -85,9 +85,9 @@ test("payload rules route requires a dashboard session when management auth is e await makeManagementSessionRequest("http://localhost/api/settings/payload-rules") ); - const unauthenticatedBody = await unauthenticated.json(); - const invalidTokenBody = await invalidToken.json(); - const authenticatedBody = await authenticated.json(); + const unauthenticatedBody = (await unauthenticated.json()) as any; + const invalidTokenBody = (await invalidToken.json()) as any; + const authenticatedBody = (await authenticated.json()) as any; assert.equal(unauthenticated.status, 401); assert.equal(unauthenticatedBody.error.message, "Authentication required"); @@ -140,7 +140,7 @@ test("payload rules route persists normalized config and hot reloads the runtime body: requestBody, }) ); - const body = await response.json(); + const body = (await response.json()) as any; const settings = await settingsDb.getSettings(); const runtimeConfig = await payloadRulesService.getPayloadRulesConfig(); @@ -172,8 +172,8 @@ test("payload rules route rejects malformed and schema-invalid payloads", async }) ); - const invalidJsonBody = await invalidJson.json(); - const invalidSchemaBody = await invalidSchema.json(); + const invalidJsonBody = (await invalidJson.json()) as any; + const invalidSchemaBody = (await invalidSchema.json()) as any; const runtimeConfig = await payloadRulesService.getPayloadRulesConfig(); assert.equal(invalidJson.status, 400); diff --git a/tests/unit/payload-rules.test.ts b/tests/unit/payload-rules.test.ts index 29ccc032b9..1e059d4402 100644 --- a/tests/unit/payload-rules.test.ts +++ b/tests/unit/payload-rules.test.ts @@ -71,10 +71,10 @@ test("payload rules apply default, default-raw, override, and filter operations" ); assert.equal(payload.temperature, 0.4); - assert.equal(payload.metadata.routeTag, "feature-110"); + assert.equal((payload.metadata as any).routeTag, "feature-110"); assert.equal("removeMe" in payload.metadata, false); assert.equal("dangerous" in payload, false); - assert.equal(payload.reasoning.effort, "high"); + assert.equal((payload as any).reasoning.effort, "high"); assert.deepEqual(payload.response_format, { type: "json_schema", json_schema: { diff --git a/tests/unit/perplexity-web.test.ts b/tests/unit/perplexity-web.test.ts index 851e6b5a17..ea10768ce6 100644 --- a/tests/unit/perplexity-web.test.ts +++ b/tests/unit/perplexity-web.test.ts @@ -104,7 +104,7 @@ test("Non-streaming: simple text response", async () => { }); assert.equal(result.response.status, 200); - const json = await result.response.json(); + const json = (await result.response.json()) as any; assert.equal(json.object, "chat.completion"); assert.equal(json.choices[0].message.role, "assistant"); assert.equal(json.choices[0].message.content, "Hello, world!"); @@ -144,7 +144,7 @@ test("Non-streaming: strips citations from response", async () => { log: null, }); - const json = await result.response.json(); + const json = (await result.response.json()) as any; assert.ok(!json.choices[0].message.content.includes("[1]")); assert.ok(!json.choices[0].message.content.includes("[2]")); assert.ok(!json.choices[0].message.content.includes("[3]")); @@ -292,7 +292,7 @@ test("Error: 401 returns auth error message", async () => { }); assert.equal(result.response.status, 401); - const json = await result.response.json(); + const json = (await result.response.json()) as any; assert.ok(json.error.message.includes("auth failed")); assert.ok(json.error.message.includes("session-token")); } finally { @@ -314,7 +314,7 @@ test("Error: 429 returns rate limit message", async () => { }); assert.equal(result.response.status, 429); - const json = await result.response.json(); + const json = (await result.response.json()) as any; assert.ok(json.error.message.includes("rate limited")); } finally { restore(); @@ -335,7 +335,7 @@ test("Error: fetch failure returns 502", async () => { }); assert.equal(result.response.status, 502); - const json = await result.response.json(); + const json = (await result.response.json()) as any; assert.ok(json.error.message.includes("ECONNREFUSED")); } finally { restore(); @@ -354,7 +354,7 @@ test("Error: empty messages returns 400", async () => { }); assert.equal(result.response.status, 400); - const json = await result.response.json(); + const json = (await result.response.json()) as any; assert.ok(json.error.message.includes("Missing or empty messages")); }); @@ -390,7 +390,7 @@ test("Non-streaming: Perplexity stream error returns 502", async () => { }); assert.equal(result.response.status, 502); - const json = await result.response.json(); + const json = (await result.response.json()) as any; assert.ok(json.error.message.includes("Too many requests")); } finally { restore(); @@ -700,7 +700,7 @@ test("Non-streaming: falls back to text field when no blocks", async () => { log: null, }); - const json = await result.response.json(); + const json = (await result.response.json()) as any; assert.ok(json.choices[0].message.content.includes("Fallback answer text")); } finally { restore(); diff --git a/tests/unit/plan3-p0.test.ts b/tests/unit/plan3-p0.test.ts index 133800e738..a8faa12570 100644 --- a/tests/unit/plan3-p0.test.ts +++ b/tests/unit/plan3-p0.test.ts @@ -346,15 +346,18 @@ test("translateNonStreamingResponse converts Responses API payload to OpenAI cha FORMATS.OPENAI ); - assert.equal(translated.object, "chat.completion"); - assert.equal(translated.model, "gpt-5.1-codex"); - assert.equal(translated.choices[0].message.role, "assistant"); - assert.equal(translated.choices[0].message.content, "Hello from responses API."); - assert.equal(translated.choices[0].finish_reason, "tool_calls"); - assert.equal(translated.choices[0].message.tool_calls.length, 1); - assert.equal(translated.usage.prompt_tokens, 11); - assert.equal(translated.usage.completion_tokens, 7); - assert.equal(translated.usage.total_tokens, 18); + assert.equal((translated as any).object, "chat.completion"); + assert.equal((translated as any).model, "gpt-5.1-codex"); + (assert as any).equal((translated as any).choices[0].message.role, "assistant"); + (assert as any).equal( + (translated as any).choices[0].message.content, + "Hello from responses API." + ); + assert.equal((translated as any).choices[0].finish_reason, "tool_calls"); + assert.equal(((translated as any).choices[0].message.tool_calls as any).length, 1); + assert.equal(((translated as any).usage as any).prompt_tokens, 11); + assert.equal((translated as any).usage.completion_tokens, 7); + assert.equal((translated as any).usage.total_tokens, 18); }); test("extractUsageFromResponse reads usage from Responses API payload", () => { diff --git a/tests/unit/prompt-injection-guard.test.ts b/tests/unit/prompt-injection-guard.test.ts index b0975950f5..7d177f6ea2 100644 --- a/tests/unit/prompt-injection-guard.test.ts +++ b/tests/unit/prompt-injection-guard.test.ts @@ -205,7 +205,7 @@ test("promptInjectionGuard: withInjectionGuard blocks suspicious POST bodies", a }); const response = await wrapped(request, {}); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 400); assert.equal(payload.error.type, "injection_detected"); @@ -235,7 +235,7 @@ test("promptInjectionGuard: withInjectionGuard annotates downstream headers in w }); const response = await wrapped(request, {}); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(payload.flagged, "true"); diff --git a/tests/unit/prompt-required-routes.test.ts b/tests/unit/prompt-required-routes.test.ts index cd5e5c979e..5bed27f11c 100644 --- a/tests/unit/prompt-required-routes.test.ts +++ b/tests/unit/prompt-required-routes.test.ts @@ -26,7 +26,7 @@ test("v1 video generation POST rejects requests without a prompt", async () => { }), }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 400); assert.match(body.error.message, /Prompt is required/); @@ -42,7 +42,7 @@ test("v1 music generation POST rejects requests without a prompt", async () => { }), }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 400); assert.match(body.error.message, /Prompt is required/); diff --git a/tests/unit/provider-models-management-route.test.ts b/tests/unit/provider-models-management-route.test.ts index 6926950f5d..cf8de63629 100644 --- a/tests/unit/provider-models-management-route.test.ts +++ b/tests/unit/provider-models-management-route.test.ts @@ -46,7 +46,7 @@ test("provider-models PATCH updates hidden flag for custom models", async () => isHidden: true, }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.ok, true); @@ -62,7 +62,7 @@ test("provider-models PATCH persists visibility overrides for catalog models", a { isHidden: true } ) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.ok, true); @@ -85,7 +85,7 @@ test("provider-models PATCH supports bulk visibility updates", async () => { modelIds: ["claude-opus-4-6", "claude-sonnet-4-6"], }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.updated, 2); @@ -101,7 +101,7 @@ test("provider-models PATCH validates required fields", async () => { modelIds: ["claude-sonnet-4-6"], }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 400); assert.equal(body.error.message, "isHidden boolean is required"); diff --git a/tests/unit/provider-models-route.test.ts b/tests/unit/provider-models-route.test.ts index 224c1524a6..379cf02c6a 100644 --- a/tests/unit/provider-models-route.test.ts +++ b/tests/unit/provider-models-route.test.ts @@ -155,7 +155,7 @@ test("provider models route falls back after OpenAI-compatible endpoint probes a }; const response = await callRoute(connection.id); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.provider, "openai-compatible-fallback"); @@ -184,7 +184,7 @@ test("provider models route retries transient OpenAI-compatible probe failures b }; const response = await callRoute(connection.id); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.source, "api"); @@ -201,7 +201,7 @@ test("provider models route returns static catalog entries for providers with ha }); const response = await callRoute(connection.id); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.provider, "bailian-coding-plan"); @@ -214,7 +214,7 @@ test("provider models route returns the local catalog for built-in image provide }); const response = await callRoute(connection.id); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.provider, "topaz"); @@ -228,7 +228,7 @@ test("provider models route returns the local catalog for new built-in chat-open }); const response = await callRoute(connection.id); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.provider, "deepinfra"); @@ -278,7 +278,7 @@ test("provider models route maps Gemini CLI quota buckets into a model list", as }; const response = await callRoute(connection.id); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.deepEqual(body.models, [ @@ -311,7 +311,7 @@ test("provider models route retries Antigravity discovery endpoints before retur }; const response = await callRoute(connection.id); - const body = await response.json(); + const body = (await response.json()) as any; const discoveryUrls = seenUrls.filter((url) => url.includes("/v1internal:models")); assert.equal(response.status, 200); @@ -337,7 +337,7 @@ test("provider models route falls back through all Antigravity discovery endpoin }; const response = await callRoute(connection.id); - const body = await response.json(); + const body = (await response.json()) as any; const discoveryUrls = seenUrls.filter((url) => url.includes("/v1internal:models")); assert.equal(response.status, 200); @@ -359,7 +359,7 @@ test("provider models route returns the local catalog for OAuth-backed Qwen conn }); const response = await callRoute(connection.id); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.source, "local_catalog"); @@ -375,7 +375,7 @@ test("provider models route filters hidden models from the static Claude catalog modelsDb.mergeModelCompatOverride("claude", "claude-sonnet-4-6", { isHidden: true }); const response = await callRoute(connection.id, "?excludeHidden=true"); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.provider, "claude"); @@ -429,7 +429,7 @@ test("provider models route trims Anthropic-compatible message URLs and filters }; const response = await callRoute(connection.id, "?excludeHidden=true"); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.deepEqual(body.models, [{ id: "visible-model", name: "Visible Model" }]); @@ -496,7 +496,7 @@ test("provider models route paginates generic providers and filters hidden model }; const response = await callRoute(connection.id, "?excludeHidden=true"); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.deepEqual(body.models.map((model) => model.id).sort(), [ @@ -527,7 +527,7 @@ test("provider models route stops pagination when the upstream repeats the next }; const response = await callRoute(connection.id); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.deepEqual( @@ -611,7 +611,7 @@ test("provider models route uses provider-specific auth headers for Kimi Coding" }; const response = await callRoute(connection.id); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.provider, "kimi-coding"); diff --git a/tests/unit/provider-nodes-route.test.ts b/tests/unit/provider-nodes-route.test.ts index 06d3599104..e09fddc420 100644 --- a/tests/unit/provider-nodes-route.test.ts +++ b/tests/unit/provider-nodes-route.test.ts @@ -49,7 +49,7 @@ test("provider nodes route lists stored nodes and exposes the CC feature flag", process.env.ENABLE_CC_COMPATIBLE_PROVIDER = "true"; const response = await providerNodesRoute.GET(); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.ccCompatibleProviderEnabled, true); @@ -72,8 +72,8 @@ test("provider nodes route rejects malformed JSON and schema validation failures }) ); - const malformedBody = await malformed.json(); - const invalidBody = await invalid.json(); + const malformedBody = (await malformed.json()) as any; + const invalidBody = (await invalid.json()) as any; assert.equal(malformed.status, 400); assert.equal(malformedBody.error.message, "Invalid request"); @@ -98,7 +98,7 @@ test("provider nodes route creates OpenAI-compatible nodes with normalized defau modelsPath: "", }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 201); assert.match(body.node.id, new RegExp(`^${OPENAI_COMPATIBLE_PREFIX}chat-`)); @@ -120,7 +120,7 @@ test("provider nodes route creates Anthropics-compatible nodes and sanitizes mes modelsPath: "/models", }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 201); assert.match(body.node.id, new RegExp(`^${ANTHROPIC_COMPATIBLE_PREFIX}`)); @@ -141,7 +141,7 @@ test("provider nodes route blocks CC-compatible nodes when the feature flag is d baseUrl: "https://cc.example.com/v1/messages?beta=1", }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 403); assert.equal(body.error, "CC Compatible provider is disabled"); @@ -161,7 +161,7 @@ test("provider nodes route creates CC-compatible nodes with CC-specific URL norm modelsPath: "/models", }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 201); assert.match(body.node.id, new RegExp(`^${CLAUDE_CODE_COMPATIBLE_PREFIX}`)); diff --git a/tests/unit/providers-route-managed-catalog.test.ts b/tests/unit/providers-route-managed-catalog.test.ts index 7268e537a8..24f9c8e131 100644 --- a/tests/unit/providers-route-managed-catalog.test.ts +++ b/tests/unit/providers-route-managed-catalog.test.ts @@ -90,7 +90,7 @@ test("providers route accepts managed audio, web-cookie and search providers", a 201, `${entry.provider} should be accepted by POST /api/providers` ); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(payload.connection.provider, entry.provider); } }); diff --git a/tests/unit/providers-validate-route.test.ts b/tests/unit/providers-validate-route.test.ts index 0cd9fc3973..37bfa706c1 100644 --- a/tests/unit/providers-validate-route.test.ts +++ b/tests/unit/providers-validate-route.test.ts @@ -40,7 +40,7 @@ test("providers validate route returns 400 for invalid JSON", async () => { const response = await validateRoute.POST(request); assert.equal(response.status, 400); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(body.error.message, "Invalid request"); }); @@ -96,7 +96,7 @@ test("providers validate route forwards baseUrl to built-in specialty validators }); const response = await validateRoute.POST(request); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.valid, true); @@ -175,7 +175,7 @@ test("providers validate route allows private baseUrl values when opt-in env is }); const response = await validateRoute.POST(request); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.valid, true); @@ -212,7 +212,7 @@ test("providers validate route returns 504 on controlled outbound timeout", asyn }); const response = await validateRoute.POST(request); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 504); assert.match(body.error, /timed out/i); diff --git a/tests/unit/proxy-fetch.test.ts b/tests/unit/proxy-fetch.test.ts index 19e1f49ce0..5b175f76ea 100644 --- a/tests/unit/proxy-fetch.test.ts +++ b/tests/unit/proxy-fetch.test.ts @@ -136,7 +136,7 @@ test("proxy fetch uses TLS fingerprint transport when enabled and available", as assert.equal(isTlsFingerprintActive(), true); assert.equal(tracked.tlsFingerprintUsed, true); - assert.deepEqual(await tracked.result.json(), { via: "tls-client" }); + assert.deepEqual(await (tracked.result as any).json(), { via: "tls-client" }); } ); }); diff --git a/tests/unit/proxy-login-bootstrap-auth.test.ts b/tests/unit/proxy-login-bootstrap-auth.test.ts index b43c403632..d637679d68 100644 --- a/tests/unit/proxy-login-bootstrap-auth.test.ts +++ b/tests/unit/proxy-login-bootstrap-auth.test.ts @@ -76,7 +76,7 @@ test("proxy requires auth for POST /api/settings/require-login after setup", asy const { proxy } = await importFreshProxy(); const response = await proxy(makeRequest("/api/settings/require-login", "POST")); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 401); assert.equal(body.error.code, "AUTH_001"); @@ -95,7 +95,7 @@ test("proxy rejects bearer tokens for POST /api/settings/require-login after set authorization: "Bearer sk-invalid", }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 403); assert.equal(body.error.code, "AUTH_001"); diff --git a/tests/unit/proxy-management-v1-route.test.ts b/tests/unit/proxy-management-v1-route.test.ts index e02f2e267c..2c3a1deec2 100644 --- a/tests/unit/proxy-management-v1-route.test.ts +++ b/tests/unit/proxy-management-v1-route.test.ts @@ -103,7 +103,7 @@ test("v1 management proxies supports create/list/pagination", async () => { new Request("http://localhost/api/v1/management/proxies?limit=1&offset=0") ); assert.equal(listRes.status, 200); - const listPayload = await listRes.json(); + const listPayload = (await listRes.json()) as any; assert.equal(Array.isArray(listPayload.items), true); assert.equal(listPayload.items.length, 1); assert.equal(listPayload.page.total >= 2, true); @@ -172,13 +172,13 @@ test("v1 management proxies main route covers auth, lookup variants, update and }) ); assert.equal(createdRes.status, 201); - const created = await createdRes.json(); + const created = (await createdRes.json()) as any; const defaultListRes = await proxyV1Route.GET( new Request("http://localhost/api/v1/management/proxies") ); assert.equal(defaultListRes.status, 200); - const defaultListBody = await defaultListRes.json(); + const defaultListBody = (await defaultListRes.json()) as any; assert.equal(defaultListBody.page.limit, 50); assert.equal(defaultListBody.page.offset, 0); assert.equal(defaultListBody.items.length, 1); @@ -187,7 +187,7 @@ test("v1 management proxies main route covers auth, lookup variants, update and new Request(`http://localhost/api/v1/management/proxies?id=${created.id}`) ); assert.equal(byIdRes.status, 200); - const byIdBody = await byIdRes.json(); + const byIdBody = (await byIdRes.json()) as any; assert.equal(byIdBody.id, created.id); const assignRes = await proxyAssignmentsV1Route.PUT( @@ -207,7 +207,7 @@ test("v1 management proxies main route covers auth, lookup variants, update and new Request(`http://localhost/api/v1/management/proxies?id=${created.id}&where_used=1`) ); assert.equal(whereUsedRes.status, 200); - const whereUsedBody = await whereUsedRes.json(); + const whereUsedBody = (await whereUsedRes.json()) as any; assert.equal(whereUsedBody.count, 1); assert.equal(whereUsedBody.assignments[0].scopeId, providerConn.id); @@ -229,7 +229,7 @@ test("v1 management proxies main route covers auth, lookup variants, update and }) ); assert.equal(updatedRes.status, 200); - const updatedBody = await updatedRes.json(); + const updatedBody = (await updatedRes.json()) as any; assert.equal(updatedBody.host, "updated.local"); assert.equal(updatedBody.port, 9090); @@ -265,7 +265,7 @@ test("v1 management proxies main route covers auth, lookup variants, update and }) ); assert.equal(inUseDeleteRes.status, 409); - const inUseDeleteBody = await inUseDeleteRes.json(); + const inUseDeleteBody = (await inUseDeleteRes.json()) as any; assert.match(inUseDeleteBody.error.message, /remove assignments first/i); const forceDeleteRes = await proxyV1Route.DELETE( @@ -369,7 +369,7 @@ test("v1 management proxies main route returns server errors when persistence fa }) ); assert.equal(createdRes.status, 201); - const created = await createdRes.json(); + const created = (await createdRes.json()) as any; await withPrepareFailure("UPDATE proxy_registry", "update proxy failure", async () => { const response = await proxyV1Route.PATCH( @@ -419,7 +419,7 @@ test("v1 management assignments supports put and filtered get", async () => { }), }) ); - const created = await createdRes.json(); + const created = (await createdRes.json()) as any; const assignRes = await proxyAssignmentsV1Route.PUT( new Request("http://localhost/api/v1/management/proxies/assignments", { @@ -440,7 +440,7 @@ test("v1 management assignments supports put and filtered get", async () => { ) ); assert.equal(filteredRes.status, 200); - const payload = await filteredRes.json(); + const payload = (await filteredRes.json()) as any; assert.equal(payload.items.length, 1); assert.equal(payload.items[0].proxyId, created.id); }); @@ -452,7 +452,7 @@ test("v1 management assignments covers unfiltered listing and error branches", a new Request("http://localhost/api/v1/management/proxies/assignments?limit=5&offset=0") ); assert.equal(listRes.status, 200); - const listPayload = await listRes.json(); + const listPayload = (await listRes.json()) as any; assert.deepEqual(listPayload.items, []); assert.equal(listPayload.page.limit, 5); assert.equal(listPayload.page.offset, 0); @@ -490,7 +490,7 @@ test("v1 management assignments covers unfiltered listing and error branches", a }) ); assert.equal(invalidPayloadRes.status, 400); - const invalidPayloadBody = await invalidPayloadRes.json(); + const invalidPayloadBody = (await invalidPayloadRes.json()) as any; assert.equal(invalidPayloadBody.error.type, "invalid_request"); assert.equal(Array.isArray(invalidPayloadBody.error.details), true); @@ -536,7 +536,7 @@ test("v1 management health endpoint aggregates proxy log metrics", async () => { }), }) ); - const created = await createdRes.json(); + const created = (await createdRes.json()) as any; proxyLogger.logProxyEvent({ status: "success", @@ -559,7 +559,7 @@ test("v1 management health endpoint aggregates proxy log metrics", async () => { new Request("http://localhost/api/v1/management/proxies/health?hours=24") ); assert.equal(healthRes.status, 200); - const healthPayload = await healthRes.json(); + const healthPayload = (await healthRes.json()) as any; const row = healthPayload.items.find((item) => item.proxyId === created.id); assert.ok(row); assert.equal(row.totalRequests >= 2, true); @@ -587,7 +587,7 @@ test("v1 management health endpoint covers default window and error handling", a new Request("http://localhost/api/v1/management/proxies/health") ); assert.equal(defaultRes.status, 200); - const defaultBody = await defaultRes.json(); + const defaultBody = (await defaultRes.json()) as any; assert.equal(defaultBody.windowHours, 24); assert.equal(defaultBody.total, 1); @@ -615,7 +615,7 @@ test("v1 bulk assignment updates multiple scope IDs in one request", async () => }), }) ); - const proxy = await proxyRes.json(); + const proxy = (await proxyRes.json()) as any; const bulkRes = await proxyBulkAssignV1Route.PUT( new Request("http://localhost/api/v1/management/proxies/bulk-assign", { @@ -629,13 +629,13 @@ test("v1 bulk assignment updates multiple scope IDs in one request", async () => }) ); assert.equal(bulkRes.status, 200); - const bulkPayload = await bulkRes.json(); + const bulkPayload = (await bulkRes.json()) as any; assert.equal(bulkPayload.updated, 2); const checkRes = await proxyAssignmentsV1Route.GET( new Request("http://localhost/api/v1/management/proxies/assignments?scope=provider") ); - const checkPayload = await checkRes.json(); + const checkPayload = (await checkRes.json()) as any; assert.equal(checkPayload.items.length >= 2, true); }); @@ -708,7 +708,7 @@ test("v1 assignments route resolves connection proxies and bulk assignment cover }), }) ); - const proxy = await proxyRes.json(); + const proxy = (await proxyRes.json()) as any; const assignRes = await proxyAssignmentsV1Route.PUT( new Request("http://localhost/api/v1/management/proxies/assignments", { @@ -729,7 +729,7 @@ test("v1 assignments route resolves connection proxies and bulk assignment cover ) ); assert.equal(resolveRes.status, 200); - const resolvePayload = await resolveRes.json(); + const resolvePayload = (await resolveRes.json()) as any; assert.equal(resolvePayload.level, "account"); assert.equal(resolvePayload.proxy.host, "resolve.local"); @@ -766,7 +766,7 @@ test("v1 assignments route resolves connection proxies and bulk assignment cover }) ); assert.equal(normalizedRes.status, 200); - const normalizedPayload = await normalizedRes.json(); + const normalizedPayload = (await normalizedRes.json()) as any; assert.equal(normalizedPayload.scope, "account"); assert.equal(normalizedPayload.requested, 2); assert.equal(normalizedPayload.updated, 1); @@ -782,7 +782,7 @@ test("v1 assignments route resolves connection proxies and bulk assignment cover }) ); assert.equal(globalRes.status, 200); - const globalPayload = await globalRes.json(); + const globalPayload = (await globalRes.json()) as any; assert.equal(globalPayload.scope, "global"); assert.equal(globalPayload.requested, 1); assert.equal(globalPayload.updated, 1); diff --git a/tests/unit/proxy-registry.test.ts b/tests/unit/proxy-registry.test.ts index f41de208bd..32037dc027 100644 --- a/tests/unit/proxy-registry.test.ts +++ b/tests/unit/proxy-registry.test.ts @@ -39,8 +39,8 @@ test("proxy registry blocks delete when proxy is still assigned", async () => { await assert.rejects( async () => proxiesDb.deleteProxyById(created.id), (error) => { - assert.equal(error.status, 409); - assert.equal(error.code, "proxy_in_use"); + assert.equal((error as any).status, 409); + (assert as any).equal((error as any).code, "proxy_in_use"); return true; } ); @@ -56,7 +56,7 @@ test("registry assignment takes precedence over legacy proxy config", async () = apiKey: "sk-test", }); - await settingsDb.setProxyForLevel("key", conn.id, { + await settingsDb.setProxyForLevel("key", (conn as any).id, { type: "http", host: "legacy-key.local", port: 8080, @@ -76,9 +76,9 @@ test("registry assignment takes precedence over legacy proxy config", async () = }); await proxiesDb.assignProxyToScope("provider", "openai", providerProxy.id); - await proxiesDb.assignProxyToScope("account", conn.id, accountProxy.id); + await proxiesDb.assignProxyToScope("account", (conn as any).id, accountProxy.id); - const resolved = await settingsDb.resolveProxyForConnection(conn.id); + const resolved = await settingsDb.resolveProxyForConnection((conn as any).id); assert.equal(resolved.level, "account"); assert.equal(resolved.source, "registry"); assert.equal(resolved.proxy.host, "account.local"); @@ -104,7 +104,7 @@ test("legacy proxy config migration imports global/provider/key assignments", as host: "provider-legacy.local", port: 443, }); - await settingsDb.setProxyForLevel("key", conn.id, { + await settingsDb.setProxyForLevel("key", (conn as any).id, { type: "http", host: "account-legacy.local", port: 8082, @@ -114,7 +114,7 @@ test("legacy proxy config migration imports global/provider/key assignments", as assert.equal(result.skipped, false); assert.equal(result.migrated >= 3, true); - const resolved = await settingsDb.resolveProxyForConnection(conn.id); + const resolved = await settingsDb.resolveProxyForConnection((conn as any).id); assert.equal(resolved.level, "account"); assert.equal(resolved.source, "registry"); assert.equal(resolved.proxy.host, "account-legacy.local"); diff --git a/tests/unit/qoder-executor.test.ts b/tests/unit/qoder-executor.test.ts index 5e6dfab2a2..7d5af39fbe 100644 --- a/tests/unit/qoder-executor.test.ts +++ b/tests/unit/qoder-executor.test.ts @@ -161,7 +161,7 @@ test("QoderExecutor: missing tokens return an authentication error response", as assert.equal(url, "https://dashscope.aliyuncs.com"); assert.equal(response.status, 401); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(payload.error.code, "token_required"); }); @@ -202,9 +202,9 @@ test("QoderExecutor: non-stream calls target DashScope and map alias models", as }); assert.equal(url, "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"); - assert.equal(transformedBody.model, "coder-model"); + assert.equal((transformedBody as any).model, "coder-model"); assert.equal(response.status, 200); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(payload.object, "chat.completion"); assert.equal(payload.choices[0].message.role, "assistant"); assert.equal(payload.choices[0].message.content, "OK"); diff --git a/tests/unit/remaining-tasks.test.ts b/tests/unit/remaining-tasks.test.ts index 3899e99d37..7b71b4c4fa 100644 --- a/tests/unit/remaining-tasks.test.ts +++ b/tests/unit/remaining-tasks.test.ts @@ -41,7 +41,7 @@ test("RequestTelemetry: measure() records errors", async () => { t.measure("connect", async () => { throw new Error("timeout"); }), - (err) => err.message === "timeout" + (err) => (err as any).message === "timeout" ); assert.equal(t.getSummary().phases[0].error, "timeout"); }); diff --git a/tests/unit/response-sanitizer.test.ts b/tests/unit/response-sanitizer.test.ts index 1d6bf6a298..0eef836c54 100644 --- a/tests/unit/response-sanitizer.test.ts +++ b/tests/unit/response-sanitizer.test.ts @@ -55,12 +55,12 @@ test("sanitizeOpenAIResponse extracts thinking, collapses newlines, strips final ], }); - assert.equal(sanitized.choices[0].index, 2); - assert.equal(sanitized.choices[0].finish_reason, "tool_calls"); - assert.equal(sanitized.choices[0].message.content, "Hello\n\nworld"); - assert.equal(sanitized.choices[0].message.reasoning_content, undefined); - assert.deepEqual(sanitized.choices[0].message.tool_calls, [{ id: "call_1" }]); - assert.deepEqual(sanitized.choices[0].message.function_call, { name: "legacy" }); + assert.equal((sanitized as any).choices[0].index, 2); + assert.equal((sanitized as any).choices[0].finish_reason, "tool_calls"); + (assert as any).equal((sanitized as any).choices[0].message.content, "Hello\n\nworld"); + assert.equal((sanitized as any).choices[0].message.reasoning_content, undefined); + (assert as any).deepEqual((sanitized as any).choices[0].message.tool_calls, [{ id: "call_1" }]); + assert.deepEqual((sanitized as any).choices[0].message.function_call, { name: "legacy" }); }); test("sanitizeOpenAIResponse preserves native reasoning_content when no visible content remains", () => { @@ -77,8 +77,8 @@ test("sanitizeOpenAIResponse preserves native reasoning_content when no visible ], }); - assert.equal(sanitized.choices[0].message.content, ""); - assert.equal(sanitized.choices[0].message.reasoning_content, "provider reasoning"); + assert.equal(((sanitized as any).choices[0].message as any).content, ""); + assert.equal((sanitized as any).choices[0].message.reasoning_content, "provider reasoning"); }); test("sanitizeOpenAIResponse maps Claude-style usage fields and strips extras", () => { @@ -93,7 +93,7 @@ test("sanitizeOpenAIResponse maps Claude-style usage fields and strips extras", }, }); - assert.deepEqual(sanitized.usage, { + assert.deepEqual((sanitized as any).usage, { prompt_tokens: 11, completion_tokens: 7, total_tokens: 18, @@ -118,7 +118,7 @@ test("sanitizeOpenAIResponse strips reasoning_details-derived reasoning_content ], }); - assert.equal(sanitized.choices[0].message.reasoning_content, undefined); + assert.equal((sanitized as any).choices[0].message.reasoning_content, undefined); }); test("sanitizeOpenAIResponse keeps reasoning_details-derived reasoning_content for reasoning-only messages", () => { @@ -138,7 +138,7 @@ test("sanitizeOpenAIResponse keeps reasoning_details-derived reasoning_content f ], }); - assert.equal(sanitized.choices[0].message.reasoning_content, "first second"); + assert.equal((sanitized as any).choices[0].message.reasoning_content, "first second"); }); test("sanitizeResponsesApiResponse converts chat completions tool calls into Responses output items", () => { @@ -176,16 +176,16 @@ test("sanitizeResponsesApiResponse converts chat completions tool calls into Res }, }); - assert.equal(sanitized.object, "response"); - assert.equal(sanitized.id, "resp_chatcmpl_tool"); - assert.equal(sanitized.output[0].type, "reasoning"); - assert.equal(sanitized.output[1].type, "function_call"); - assert.equal(sanitized.output[1].call_id, "call_web_search"); - assert.equal(sanitized.output[1].name, "omniroute_web_search"); - assert.equal(sanitized.usage.input_tokens, 12); - assert.equal(sanitized.usage.output_tokens, 5); - assert.equal(sanitized.usage.input_tokens_details.cached_tokens, 3); - assert.equal(sanitized.usage.output_tokens_details.reasoning_tokens, 2); + assert.equal((sanitized as any).object, "response"); + assert.equal((sanitized as any).id, "resp_chatcmpl_tool"); + assert.equal((sanitized as any).output[0].type, "reasoning"); + (assert as any).equal((sanitized as any).output[1].type, "function_call"); + (assert as any).equal((sanitized as any).output[1].call_id, "call_web_search"); + (assert as any).equal((sanitized as any).output[1].name, "omniroute_web_search"); + assert.equal((sanitized as any).usage.input_tokens, 12); + assert.equal(((sanitized as any).usage as any).output_tokens, 5); + assert.equal((sanitized as any).usage.input_tokens_details.cached_tokens, 3); + assert.equal((sanitized as any).usage.output_tokens_details.reasoning_tokens, 2); }); test("sanitizeResponsesApiResponse preserves native Responses payloads and usage details", () => { @@ -219,15 +219,15 @@ test("sanitizeResponsesApiResponse preserves native Responses payloads and usage }, }); - assert.equal(sanitized.object, "response"); - assert.equal(sanitized.output[0].content[0].text, "Hello\n\nworld"); - assert.equal(sanitized.output[1].arguments, '{"path":"/tmp/a"}'); - assert.equal(sanitized.output_text, "Hello\n\nworld"); - assert.equal(sanitized.usage.input_tokens, 20); - assert.equal(sanitized.usage.output_tokens, 7); - assert.equal(sanitized.usage.input_tokens_details.cached_tokens, 4); - assert.equal(sanitized.usage.input_tokens_details.cache_creation_tokens, 1); - assert.equal(sanitized.usage.output_tokens_details.reasoning_tokens, 3); + assert.equal((sanitized as any).object, "response"); + assert.equal(((sanitized as any).output[0] as any).content[0].text, "Hello\n\nworld"); + assert.equal((sanitized as any).output[1].arguments, '{"path":"/tmp/a"}'); + assert.equal((sanitized as any).output_text, "Hello\n\nworld"); + assert.equal((sanitized as any).usage.input_tokens, 20); + (assert as any).equal((sanitized as any).usage.output_tokens, 7); + assert.equal((sanitized as any).usage.input_tokens_details.cached_tokens, 4); + assert.equal((sanitized as any).usage.input_tokens_details.cache_creation_tokens, 1); + assert.equal((sanitized as any).usage.output_tokens_details.reasoning_tokens, 3); }); test("sanitizeStreamingChunk keeps only safe chunk fields and maps reasoning aliases", () => { @@ -292,7 +292,7 @@ test("sanitizeStreamingChunk converts reasoning_details arrays in deltas", () => ], }); - assert.equal(sanitized.choices[0].delta.reasoning_content, "alphabeta"); + assert.equal((sanitized as any).choices[0].delta.reasoning_content, "alphabeta"); }); test("sanitize functions return non-object inputs unchanged", () => { diff --git a/tests/unit/responses-handler.test.ts b/tests/unit/responses-handler.test.ts index b27864b4a8..9f5022dde0 100644 --- a/tests/unit/responses-handler.test.ts +++ b/tests/unit/responses-handler.test.ts @@ -233,7 +233,7 @@ test("handleResponsesCore propagates upstream failures from chatCore unchanged", assert.equal(result.success, false); assert.equal(result.status, 401); - const payload = await result.response.json(); + const payload = (await result.response.json()) as any; assert.equal(payload.error.message, "[401]: unauthorized"); }); diff --git a/tests/unit/responses-translation-fixes.test.ts b/tests/unit/responses-translation-fixes.test.ts index c85415377a..5d83a95875 100644 --- a/tests/unit/responses-translation-fixes.test.ts +++ b/tests/unit/responses-translation-fixes.test.ts @@ -18,7 +18,7 @@ test("convertResponsesApiFormat filters orphaned function_call_output items", () ], }; const result = convertResponsesApiFormat(body); - const toolMsgs = result.messages.filter((m) => m.role === "tool"); + const toolMsgs = (result as any).messages.filter((m) => m.role === "tool"); assert.equal(toolMsgs.length, 0); }); @@ -31,7 +31,7 @@ test("convertResponsesApiFormat skips function_call items with empty names", () ], }; const result = convertResponsesApiFormat(body); - const assistantMsgs = result.messages.filter((m) => m.role === "assistant"); + const assistantMsgs = (result as any).messages.filter((m) => m.role === "assistant"); assert.equal(assistantMsgs.length, 0); }); @@ -50,7 +50,7 @@ test("Responses→Chat: input_image converted to image_url with detail", () => { ], }; const result = openaiResponsesToOpenAIRequest(null, body, null, null); - const userMsg = result.messages.find((m) => m.role === "user"); + const userMsg = (result as any).messages.find((m) => m.role === "user"); const imgPart = userMsg.content.find((c) => c.type === "image_url"); assert.ok(imgPart, "should have image_url content part"); assert.equal(imgPart.image_url.url, "https://example.com/img.png"); @@ -69,7 +69,7 @@ test("Responses→Chat: input_image without detail omits detail field", () => { ], }; const result = openaiResponsesToOpenAIRequest(null, body, null, null); - const userMsg = result.messages.find((m) => m.role === "user"); + const userMsg = (result as any).messages.find((m) => m.role === "user"); const imgPart = userMsg.content.find((c) => c.type === "image_url"); assert.ok(imgPart); assert.equal(imgPart.image_url.url, "https://example.com/img.png"); @@ -90,7 +90,7 @@ test("Chat→Responses: image_url detail preserved as input_image", () => { ], }; const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, null); - const userItem = result.input.find((i) => i.type === "message" && i.role === "user"); + const userItem = (result as any).input.find((i) => i.type === "message" && i.role === "user"); const imgPart = userItem.content.find((c) => c.type === "input_image"); assert.ok(imgPart, "should have input_image content part"); assert.equal(imgPart.image_url, "https://example.com/img.png"); @@ -108,7 +108,7 @@ test("Chat→Responses: image_url without detail omits detail", () => { ], }; const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, null); - const userItem = result.input.find((i) => i.type === "message" && i.role === "user"); + const userItem = (result as any).input.find((i) => i.type === "message" && i.role === "user"); const imgPart = userItem.content.find((c) => c.type === "input_image"); assert.ok(imgPart); assert.equal(imgPart.detail, undefined); @@ -126,7 +126,7 @@ test("Responses→Chat: input_file converted to file content part", () => { ], }; const result = openaiResponsesToOpenAIRequest(null, body, null, null); - const userMsg = result.messages.find((m) => m.role === "user"); + const userMsg = (result as any).messages.find((m) => m.role === "user"); const filePart = userMsg.content.find((c) => c.type === "file"); assert.ok(filePart, "should have file content part"); assert.equal(filePart.file.file_id, "file-abc"); @@ -144,7 +144,7 @@ test("Chat→Responses: file content part converted to input_file", () => { ], }; const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, null); - const userItem = result.input.find((i) => i.type === "message" && i.role === "user"); + const userItem = (result as any).input.find((i) => i.type === "message" && i.role === "user"); const filePart = userItem.content.find((c) => c.type === "input_file"); assert.ok(filePart, "should have input_file content part"); assert.equal(filePart.file_id, "file-abc"); @@ -159,7 +159,7 @@ test("Responses→Chat: tool_choice {type:'function', name} wrapped to {type:'fu tools: [{ type: "function", name: "get_weather", parameters: {} }], }; const result = openaiResponsesToOpenAIRequest(null, body, null, null); - assert.deepEqual(result.tool_choice, { + (assert as any).deepEqual((result as any).tool_choice, { type: "function", function: { name: "get_weather" }, }); @@ -173,7 +173,7 @@ test("Chat→Responses: tool_choice {type:'function', function:{name}} unwrapped tools: [{ type: "function", function: { name: "get_weather", parameters: {} } }], }; const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, null); - assert.deepEqual(result.tool_choice, { + (assert as any).deepEqual((result as any).tool_choice, { type: "function", name: "get_weather", }); @@ -182,7 +182,7 @@ test("Chat→Responses: tool_choice {type:'function', function:{name}} unwrapped test("Responses→Chat: string tool_choice passes through unchanged", () => { const body = { model: "gpt-4", input: "hello", tool_choice: "auto" }; const result = openaiResponsesToOpenAIRequest(null, body, null, null); - assert.equal(result.tool_choice, "auto"); + assert.equal((result as any).tool_choice, "auto"); }); test("Chat→Responses: string tool_choice passes through unchanged", () => { @@ -192,7 +192,7 @@ test("Chat→Responses: string tool_choice passes through unchanged", () => { tool_choice: "required", }; const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, null); - assert.equal(result.tool_choice, "required"); + assert.equal((result as any).tool_choice, "required"); }); test("Responses→Chat: built-in tool_choice type throws unsupported error", () => { @@ -203,7 +203,7 @@ test("Responses→Chat: built-in tool_choice type throws unsupported error", () }; assert.throws( () => openaiResponsesToOpenAIRequest(null, body, null, null), - (err) => err.message.includes("web_search_preview") + (err) => (err as any).message.includes("web_search_preview") ); }); @@ -215,7 +215,7 @@ test("Responses→Chat: web_search tool type throws unsupported error", () => { }; assert.throws( () => openaiResponsesToOpenAIRequest(null, body, null, null), - (err) => err.message.includes("web_search") + (err) => (err as any).message.includes("web_search") ); }); @@ -227,7 +227,7 @@ test("Responses→Chat: computer tool type throws unsupported error", () => { }; assert.throws( () => openaiResponsesToOpenAIRequest(null, body, null, null), - (err) => err.message.includes("computer") + (err) => (err as any).message.includes("computer") ); }); @@ -239,7 +239,7 @@ test("Responses→Chat: mcp tool type throws unsupported error", () => { }; assert.throws( () => openaiResponsesToOpenAIRequest(null, body, null, null), - (err) => err.message.includes("mcp") + (err) => (err as any).message.includes("mcp") ); }); @@ -252,7 +252,7 @@ test("Responses→Chat: non-string arguments are JSON-stringified", () => { ], }; const result = openaiResponsesToOpenAIRequest(null, body, null, null); - const assistantMsg = result.messages.find((m) => m.role === "assistant"); + const assistantMsg = (result as any).messages.find((m) => m.role === "assistant"); assert.equal(typeof assistantMsg.tool_calls[0].function.arguments, "string"); assert.equal(assistantMsg.tool_calls[0].function.arguments, '{"key":"val"}'); }); @@ -275,7 +275,7 @@ test("Chat→Responses: array tool content converts text→input_text types", () ], }; const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, null); - const outputItem = result.input.find((i) => i.type === "function_call_output"); + const outputItem = (result as any).input.find((i) => i.type === "function_call_output"); assert.ok(Array.isArray(outputItem.output), "output should be array"); assert.equal(outputItem.output[0].type, "input_text"); assert.equal(outputItem.output[0].text, "result data"); @@ -288,8 +288,8 @@ test("Responses→Chat: function tool type passes through", () => { tools: [{ type: "function", name: "greet", parameters: {} }], }; const result = openaiResponsesToOpenAIRequest(null, body, null, null); - assert.equal(result.tools.length, 1); - assert.equal(result.tools[0].type, "function"); + assert.equal((result as any).tools.length, 1); + assert.equal((result as any).tools[0].type, "function"); }); test("Chat→Responses: deprecated function_call field on assistant converted to function_call item", () => { @@ -305,7 +305,7 @@ test("Chat→Responses: deprecated function_call field on assistant converted to ], }; const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, null); - const fcItem = result.input.find((i) => i.type === "function_call"); + const fcItem = (result as any).input.find((i) => i.type === "function_call"); assert.ok(fcItem, "should have function_call input item"); assert.equal(fcItem.name, "get_weather"); assert.equal(fcItem.arguments, '{"city":"NYC"}'); @@ -326,11 +326,11 @@ test("Chat→Responses: deprecated function role message converted to function_c ], }; const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, null); - const fcOutput = result.input.find((i) => i.type === "function_call_output"); + const fcOutput = (result as any).input.find((i) => i.type === "function_call_output"); assert.ok(fcOutput, "should have function_call_output item"); assert.equal(fcOutput.output, '{"temp":72}'); // The call_ids should match between function_call and function_call_output - const fcItem = result.input.find((i) => i.type === "function_call"); + const fcItem = (result as any).input.find((i) => i.type === "function_call"); assert.equal(fcOutput.call_id, fcItem.call_id); }); diff --git a/tests/unit/route-edge-coverage.test.ts b/tests/unit/route-edge-coverage.test.ts index e966536189..53ab66ce94 100644 --- a/tests/unit/route-edge-coverage.test.ts +++ b/tests/unit/route-edge-coverage.test.ts @@ -158,7 +158,7 @@ test("api keys route covers auth, create, masking, pagination fallback and cloud body: { name: "Key / Prod #1", noLog: true }, }) ); - const createdBody = await created.json(); + const createdBody = (await created.json()) as any; const stored = await apiKeysDb.getApiKeyById(createdBody.id); await apiKeysDb.createApiKey("Alpha", MACHINE_ID); @@ -168,9 +168,9 @@ test("api keys route covers auth, create, masking, pagination fallback and cloud await makeManagementSessionRequest("http://localhost/api/keys?limit=0&offset=-25") ); - const unauthenticatedBody = await unauthenticated.json(); - const invalidTokenBody = await invalidToken.json(); - const pagedBody = await paged.json(); + const unauthenticatedBody = (await unauthenticated.json()) as any; + const invalidTokenBody = (await invalidToken.json()) as any; + const pagedBody = (await paged.json()) as any; assert.equal(unauthenticated.status, 401); assert.equal(unauthenticatedBody.error.message, "Authentication required"); @@ -214,7 +214,7 @@ test("api keys route rejects invalid payloads and malformed JSON", async () => { }) ); - const malformedBody = await malformed.json(); + const malformedBody = (await malformed.json()) as any; assert.equal(missingName.status, 400); assert.equal(malformed.status, 500); @@ -297,16 +297,16 @@ test("settings proxy route covers full config, resolve, validation, delete and g new Request("http://localhost/api/settings/proxy", { method: "DELETE" }) ); - const invalidJsonBody = await invalidJson.json(); - const invalidBodyPayload = await invalidBody.json(); - const validPutBody = await validPut.json(); - const legacyPutBody = await legacyPut.json(); - const providerGetBody = await providerGet.json(); - const resolveBody = await resolveGet.json(); - const fullConfigBody = await fullConfig.json(); - const deletedBody = await deleted.json(); - const resolveAfterDeleteBody = await resolveAfterDelete.json(); - const missingLevelBody = await missingLevel.json(); + const invalidJsonBody = (await invalidJson.json()) as any; + const invalidBodyPayload = (await invalidBody.json()) as any; + const validPutBody = (await validPut.json()) as any; + const legacyPutBody = (await legacyPut.json()) as any; + const providerGetBody = (await providerGet.json()) as any; + const resolveBody = (await resolveGet.json()) as any; + const fullConfigBody = (await fullConfig.json()) as any; + const deletedBody = (await deleted.json()) as any; + const resolveAfterDeleteBody = (await resolveAfterDelete.json()) as any; + const missingLevelBody = (await missingLevel.json()) as any; assert.equal(invalidJson.status, 400); assert.equal(invalidJsonBody.error.message, "Invalid JSON body"); @@ -346,7 +346,7 @@ test("settings proxy route prefers proxy registry assignments and enforces socks const registryBacked = await settingsProxyRoute.GET( new Request("http://localhost/api/settings/proxy?level=global") ); - const registryBackedBody = await registryBacked.json(); + const registryBackedBody = (await registryBacked.json()) as any; process.env.ENABLE_SOCKS5_PROXY = "false"; const disabledSocks = await settingsProxyRoute.PUT( @@ -370,8 +370,8 @@ test("settings proxy route prefers proxy registry assignments and enforces socks }) ); - const disabledSocksBody = await disabledSocks.json(); - const enabledSocksBody = await enabledSocks.json(); + const disabledSocksBody = (await disabledSocks.json()) as any; + const enabledSocksBody = (await enabledSocks.json()) as any; assert.equal(registryBacked.status, 200); assert.equal(registryBackedBody.proxy.host, "registry.local"); @@ -393,7 +393,7 @@ test("settings proxy route covers default types, null maps, registry fallback, a }) ); assert.equal(defaultTypePut.status, 200); - const defaultTypeBody = await defaultTypePut.json(); + const defaultTypeBody = (await defaultTypePut.json()) as any; assert.equal(defaultTypeBody.global.type, "http"); const clearMapPut = await settingsProxyRoute.PUT( @@ -406,7 +406,7 @@ test("settings proxy route covers default types, null maps, registry fallback, a }) ); assert.equal(clearMapPut.status, 200); - const clearMapBody = await clearMapPut.json(); + const clearMapBody = (await clearMapPut.json()) as any; assert.equal(clearMapBody.global, null); assert.equal(Object.prototype.hasOwnProperty.call(clearMapBody.providers || {}, "openai"), false); @@ -441,7 +441,7 @@ test("settings proxy route covers default types, null maps, registry fallback, a new Request("http://localhost/api/settings/proxy?level=global") ); assert.equal(response.status, 200); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(body.level, "global"); assert.equal(body.proxy.host, "legacy-fallback.local"); } @@ -473,7 +473,7 @@ test("settings proxy route covers default types, null maps, registry fallback, a }) ); assert.equal(response.status, 500); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(body.error.type, "server_error"); assert.match(body.error.message, /proxy config write failure/i); } @@ -518,7 +518,7 @@ test("management proxies route covers auth, pagination, lookup, where-used, patc }, }) ); - const created = await createdResponse.json(); + const created = (await createdResponse.json()) as any; await localDb.assignProxyToScope("provider", "openai", created.id); const pagedList = await managementProxiesRoute.GET( @@ -580,18 +580,18 @@ test("management proxies route covers auth, pagination, lookup, where-used, patc ) ); - const unauthenticatedBody = await unauthenticated.json(); - const invalidTokenBody = await invalidToken.json(); - const pagedListBody = await pagedList.json(); - const byIdBody = await byId.json(); - const whereUsedBody = await whereUsed.json(); - const missingGetBody = await missingGet.json(); - const invalidJsonPatchBody = await invalidJsonPatch.json(); - const invalidPatchBody = await invalidPatch.json(); - const patchedBody = await patched.json(); - const missingDeleteBody = await missingDelete.json(); - const conflictDeleteBody = await conflictDelete.json(); - const forcedDeleteBody = await forcedDelete.json(); + const unauthenticatedBody = (await unauthenticated.json()) as any; + const invalidTokenBody = (await invalidToken.json()) as any; + const pagedListBody = (await pagedList.json()) as any; + const byIdBody = (await byId.json()) as any; + const whereUsedBody = (await whereUsed.json()) as any; + const missingGetBody = (await missingGet.json()) as any; + const invalidJsonPatchBody = (await invalidJsonPatch.json()) as any; + const invalidPatchBody = (await invalidPatch.json()) as any; + const patchedBody = (await patched.json()) as any; + const missingDeleteBody = (await missingDelete.json()) as any; + const conflictDeleteBody = (await conflictDelete.json()) as any; + const forcedDeleteBody = (await forcedDelete.json()) as any; assert.equal(unauthenticated.status, 401); assert.equal(unauthenticatedBody.error.message, "Authentication required"); @@ -633,7 +633,7 @@ test("embeddings route covers options, custom-model listing and defensive POST b const optionsResponse = await embeddingsRoute.OPTIONS(); const getResponse = await embeddingsRoute.GET(); - const getBody = await getResponse.json(); + const getBody = (await getResponse.json()) as any; const invalidJson = await embeddingsRoute.POST( new Request("http://localhost/v1/embeddings", { @@ -656,9 +656,9 @@ test("embeddings route covers options, custom-model listing and defensive POST b ); const optionsHeaders = Object.fromEntries(optionsResponse.headers.entries()); - const invalidJsonBody = await invalidJson.json(); - const validationFailureBody = await validationFailure.json(); - const invalidModelBody = await invalidModel.json(); + const invalidJsonBody = (await invalidJson.json()) as any; + const validationFailureBody = (await validationFailure.json()) as any; + const invalidModelBody = (await invalidModel.json()) as any; assert.equal(optionsHeaders["access-control-allow-origin"], "*"); assert.equal(getResponse.status, 200); @@ -726,10 +726,10 @@ test("embeddings route enforces caller auth, missing credentials and provider ra }) ); - const missingKeyBody = await missingKey.json(); - const invalidKeyBody = await invalidKey.json(); - const missingCredentialsBody = await missingCredentials.json(); - const allRateLimitedBody = await allRateLimited.json(); + const missingKeyBody = (await missingKey.json()) as any; + const invalidKeyBody = (await invalidKey.json()) as any; + const missingCredentialsBody = (await missingCredentials.json()) as any; + const allRateLimitedBody = (await allRateLimited.json()) as any; assert.equal(missingKey.status, 401); assert.equal(missingKeyBody.error.message, "Missing API key"); @@ -757,7 +757,7 @@ test("embeddings route tolerates custom-model and provider-node lookup failures" "custom models unavailable", async () => { const response = await embeddingsRoute.GET(); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.ok(body.data.some((model) => model.id === "openai/text-embedding-3-small")); @@ -774,7 +774,7 @@ test("embeddings route tolerates custom-model and provider-node lookup failures" body: { model: "openai/text-embedding-3-small", input: "hello" }, }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.model, "openai/text-embedding-3-small"); @@ -820,7 +820,7 @@ test("embeddings route supports local provider nodes without credentials and enf }, }) ); - const localBody = await localResponse.json(); + const localBody = (await localResponse.json()) as any; assert.equal(localResponse.status, 200); assert.equal(localBody.model, "localembed/demo-embed"); @@ -849,7 +849,7 @@ test("embeddings route supports local provider nodes without credentials and enf }), }) ); - const rejectedBody = await rejected.json(); + const rejectedBody = (await rejected.json()) as any; assert.equal(rejected.status, 403); assert.match(rejectedBody.error.message, /not allowed/i); @@ -871,7 +871,7 @@ test("embeddings route returns normalized upstream failures", async () => { body: { model: "openai/text-embedding-3-small", input: "hello" }, }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 502); assert.equal(body.error.message, "upstream boom"); @@ -907,7 +907,7 @@ test("embeddings route GET skips malformed, non-embedding, and duplicate custom ); const response = await embeddingsRoute.GET(); - const body = await response.json(); + const body = (await response.json()) as any; const ids = body.data.map((model) => model.id); assert.equal(response.status, 200); @@ -976,7 +976,7 @@ test("embeddings route tolerates non-array provider nodes and remote fallback lo }) ) ); - const remoteFallbackBody = await remoteFallback.json(); + const remoteFallbackBody = (await remoteFallback.json()) as any; assert.equal(remoteFallback.status, 400); assert.match(remoteFallbackBody.error.message, /Unknown embedding provider|No matching/i); @@ -1063,7 +1063,7 @@ test("embeddings route handles responses provider nodes, invalid local nodes, an }) ) ); - const remoteBody = await remoteResponse.json(); + const remoteBody = (await remoteResponse.json()) as any; assert.equal(remoteResponse.status, 200); assert.equal(fetchCalls[1].url, "https://remote.example.com/v1beta/openai/embeddings"); diff --git a/tests/unit/safe-outbound-fetch.test.ts b/tests/unit/safe-outbound-fetch.test.ts index 94416bae1a..67da7f3884 100644 --- a/tests/unit/safe-outbound-fetch.test.ts +++ b/tests/unit/safe-outbound-fetch.test.ts @@ -58,9 +58,9 @@ test("safeOutboundFetch normalizes timeout failures", async () => { }), (error) => { assert.equal(error instanceof SafeOutboundFetchError, true); - assert.equal(error.code, "TIMEOUT"); - assert.equal(error.timeoutMs, 5); - assert.equal(error.url, "https://example.test/slow"); + assert.equal((error as any).code, "TIMEOUT"); + (assert as any).equal((error as any).timeoutMs, 5); + (assert as any).equal((error as any).url, "https://example.test/slow"); return true; } ); @@ -81,9 +81,9 @@ test("safeOutboundFetch blocks redirects when allowRedirect is disabled", async }), (error) => { assert.equal(error instanceof SafeOutboundFetchError, true); - assert.equal(error.code, "REDIRECT_BLOCKED"); - assert.equal(error.status, 302); - assert.equal(error.location, "https://redirect.example.test/login"); + assert.equal((error as any).code, "REDIRECT_BLOCKED"); + assert.equal((error as any).status, 302); + assert.equal((error as any).location, "https://redirect.example.test/login"); return true; } ); @@ -104,7 +104,7 @@ test("safeOutboundFetch blocks private hosts when public-only guard is enabled", }), (error) => { assert.equal(error instanceof SafeOutboundFetchError, true); - assert.equal(error.code, "URL_GUARD_BLOCKED"); + assert.equal((error as any).code, "URL_GUARD_BLOCKED"); assert.equal(called, false); return true; } diff --git a/tests/unit/schema-coercion.test.ts b/tests/unit/schema-coercion.test.ts index 5b6310d863..669a6249e5 100644 --- a/tests/unit/schema-coercion.test.ts +++ b/tests/unit/schema-coercion.test.ts @@ -22,9 +22,9 @@ test("coerceSchemaNumericFields converts string numbers to actual numbers", () = const result = coerceSchemaNumericFields(schema); - assert.strictEqual(result.minimum, 5); - assert.strictEqual(result.properties.items.minItems, 1); - assert.strictEqual(result.properties.items.maxItems, 2); + assert.strictEqual((result as any).minimum, 5); + (assert as any).strictEqual((result as any).properties.items.minItems, 1); + (assert as any).strictEqual((result as any).properties.items.maxItems, 2); }); test("coerceSchemaNumericFields ignores non-numeric strings", () => { @@ -35,8 +35,8 @@ test("coerceSchemaNumericFields ignores non-numeric strings", () => { const result = coerceSchemaNumericFields(schema); - assert.strictEqual(result.minimum, "abc"); - assert.strictEqual(result.maximum, 10.5); + (assert as any).strictEqual((result as any).minimum, "abc"); + assert.strictEqual((result as any).maximum, 10.5); }); test("coerceToolSchemas applies coercion to OpenAI tools", () => { @@ -76,7 +76,7 @@ test("sanitizeToolDescription converts null to empty string (OpenAI format)", () function: { name: "test", description: null, parameters: {} }, }; const result = sanitizeToolDescription(tool); - assert.equal(result.function.description, ""); + assert.equal((result as any).function.description, ""); }); test("sanitizeToolDescription converts number to string (OpenAI format)", () => { @@ -85,13 +85,13 @@ test("sanitizeToolDescription converts number to string (OpenAI format)", () => function: { name: "test", description: 42, parameters: {} }, }; const result = sanitizeToolDescription(tool); - assert.equal(result.function.description, "42"); + assert.equal((result as any).function.description, "42"); }); test("sanitizeToolDescription handles Claude format", () => { const tool = { name: "test", description: null, input_schema: {} }; const result = sanitizeToolDescription(tool); - assert.equal(result.description, ""); + assert.equal((result as any).description, ""); }); test("sanitizeToolDescription preserves valid string descriptions", () => { @@ -100,7 +100,7 @@ test("sanitizeToolDescription preserves valid string descriptions", () => { function: { name: "test", description: "A useful tool", parameters: {} }, }; const result = sanitizeToolDescription(tool); - assert.equal(result.function.description, "A useful tool"); + assert.equal((result as any).function.description, "A useful tool"); }); test("sanitizeToolDescriptions works on arrays", () => { diff --git a/tests/unit/search-route.test.ts b/tests/unit/search-route.test.ts index 05ecfc4911..69d957bc76 100644 --- a/tests/unit/search-route.test.ts +++ b/tests/unit/search-route.test.ts @@ -47,7 +47,7 @@ test.after(() => { test("v1 search GET lists all 9 search providers", async () => { const response = await searchRoute.GET(); - const body = await response.json(); + const body = (await response.json()) as any; const ids = body.data.map((item: { id: string }) => item.id); assert.equal(response.status, 200); @@ -105,7 +105,7 @@ test("v1 search POST uses stored Linkup credentials and returns normalized resul }), }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(capturedUrl, "https://api.linkup.so/v1/search"); @@ -162,7 +162,7 @@ test("v1 search POST accepts authless SearXNG with provider_options baseUrl", as }), }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal( @@ -210,7 +210,7 @@ test("v1 search POST accepts authless SearXNG with the built-in default base URL }), }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal( @@ -265,7 +265,7 @@ test("v1 search POST preserves stored SearXNG baseUrl for authless providers", a }), }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal( @@ -311,7 +311,7 @@ test("v1 search POST auto-select uses authless SearXNG when no API-key providers }), }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal( diff --git a/tests/unit/services-branch-hardening.test.ts b/tests/unit/services-branch-hardening.test.ts index 6adef33e1f..a6384e42de 100644 --- a/tests/unit/services-branch-hardening.test.ts +++ b/tests/unit/services-branch-hardening.test.ts @@ -174,7 +174,7 @@ test("rate limit semaphore covers immediate acquire, timeout, cooldown drain and maxConcurrency: 1, timeoutMs: 15, }); - await assert.rejects(timeoutPromise, (error) => error?.code === "SEMAPHORE_TIMEOUT"); + await assert.rejects(timeoutPromise, (error) => (error as any).code === "SEMAPHORE_TIMEOUT"); heldRelease(); const firstRelease = await rateLimitSemaphore.acquire("model-c", { maxConcurrency: 1 }); diff --git a/tests/unit/settings-api.test.ts b/tests/unit/settings-api.test.ts index 1767dcc794..47c39ddcd9 100644 --- a/tests/unit/settings-api.test.ts +++ b/tests/unit/settings-api.test.ts @@ -141,7 +141,7 @@ describe("Settings API - debugMode and hiddenSidebarItems", () => { body: { antigravitySignatureCacheMode: "bypass" }, }) ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(body.antigravitySignatureCacheMode, "bypass"); diff --git a/tests/unit/settings-route-password.test.ts b/tests/unit/settings-route-password.test.ts index 8576d4cc86..3b0fee671a 100644 --- a/tests/unit/settings-route-password.test.ts +++ b/tests/unit/settings-route-password.test.ts @@ -53,11 +53,14 @@ test("settings route password update requires the current INITIAL_PASSWORD after assert.equal(response.status, 200); assert.equal(managementPassword.isBcryptHash(settings.password), true); assert.equal( - await managementPassword.verifyManagementPassword("rotated-secret", settings.password), + await managementPassword.verifyManagementPassword("rotated-secret", (settings as any).password), true ); assert.equal( - await managementPassword.verifyManagementPassword("bootstrap-secret", settings.password), + await managementPassword.verifyManagementPassword( + "bootstrap-secret" as any, + (settings as any).password + ), false ); }); diff --git a/tests/unit/shared-api-utils.test.ts b/tests/unit/shared-api-utils.test.ts index ccde74d910..56b8f709a0 100644 --- a/tests/unit/shared-api-utils.test.ts +++ b/tests/unit/shared-api-utils.test.ts @@ -99,9 +99,12 @@ test("shared api utils throw enriched errors for non-OK responses", async () => await assert.rejects( () => del("http://localhost/delete"), (error) => { - assert.equal(error.message, "bad request"); - assert.equal(error.status, 400); - assert.deepEqual(error.data, { error: "bad request", detail: "broken payload" }); + assert.equal((error as any).message, "bad request"); + (assert as any).equal((error as any).status, 400); + (assert as any).deepEqual((error as any).data, { + error: "bad request", + detail: "broken payload", + }); return true; } ); diff --git a/tests/unit/signature-cache.test.ts b/tests/unit/signature-cache.test.ts index 0d6f9b1b93..547fe952fe 100644 --- a/tests/unit/signature-cache.test.ts +++ b/tests/unit/signature-cache.test.ts @@ -60,7 +60,7 @@ test("detectAndLearn: finds known signatures", () => { const result = detectAndLearn("I think... Hello!", {}); assert.ok(result.found.includes("")); assert.ok(result.found.includes("")); - assert.ok(!result.cleaned.includes("")); + assert.ok(!(result.cleaned as any).includes("")); }); test("detectAndLearn: auto-learns new thinking tags", () => { diff --git a/tests/unit/skills-routes.test.ts b/tests/unit/skills-routes.test.ts index a58092607b..0dc3ba150e 100644 --- a/tests/unit/skills-routes.test.ts +++ b/tests/unit/skills-routes.test.ts @@ -71,7 +71,7 @@ test("skills route GET loads skills from the database and lists them", async () const response = await skillsRoute.GET( new Request("http://localhost/api/skills?page=1&limit=50") ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 200); assert.ok(Array.isArray(body.data)); @@ -90,7 +90,7 @@ test("skills route GET returns 500 when the registry load fails", async () => { const response = await skillsRoute.GET( new Request("http://localhost/api/skills?page=1&limit=50") ); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(response.status, 500); assert.equal(body.error, "skill db unavailable"); @@ -105,12 +105,12 @@ test("skills by-id DELETE removes existing skills, returns 404 for missing ones, const deleted = await skillByIdRoute.DELETE(new Request("http://localhost/api/skills/id"), { params: Promise.resolve({ id: created.id }), }); - const deletedBody = await deleted.json(); + const deletedBody = (await deleted.json()) as any; const missing = await skillByIdRoute.DELETE(new Request("http://localhost/api/skills/id"), { params: Promise.resolve({ id: created.id }), }); - const missingBody = await missing.json(); + const missingBody = (await missing.json()) as any; const originalUnregisterById = skillRegistry.unregisterById; skillRegistry.unregisterById = async () => { @@ -121,7 +121,7 @@ test("skills by-id DELETE removes existing skills, returns 404 for missing ones, const failed = await skillByIdRoute.DELETE(new Request("http://localhost/api/skills/id"), { params: Promise.resolve({ id: "broken-skill" }), }); - const failedBody = await failed.json(); + const failedBody = (await failed.json()) as any; assert.equal(deleted.status, 200); assert.deepEqual(deletedBody, { success: true }); @@ -167,14 +167,14 @@ test("skills by-id PUT updates enabled state, validates input, and surfaces pars { params: Promise.resolve({ id: created.id }) } ); - const updatedBody = await updated.json(); - const invalidBody = await invalid.json(); - const malformedBody = await malformed.json(); + const updatedBody = (await updated.json()) as any; + const invalidBody = (await invalid.json()) as any; + const malformedBody = (await malformed.json()) as any; const loadedSkillRow = core .getDbInstance() .prepare("SELECT enabled FROM skills WHERE id = ?") .get(created.id); - const isEnabled = loadedSkillRow ? loadedSkillRow.enabled === 1 : false; + const isEnabled = loadedSkillRow ? (loadedSkillRow as any).enabled === 1 : false; assert.equal(updated.status, 200); assert.deepEqual(updatedBody, { success: true, enabled: true }); diff --git a/tests/unit/skills-skillssh.test.ts b/tests/unit/skills-skillssh.test.ts index 45536523ba..7d72aa844e 100644 --- a/tests/unit/skills-skillssh.test.ts +++ b/tests/unit/skills-skillssh.test.ts @@ -121,7 +121,7 @@ test("searchSkillsSh throws on non-ok response", async () => { await assert.rejects( () => searchSkillsSh("fail"), - (err) => err.message.includes("skills.sh API error: 500") + (err) => (err as any).message.includes("skills.sh API error: 500") ); }); @@ -145,7 +145,7 @@ test("fetchSkillMd throws on 404", async () => { await assert.rejects( () => fetchSkillMd("owner/repo", "missing-skill"), - (err) => err.message.includes("Failed to fetch SKILL.md: 404") + (err) => (err as any).message.includes("Failed to fetch SKILL.md: 404") ); }); @@ -179,7 +179,7 @@ test("skillssh search route returns skills from the API", async () => { const req = new Request("http://localhost/api/skills/skillssh?q=docker&limit=10"); const res = await searchRoute.GET(req); - const body = await res.json(); + const body = (await res.json()) as any; assert.equal(res.status, 200); assert.equal(body.skills.length, 2); @@ -192,7 +192,7 @@ test("skillssh search route returns 500 when upstream fails", async () => { const req = new Request("http://localhost/api/skills/skillssh?q=broken"); const res = await searchRoute.GET(req); - const body = await res.json(); + const body = (await res.json()) as any; assert.equal(res.status, 500); assert.ok(body.error.includes("skills.sh API error")); @@ -223,7 +223,7 @@ test("skillssh install route registers a skill from skills.sh", async () => { }); const res = await installRoute.POST(req); - const body = await res.json(); + const body = (await res.json()) as any; assert.equal(res.status, 200); assert.equal(body.success, true); @@ -269,7 +269,7 @@ test("skillssh install route returns 500 when SKILL.md fetch fails", async () => }); const res = await installRoute.POST(req); - const body = await res.json(); + const body = (await res.json()) as any; assert.equal(res.status, 500); assert.ok(body.error.includes("Failed to fetch SKILL.md")); @@ -295,7 +295,7 @@ test("skillssh install route defaults version to 1.0.0 when omitted", async () = }); const res = await installRoute.POST(req); - const body = await res.json(); + const body = (await res.json()) as any; assert.equal(res.status, 200); assert.equal(body.success, true); diff --git a/tests/unit/spend-batch-writer.test.ts b/tests/unit/spend-batch-writer.test.ts index 85b994bc3e..29253602f6 100644 --- a/tests/unit/spend-batch-writer.test.ts +++ b/tests/unit/spend-batch-writer.test.ts @@ -31,7 +31,7 @@ async function resetStorage() { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } break; - } catch (error) { + } catch (error: any) { if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) { await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); } else { diff --git a/tests/unit/sse-auth.test.ts b/tests/unit/sse-auth.test.ts index 04d558a7c2..346bd1fa53 100644 --- a/tests/unit/sse-auth.test.ts +++ b/tests/unit/sse-auth.test.ts @@ -134,7 +134,7 @@ test("getProviderCredentials enforces generic quota policy unless explicitly byp }, }); const resetAt = futureIso(); - quotaCache.setQuotaCache(connection.id, "openai", { + quotaCache.setQuotaCache((connection as any).id, "openai", { daily: { remainingPercentage: 10, resetAt }, }); @@ -301,17 +301,17 @@ test("getProviderCredentials round-robin stays on the current account while belo priority: 2, }); - await providersDb.updateProviderConnection(current.id, { + await providersDb.updateProviderConnection((current as any).id, { lastUsedAt: new Date().toISOString(), consecutiveUseCount: 1, }); - await providersDb.updateProviderConnection(other.id, { + await providersDb.updateProviderConnection((other as any).id, { lastUsedAt: new Date(Date.now() - 60_000).toISOString(), consecutiveUseCount: 0, }); const selected = await auth.getProviderCredentials("openai"); - const updated = await providersDb.getProviderConnectionById(current.id); + const updated = await providersDb.getProviderConnectionById((current as any).id); assert.equal(selected.connectionId, current.id); assert.equal(updated.consecutiveUseCount, 2); @@ -409,7 +409,7 @@ test("getProviderCredentials retains terminal accounts for combo live tests", as const bypassed = await auth.getProviderCredentials("openai", null, null, null, { allowSuppressedConnections: true, }); - const updated = await providersDb.getProviderConnectionById(connection.id); + const updated = await providersDb.getProviderConnectionById((connection as any).id); assert.equal(blocked, null); assert.equal(bypassed.connectionId, connection.id); @@ -450,9 +450,15 @@ test("getProviderCredentials reports allRateLimited when every account is model- name: "gemini-model-lock-second", }); - await auth.markAccountUnavailable(first.id, 429, "too many requests", "gemini", "gemini-2.5-pro"); await auth.markAccountUnavailable( - second.id, + (first as any).id, + 429, + "too many requests", + "gemini", + "gemini-2.5-pro" + ); + await auth.markAccountUnavailable( + (second as any).id, 429, "too many requests", "gemini", @@ -479,7 +485,7 @@ test("getProviderCredentials auto-decays stale backoff metadata for recovered ac const selected = await auth.getProviderCredentials("openai"); await flushWrites(); - const updated = await providersDb.getProviderConnectionById(connection.id); + const updated = await providersDb.getProviderConnectionById((connection as any).id); assert.equal(selected.connectionId, connection.id); assert.equal(updated.backoffLevel, 0); @@ -497,7 +503,7 @@ test("getProviderCredentials falls back to a five-minute retry window when quota }, }); - quotaCache.setQuotaCache(connection.id, "openai", { + quotaCache.setQuotaCache((connection as any).id, "openai", { daily: { remainingPercentage: 0, resetAt: null }, }); @@ -522,10 +528,10 @@ test("getProviderCredentials prioritizes accounts that still have quota availabl apiKey: "sk-available", }); - quotaCache.setQuotaCache(exhausted.id, "openai", { + quotaCache.setQuotaCache((exhausted as any).id, "openai", { daily: { remainingPercentage: 0, resetAt: futureIso() }, }); - quotaCache.setQuotaCache(available.id, "openai", { + quotaCache.setQuotaCache((available as any).id, "openai", { daily: { remainingPercentage: 65, resetAt: futureIso() }, }); @@ -549,17 +555,17 @@ test("getProviderCredentials round-robin switches to the least recently used acc priority: 2, }); - await providersDb.updateProviderConnection(current.id, { + await providersDb.updateProviderConnection((current as any).id, { lastUsedAt: new Date().toISOString(), consecutiveUseCount: 2, }); - await providersDb.updateProviderConnection(fallback.id, { + await providersDb.updateProviderConnection((fallback as any).id, { lastUsedAt: new Date(Date.now() - 120_000).toISOString(), consecutiveUseCount: 0, }); const selected = await auth.getProviderCredentials("openai"); - const updated = await providersDb.getProviderConnectionById(fallback.id); + const updated = await providersDb.getProviderConnectionById((fallback as any).id); assert.equal(selected.connectionId, fallback.id); assert.equal(updated.consecutiveUseCount, 1); @@ -579,17 +585,17 @@ test("getProviderCredentials round-robin fallback mode excludes the failed accou priority: 2, }); - await providersDb.updateProviderConnection(failed.id, { + await providersDb.updateProviderConnection((failed as any).id, { lastUsedAt: new Date().toISOString(), consecutiveUseCount: 3, }); - await providersDb.updateProviderConnection(fallback.id, { + await providersDb.updateProviderConnection((fallback as any).id, { lastUsedAt: new Date(Date.now() - 120_000).toISOString(), consecutiveUseCount: 0, }); - const selected = await auth.getProviderCredentials("openai", failed.id); - const updated = await providersDb.getProviderConnectionById(fallback.id); + const selected = await auth.getProviderCredentials("openai" as any, (failed as any).id); + const updated = await providersDb.getProviderConnectionById((fallback as any).id); assert.equal(selected.connectionId, fallback.id); assert.equal(updated.consecutiveUseCount, 1); @@ -619,10 +625,10 @@ test("getProviderCredentials least-used prefers accounts that were never used", name: "least-used-never", priority: 9, }); - await providersDb.updateProviderConnection(recentlyUsed.id, { + await providersDb.updateProviderConnection((recentlyUsed as any).id, { lastUsedAt: new Date().toISOString(), }); - await providersDb.updateProviderConnection(neverUsed.id, { + await providersDb.updateProviderConnection((neverUsed as any).id, { lastUsedAt: null, }); @@ -643,10 +649,10 @@ test("getProviderCredentials least-used prefers the oldest timestamp when all ac priority: 1, }); - await providersDb.updateProviderConnection(oldest.id, { + await providersDb.updateProviderConnection((oldest as any).id, { lastUsedAt: new Date(Date.now() - 120_000).toISOString(), }); - await providersDb.updateProviderConnection(newest.id, { + await providersDb.updateProviderConnection((newest as any).id, { lastUsedAt: new Date().toISOString(), }); @@ -698,10 +704,10 @@ test("getProviderCredentials p2c prefers the account with more quota headroom ov }, }); - quotaCache.setQuotaCache(nearLimit.id, "openai", { + (quotaCache as any).setQuotaCache(nearLimit.id, "openai", { daily: { remainingPercentage: 12, resetAt: futureIso(180_000) }, }); - quotaCache.setQuotaCache(healthy.id, "openai", { + (quotaCache as any).setQuotaCache(healthy.id, "openai", { daily: { remainingPercentage: 78, resetAt: futureIso(180_000) }, }); @@ -782,13 +788,13 @@ test("markAccountUnavailable keeps local 404 failures model-scoped with the loca }); const result = await auth.markAccountUnavailable( - connection.id, + (connection as any).id, 404, "model not found", "openai", "local-model" ); - const updated = await providersDb.getProviderConnectionById(connection.id); + const updated = await (providersDb as any).getProviderConnectionById(connection.id); assert.equal(result.shouldFallback, true); assert.equal(result.cooldownMs, COOLDOWN_MS.notFoundLocal); @@ -804,14 +810,14 @@ test("markAccountUnavailable applies a model-only lockout for Gemini 429 respons }); const result = await auth.markAccountUnavailable( - connection.id, + (connection as any).id, 429, "too many requests", "gemini", "gemini-2.5-pro" ); await flushWrites(); - const updated = await providersDb.getProviderConnectionById(connection.id); + const updated = await providersDb.getProviderConnectionById((connection as any).id); assert.equal(result.shouldFallback, true); assert.ok(result.cooldownMs > 0); @@ -827,14 +833,14 @@ test("markAccountUnavailable applies a model-only lockout for compatible provide }); const result = await auth.markAccountUnavailable( - connection.id, + (connection as any).id, 429, "The upstream compatible service exhausted its capacity", "openai-compatible-custom-node", "custom-model-a" ); await flushWrites(); - const updated = await providersDb.getProviderConnectionById(connection.id); + const updated = await providersDb.getProviderConnectionById((connection as any).id); assert.equal(result.shouldFallback, true); assert.ok(result.cooldownMs > 0); @@ -862,7 +868,7 @@ test("markAccountUnavailable uses the unified configured api-key connection cool }); const result = await auth.markAccountUnavailable( - connection.id, + (connection as any).id, 429, "too many requests", "openai", @@ -884,20 +890,20 @@ test("markAccountUnavailable stores Codex scope-specific cooldowns without a glo }); const result = await auth.markAccountUnavailable( - connection.id, + (connection as any).id, 429, "quota reached", "codex", "codex-spark-mini" ); - const updated = await providersDb.getProviderConnectionById(connection.id); + const updated = await providersDb.getProviderConnectionById((connection as any).id); const selected = await auth.getProviderCredentials("codex", null, null, "codex-spark-mini"); assert.equal(result.shouldFallback, true); assert.ok(result.cooldownMs > 0); assert.equal(updated.testStatus, "unavailable"); assert.equal(updated.rateLimitedUntil, undefined); - assert.ok(updated.providerSpecificData.codexScopeRateLimitedUntil.spark); + assert.ok((updated.providerSpecificData as any).codexScopeRateLimitedUntil.spark); assert.equal(selected.allRateLimited, true); }); @@ -907,13 +913,13 @@ test("markAccountUnavailable returns without fallback on bad requests", async () }); const result = await auth.markAccountUnavailable( - connection.id, + (connection as any).id, 400, "schema mismatch", "openai", "gpt-4o" ); - const updated = await providersDb.getProviderConnectionById(connection.id); + const updated = await providersDb.getProviderConnectionById((connection as any).id); assert.deepEqual(result, { shouldFallback: false, cooldownMs: 0 }); assert.equal(updated.testStatus, "active"); @@ -927,8 +933,13 @@ test("markAccountUnavailable preserves terminal statuses without overwriting the rateLimitedUntil: null, }); - const result = await auth.markAccountUnavailable(connection.id, 503, "upstream error", "openai"); - const updated = await providersDb.getProviderConnectionById(connection.id); + const result = await auth.markAccountUnavailable( + (connection as any).id, + 503, + "upstream error", + "openai" + ); + const updated = await providersDb.getProviderConnectionById((connection as any).id); assert.equal(result.shouldFallback, true); assert.equal(result.cooldownMs, 0); @@ -943,8 +954,13 @@ test("markAccountUnavailable reuses an existing connection-wide cooldown", async rateLimitedUntil: retryAfter, }); - const result = await auth.markAccountUnavailable(connection.id, 503, "upstream error", "openai"); - const updated = await providersDb.getProviderConnectionById(connection.id); + const result = await auth.markAccountUnavailable( + (connection as any).id, + 503, + "upstream error", + "openai" + ); + const updated = await providersDb.getProviderConnectionById((connection as any).id); assert.equal(result.shouldFallback, true); assert.ok(result.cooldownMs > 0); @@ -968,18 +984,21 @@ test("markAccountUnavailable reuses an existing Codex scope cooldown", async () }); const result = await auth.markAccountUnavailable( - connection.id, + (connection as any).id, 429, "quota reached", "codex", "codex-spark-mini" ); - const updated = await providersDb.getProviderConnectionById(connection.id); + const updated = await providersDb.getProviderConnectionById((connection as any).id); assert.equal(result.shouldFallback, true); assert.ok(result.cooldownMs > 0); assert.equal(updated.rateLimitedUntil, undefined); - assert.equal(updated.providerSpecificData.codexScopeRateLimitedUntil.spark, retryAfter); + (assert as any).equal( + (updated.providerSpecificData as any).codexScopeRateLimitedUntil.spark, + retryAfter + ); }); test("markAccountUnavailable uses a connection-wide cooldown for non-local 404 errors", async () => { @@ -991,13 +1010,13 @@ test("markAccountUnavailable uses a connection-wide cooldown for non-local 404 e }); const result = await auth.markAccountUnavailable( - connection.id, + (connection as any).id, 404, "model not found", "openai", "gpt-missing" ); - const updated = await providersDb.getProviderConnectionById(connection.id); + const updated = await providersDb.getProviderConnectionById((connection as any).id); assert.equal(result.shouldFallback, true); assert.ok(result.cooldownMs > 0); @@ -1012,13 +1031,13 @@ test("markAccountUnavailable auto-disables permanently banned accounts when the }); const result = await auth.markAccountUnavailable( - connection.id, + (connection as any).id, 401, "Verify your account to continue", "openai", "gpt-4o" ); - const updated = await providersDb.getProviderConnectionById(connection.id); + const updated = await providersDb.getProviderConnectionById((connection as any).id); assert.equal(result.shouldFallback, true); assert.equal(updated.isActive, false); @@ -1032,13 +1051,13 @@ test("markAccountUnavailable leaves permanently banned accounts active when auto }); const result = await auth.markAccountUnavailable( - connection.id, + (connection as any).id, 401, "Verify your account to continue", "openai", "gpt-4o" ); - const updated = await providersDb.getProviderConnectionById(connection.id); + const updated = await providersDb.getProviderConnectionById((connection as any).id); assert.equal(result.shouldFallback, true); assert.equal(updated.isActive, true); @@ -1076,13 +1095,13 @@ test("markAccountUnavailable swallows auto-disable persistence errors", async () try { const result = await auth.markAccountUnavailable( - connection.id, + (connection as any).id, 401, "Verify your account to continue", "openai", "gpt-4o" ); - const updated = await providersDb.getProviderConnectionById(connection.id); + const updated = await providersDb.getProviderConnectionById((connection as any).id); assert.equal(result.shouldFallback, true); assert.equal(updated.isActive, true); diff --git a/tests/unit/sse-parser.test.ts b/tests/unit/sse-parser.test.ts index ed2e91b028..82b4fd655f 100644 --- a/tests/unit/sse-parser.test.ts +++ b/tests/unit/sse-parser.test.ts @@ -103,12 +103,12 @@ test("parseSSEToClaudeResponse parses text, thinking, tool_use, and usage events assert.equal(parsed.id, "msg_1"); assert.equal(parsed.model, "claude-3-5-sonnet"); - assert.equal(parsed.content[0].type, "thinking"); - assert.equal(parsed.content[0].thinking, "step 1"); - assert.equal(parsed.content[0].signature, "sig-1"); - assert.equal(parsed.content[1].text, "Hello"); - assert.equal(parsed.content[2].type, "tool_use"); - assert.deepEqual(parsed.content[2].input, { q: "docs" }); + assert.equal((parsed.content[0] as any).type, "thinking"); + assert.equal((parsed as any).content[0].thinking, "step 1"); + assert.equal((parsed as any).content[0].signature, "sig-1"); + (assert as any).equal((parsed.content[1] as any).text, "Hello"); + assert.equal((parsed.content[2] as any).type, "tool_use"); + (assert as any).deepEqual((parsed.content[2] as any).input, { q: "docs" }); assert.equal(parsed.stop_reason, "tool_use"); assert.equal(parsed.stop_sequence, "END"); assert.deepEqual(parsed.usage, { input_tokens: 10, output_tokens: 4 }); diff --git a/tests/unit/stream-utils.test.ts b/tests/unit/stream-utils.test.ts index 51b5098b27..9117d4fca8 100644 --- a/tests/unit/stream-utils.test.ts +++ b/tests/unit/stream-utils.test.ts @@ -281,11 +281,11 @@ test("buildStreamSummaryFromEvents falls back to response.output_text.delta when "gpt-5.4" ); - assert.equal(summary.object, "response"); - assert.equal(summary.output[0].type, "message"); - assert.equal(summary.output[0].content[0].type, "output_text"); - assert.equal(summary.output[0].content[0].text, "Hello world"); - assert.equal(summary.usage.output_tokens, 2); + assert.equal((summary as any).object, "response"); + assert.equal((summary as any).output[0].type, "message"); + assert.equal((summary as any).output[0].content[0].type, "output_text"); + assert.equal((summary as any).output[0].content[0].text, "Hello world"); + assert.equal((summary as any).usage.output_tokens, 2); }); test("createSSEStream translate mode aborts on Responses failure with rate limit error", async () => { @@ -678,10 +678,10 @@ test("buildStreamSummaryFromEvents compacts Responses API deltas into a syntheti "gpt-4.1-mini" ); - assert.equal(summary.object, "response"); - assert.equal(summary.model, "gpt-4.1-mini"); - assert.equal(summary.output[0].content[0].text, "Hello world"); - assert.deepEqual(summary.usage, { input_tokens: 2, output_tokens: 3, total_tokens: 5 }); + assert.equal((summary as any).object, "response"); + assert.equal((summary as any).model, "gpt-4.1-mini"); + assert.equal((summary as any).output[0].content[0].text, "Hello world"); + assert.deepEqual((summary as any).usage, { input_tokens: 2, output_tokens: 3, total_tokens: 5 }); }); test("buildStreamSummaryFromEvents preserves Gemini thought parts and function calls", () => { @@ -731,13 +731,13 @@ test("buildStreamSummaryFromEvents preserves Gemini thought parts and function c "gemini-2.5-pro" ); - assert.equal(summary.modelVersion, "gemini-2.5-pro"); - assert.equal(summary.candidates[0].content.parts[0].text, "Thinking aloud"); - assert.equal(summary.candidates[0].content.parts[0].thought, true); - assert.deepEqual(summary.candidates[0].content.parts[2], { + assert.equal((summary as any).modelVersion, "gemini-2.5-pro"); + assert.equal((summary as any).candidates[0].content.parts[0].text, "Thinking aloud"); + assert.equal((summary as any).candidates[0].content.parts[0].thought, true); + assert.deepEqual((summary as any).candidates[0].content.parts[2], { functionCall: { name: "read_file", args: { path: "/tmp/a" } }, }); - assert.deepEqual(summary.usageMetadata, { + assert.deepEqual((summary as any).usageMetadata, { promptTokenCount: 4, candidatesTokenCount: 5, totalTokenCount: 9, diff --git a/tests/unit/sync-bundle.test.ts b/tests/unit/sync-bundle.test.ts index c60a38b96c..3febe02d58 100644 --- a/tests/unit/sync-bundle.test.ts +++ b/tests/unit/sync-bundle.test.ts @@ -82,7 +82,7 @@ test("config sync bundle is deterministic, strips auth settings, and ignores vol assert.equal(first.bundle.providerConnections[0].apiKey, "sk-live-secret"); assert.equal(first.bundle.modelAliases["smart-default"], "openai/gpt-4o-mini"); - await providersDb.updateProviderConnection(connection.id, { + await providersDb.updateProviderConnection((connection as any).id, { lastError: "temporary upstream failure", lastErrorAt: "2026-04-14T12:00:00.000Z", rateLimitedUntil: "2026-04-14T12:30:00.000Z", @@ -91,7 +91,7 @@ test("config sync bundle is deterministic, strips auth settings, and ignores vol const afterVolatileChange = await syncBundle.buildConfigSyncEnvelope(); assert.equal(afterVolatileChange.version, first.version); - await providersDb.updateProviderConnection(connection.id, { + await providersDb.updateProviderConnection((connection as any).id, { defaultModel: "gpt-4.1-mini", }); diff --git a/tests/unit/sync-routes.test.ts b/tests/unit/sync-routes.test.ts index 3bc1f09efa..c12c0756ca 100644 --- a/tests/unit/sync-routes.test.ts +++ b/tests/unit/sync-routes.test.ts @@ -86,7 +86,7 @@ test("sync token routes issue, list, use and revoke dedicated tokens", async () ); assert.equal(createResponse.status, 201); - const createdBody = await createResponse.json(); + const createdBody = (await createResponse.json()) as any; assert.match(createdBody.token, /^osync_/); assert.equal(createdBody.syncToken.name, "Desktop client"); assert.equal(createdBody.syncToken.syncApiKeyId, managementKey.id); @@ -97,7 +97,7 @@ test("sync token routes issue, list, use and revoke dedicated tokens", async () }) ); assert.equal(listResponse.status, 200); - const listed = await listResponse.json(); + const listed = (await listResponse.json()) as any; assert.equal(listed.total, 1); assert.equal(listed.tokens[0].name, "Desktop client"); assert.equal(listed.tokens[0].lastUsedAt, null); @@ -113,7 +113,7 @@ test("sync token routes issue, list, use and revoke dedicated tokens", async () assert.equal(bundleResponse.status, 200); assert.match(bundleResponse.headers.get("etag") || "", /^"[a-f0-9]{64}"$/); assert.match(bundleResponse.headers.get("x-config-version") || "", /^[a-f0-9]{64}$/); - const bundlePayload = await bundleResponse.json(); + const bundlePayload = (await bundleResponse.json()) as any; assert.equal(bundlePayload.version, bundleResponse.headers.get("x-config-version")); assert.equal(typeof bundlePayload.bundle, "object"); @@ -122,7 +122,7 @@ test("sync token routes issue, list, use and revoke dedicated tokens", async () token: managementKey.key, }) ); - const secondListBody = await secondListResponse.json(); + const secondListBody = (await secondListResponse.json()) as any; assert.equal(typeof secondListBody.tokens[0].lastUsedAt, "string"); const notModifiedResponse = await syncBundleRoute.GET( @@ -151,7 +151,7 @@ test("sync token routes issue, list, use and revoke dedicated tokens", async () ); assert.equal(revokeResponse.status, 200); - const revokeBody = await revokeResponse.json(); + const revokeBody = (await revokeResponse.json()) as any; assert.equal(typeof revokeBody.syncToken.revokedAt, "string"); const revokedBundleResponse = await syncBundleRoute.GET( diff --git a/tests/unit/t14-proxy-fast-fail.test.ts b/tests/unit/t14-proxy-fast-fail.test.ts index 20a8b11327..8de0f643d3 100644 --- a/tests/unit/t14-proxy-fast-fail.test.ts +++ b/tests/unit/t14-proxy-fast-fail.test.ts @@ -28,7 +28,7 @@ test("T14: runWithProxyContext fast-fails when proxy is unreachable", async () = executed = true; return "ok"; }), - (err) => err?.code === "PROXY_UNREACHABLE" + (err) => (err as any).code === "PROXY_UNREACHABLE" ); assert.equal(executed, false); diff --git a/tests/unit/t19-codex-responses-empty-content.test.ts b/tests/unit/t19-codex-responses-empty-content.test.ts index cc488073c8..008f1bd7b2 100644 --- a/tests/unit/t19-codex-responses-empty-content.test.ts +++ b/tests/unit/t19-codex-responses-empty-content.test.ts @@ -34,7 +34,7 @@ test("T19: picks the last non-empty message content from Responses output", () = FORMATS.OPENAI ); - assert.equal(translated.choices[0].message.content, "Resposta final"); + assert.equal((translated as any).choices[0].message.content, "Resposta final"); }); test("T19: falls back to last message block when all message texts are empty", () => { @@ -61,6 +61,6 @@ test("T19: falls back to last message block when all message texts are empty", ( FORMATS.OPENAI ); - assert.equal(translated.choices[0].message.content, ""); - assert.equal(translated.choices[0].finish_reason, "stop"); + assert.equal((translated as any).choices[0].message.content, ""); + (assert as any).equal((translated as any).choices[0].finish_reason, "stop"); }); diff --git a/tests/unit/t23-t24-fallback-resilience.test.ts b/tests/unit/t23-t24-fallback-resilience.test.ts index db64ac4289..d72a0b275f 100644 --- a/tests/unit/t23-t24-fallback-resilience.test.ts +++ b/tests/unit/t23-t24-fallback-resilience.test.ts @@ -137,7 +137,7 @@ test("T24: all inactive accounts return 503 service_unavailable (not 406)", asyn }); assert.equal(result.status, 503); - const body = await result.json(); + const body = (await result.json()) as any; assert.equal(body.error?.code, "ALL_ACCOUNTS_INACTIVE"); }); diff --git a/tests/unit/tag-routing.test.ts b/tests/unit/tag-routing.test.ts index 3d8995c3a9..c87f929dc7 100644 --- a/tests/unit/tag-routing.test.ts +++ b/tests/unit/tag-routing.test.ts @@ -106,7 +106,7 @@ test("handleComboChat filters priority targets by metadata.tags using any-match log: createLog(), }); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 200); assert.equal(attempts.length, 1); assert.equal(attempts[0].model, "fireworks/gpt-4o-mini"); diff --git a/tests/unit/telemetry-summary-route.test.ts b/tests/unit/telemetry-summary-route.test.ts index ce50cc3e9f..1fc8d58ded 100644 --- a/tests/unit/telemetry-summary-route.test.ts +++ b/tests/unit/telemetry-summary-route.test.ts @@ -25,7 +25,7 @@ test("telemetry summary route includes totalRequests alias plus session/quota mo const response = await GET( new Request("http://localhost:20128/api/telemetry/summary?windowMs=600000") ); - const payload = await response.json(); + const payload = (await response.json()) as any; assert.equal(response.status, 200); assert.ok(payload.totalRequests >= 1); diff --git a/tests/unit/token-health-check.test.ts b/tests/unit/token-health-check.test.ts index 8cc9bea5e6..8f7cb92fcd 100644 --- a/tests/unit/token-health-check.test.ts +++ b/tests/unit/token-health-check.test.ts @@ -26,7 +26,7 @@ async function resetStorage() { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } break; - } catch (error) { + } catch (error: any) { if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) { await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); } else { @@ -251,7 +251,7 @@ test("checkConnection uses the resolved proxy payload when refreshing tokens", a isActive: true, }); - await settingsDb.setProxyForLevel("key", connection.id, { + await settingsDb.setProxyForLevel("key", (connection as any).id, { type: "http", host: proxy.host, port: proxy.port, @@ -259,7 +259,7 @@ test("checkConnection uses the resolved proxy payload when refreshing tokens", a await tokenHealthCheck.checkConnection(connection); - const updated = await providersDb.getProviderConnectionById(connection.id); + const updated = await providersDb.getProviderConnectionById((connection as any).id); assert.equal(refreshRequests.length, 1); assert.equal(refreshRequests[0].method, "POST"); @@ -325,14 +325,14 @@ test("checkConnection uses the latest stored refresh token instead of a stale sw }); const staleCheckTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); - await providersDb.updateProviderConnection(connection.id, { + await providersDb.updateProviderConnection((connection as any).id, { refreshToken: "snapshot-refresh-current", lastHealthCheckAt: staleCheckTime, }); await tokenHealthCheck.checkConnection(connection); - const updated = await providersDb.getProviderConnectionById(connection.id); + const updated = await providersDb.getProviderConnectionById((connection as any).id); assert.equal(refreshRequests.length, 1); assert.match(refreshRequests[0], /refresh_token=snapshot-refresh-current/); assert.equal(updated?.refreshToken, "snapshot-refresh-next"); @@ -382,13 +382,13 @@ test("checkConnection skips interval refresh when token expiry is known and stil }); const staleCheckTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); - await providersDb.updateProviderConnection(connection.id, { + await providersDb.updateProviderConnection((connection as any).id, { lastHealthCheckAt: staleCheckTime, }); await tokenHealthCheck.checkConnection(connection); - const updated = await providersDb.getProviderConnectionById(connection.id); + const updated = await providersDb.getProviderConnectionById((connection as any).id); assert.equal(refreshCount, 0); assert.equal(updated?.accessToken, "known-expiry-access"); assert.equal(updated?.refreshToken, "known-expiry-refresh"); diff --git a/tests/unit/token-refresh-route-service.test.ts b/tests/unit/token-refresh-route-service.test.ts index 7780b318fe..546f9ca56a 100644 --- a/tests/unit/token-refresh-route-service.test.ts +++ b/tests/unit/token-refresh-route-service.test.ts @@ -186,13 +186,13 @@ test("updateProviderCredentials persists rotated tokens and returns false for mi refreshToken: "refresh-old", }); - const updated = await tokenRefresh.updateProviderCredentials(connection.id, { + const updated = await tokenRefresh.updateProviderCredentials((connection as any).id, { accessToken: "access-new", refreshToken: "refresh-new", expiresIn: 600, providerSpecificData: { tenant: "team-a" }, }); - const stored = await providersDb.getProviderConnectionById(connection.id); + const stored = await providersDb.getProviderConnectionById((connection as any).id); const missing = await tokenRefresh.updateProviderCredentials("missing", { accessToken: "nope" }); assert.equal(updated, true); @@ -232,7 +232,7 @@ test("checkAndRefreshToken refreshes expiring OAuth access tokens and updates th ...connection, connectionId: connection.id, }); - const stored = await providersDb.getProviderConnectionById(connection.id); + const stored = await providersDb.getProviderConnectionById((connection as any).id); assert.equal(refreshed.accessToken, "claude-access-fresh"); assert.equal(refreshed.refreshToken, "claude-refresh-fresh"); @@ -275,12 +275,12 @@ test("checkAndRefreshToken refreshes expiring GitHub copilot tokens and syncs th ...connection, connectionId: connection.id, }); - const stored = await providersDb.getProviderConnectionById(connection.id); + const stored = await providersDb.getProviderConnectionById((connection as any).id); assert.equal(refreshed.copilotToken, "copilot-fresh"); assert.equal(refreshed.providerSpecificData.copilotToken, "copilot-fresh"); - assert.equal(stored.providerSpecificData.copilotToken, "copilot-fresh"); - assert.equal(stored.providerSpecificData.copilotTokenExpiresAt, 1_700_001_000); + assert.equal((stored as any).providerSpecificData.copilotToken, "copilot-fresh"); + assert.equal((stored as any).providerSpecificData.copilotTokenExpiresAt, 1_700_001_000); } ); }); diff --git a/tests/unit/tool-request-sanitization.test.ts b/tests/unit/tool-request-sanitization.test.ts index 47e435bbe3..08e458b7b1 100644 --- a/tests/unit/tool-request-sanitization.test.ts +++ b/tests/unit/tool-request-sanitization.test.ts @@ -25,10 +25,10 @@ test("tool sanitization: coerces numeric JSON Schema fields recursively", () => }; const result = coerceSchemaNumericFields(schema); - assert.equal(result.properties.count.minimum, 1); - assert.equal(result.properties.count.maximum, 10); - assert.equal(result.properties.items.minItems, 2); - assert.equal(result.properties.items.items.minLength, 3); + assert.equal((result as any).properties.count.minimum, 1); + (assert as any).equal((result as any).properties.count.maximum, 10); + (assert as any).equal((result as any).properties.items.minItems, 2); + assert.equal((result as any).properties.items.items.minLength, 3); }); test("tool sanitization: preserves non-numeric JSON Schema strings", () => { @@ -40,7 +40,7 @@ test("tool sanitization: preserves non-numeric JSON Schema strings", () => { }; const result = coerceSchemaNumericFields(schema); - assert.equal(result.properties.value.minimum, "abc"); + assert.equal((result as any).properties.value.minimum, "abc"); }); test("tool sanitization: normalizes descriptions across OpenAI, Claude, and Gemini shapes", () => { @@ -57,9 +57,9 @@ test("tool sanitization: normalizes descriptions across OpenAI, Claude, and Gemi functionDeclarations: [{ name: "sum", description: false, parameters: {} }], }); - assert.equal(openAITool.function.description, ""); - assert.equal(claudeTool.description, "42"); - assert.equal(geminiTool.functionDeclarations[0].description, "false"); + (assert as any).equal(((openAITool as any).function as any).description, ""); + assert.equal((claudeTool as any).description, "42"); + assert.equal((geminiTool as any).functionDeclarations[0].description, "false"); }); test("tool sanitization: coerces schemas and descriptions in tool arrays", () => { diff --git a/tests/unit/translator-antigravity-to-openai.test.ts b/tests/unit/translator-antigravity-to-openai.test.ts index 0473ebaa26..0886c131be 100644 --- a/tests/unit/translator-antigravity-to-openai.test.ts +++ b/tests/unit/translator-antigravity-to-openai.test.ts @@ -182,7 +182,7 @@ test("Antigravity -> OpenAI lowers schema types recursively", () => { false ); - assert.deepEqual(result.tools[0].function.parameters, { + assert.deepEqual((result.tools[0].function as any).parameters, { type: "object", properties: { items: { diff --git a/tests/unit/translator-claude-to-openai.test.ts b/tests/unit/translator-claude-to-openai.test.ts index febcfae8b1..f9f8fb3c99 100644 --- a/tests/unit/translator-claude-to-openai.test.ts +++ b/tests/unit/translator-claude-to-openai.test.ts @@ -37,7 +37,7 @@ test("Claude -> OpenAI maps system blocks, parameters, tool declarations and too role: "user", content: "Hello", }); - assert.equal(result.tools.length, 1); + assert.equal((result.tools as any).length, 1); assert.deepEqual(result.tools[0], { type: "function", function: { diff --git a/tests/unit/translator-helper-branches.test.ts b/tests/unit/translator-helper-branches.test.ts index 33a59a0302..9f80646b07 100644 --- a/tests/unit/translator-helper-branches.test.ts +++ b/tests/unit/translator-helper-branches.test.ts @@ -47,24 +47,24 @@ test("schemaCoercion recursively coerces schema numeric fields across object var else: { minItems: "18" }, }); - assert.equal(result.minimum, 1); - assert.equal(result.maxItems, 5); - assert.equal(result.properties.nested.minLength, 2); - assert.equal(result.properties.nested.items.maximum, 7); - assert.equal(result.patternProperties["^x-"].minProperties, 1); - assert.equal(result.definitions.one.exclusiveMaximum, 9); - assert.equal(result.$defs.two.minItems, 3); - assert.equal(result.dependentSchemas.dep.maxProperties, 4); - assert.equal(result.additionalProperties.maximum, 8); - assert.equal(result.unevaluatedProperties.minimum, 0); - assert.equal(result.prefixItems[0].minimum, 11); - assert.equal(result.anyOf[0].maximum, 12); - assert.equal(result.oneOf[0].minimum, 13); - assert.equal(result.allOf[0].maxLength, 14); - assert.equal(result.not.minimum, 15); - assert.equal(result.if.minimum, 16); - assert.equal(result.then.maximum, 17); - assert.equal(result.else.minItems, 18); + assert.equal((result as any).minimum, 1); + (assert as any).equal((result as any).maxItems, 5); + (assert as any).equal((result as any).properties.nested.minLength, 2); + assert.equal((result as any).properties.nested.items.maximum, 7); + assert.equal((result as any).patternProperties["^x-"].minProperties, 1); + assert.equal((result as any).definitions.one.exclusiveMaximum, 9); + assert.equal((result as any).$defs.two.minItems, 3); + assert.equal((result as any).dependentSchemas.dep.maxProperties, 4); + assert.equal((result as any).additionalProperties.maximum, 8); + assert.equal((result as any).unevaluatedProperties.minimum, 0); + assert.equal((result as any).prefixItems[0].minimum, 11); + assert.equal((result as any).anyOf[0].maximum, 12); + assert.equal((result as any).oneOf[0].minimum, 13); + assert.equal((result as any).allOf[0].maxLength, 14); + assert.equal((result as any).not.minimum, 15); + assert.equal((result as any).if.minimum, 16); + assert.equal((result as any).then.maximum, 17); + assert.equal((result as any).else.minItems, 18); assert.equal(schemaCoercion.coerceSchemaNumericFields("unchanged"), "unchanged"); assert.deepEqual(schemaCoercion.coerceSchemaNumericFields(["2", { minimum: "3" }]), [ @@ -78,19 +78,19 @@ test("schemaCoercion sanitizes descriptions, tool schemas, tool ids and deepseek type: "function", function: { name: "weather", description: 42 }, }); - assert.equal(sanitizedOpenAI.function.description, "42"); + (assert as any).equal((sanitizedOpenAI as any).function.description, "42"); const sanitizedClaude = schemaCoercion.sanitizeToolDescription({ name: "weather", description: null, }); - assert.equal(sanitizedClaude.description, ""); + assert.equal((sanitizedClaude as any).description, ""); const sanitizedGemini = schemaCoercion.sanitizeToolDescription({ functionDeclarations: [{ name: "one", description: 12 }, { name: "two" }], }); - assert.equal(sanitizedGemini.functionDeclarations[0].description, "12"); - assert.equal(sanitizedGemini.functionDeclarations[1].name, "two"); + assert.equal((sanitizedGemini as any).functionDeclarations[0].description, "12"); + assert.equal((sanitizedGemini as any).functionDeclarations[1].name, "two"); assert.equal(schemaCoercion.sanitizeToolDescription("plain"), "plain"); const coercedTools = schemaCoercion.coerceToolSchemas([ diff --git a/tests/unit/translator-openai-responses-req.test.ts b/tests/unit/translator-openai-responses-req.test.ts index bf907e7e19..118a3a4fd7 100644 --- a/tests/unit/translator-openai-responses-req.test.ts +++ b/tests/unit/translator-openai-responses-req.test.ts @@ -45,7 +45,7 @@ test("Responses -> Chat converts instructions, inputs, function calls, outputs, null ); - assert.deepEqual(result.messages, [ + assert.deepEqual((result as any).messages, [ { role: "system", content: "Rules" }, { role: "user", @@ -68,7 +68,7 @@ test("Responses -> Chat converts instructions, inputs, function calls, outputs, }, { role: "tool", tool_call_id: "call_1", content: '{"ok":true}' }, ]); - assert.deepEqual(result.tools, [ + (assert as any).deepEqual((result as any).tools, [ { type: "function", function: { @@ -79,7 +79,10 @@ test("Responses -> Chat converts instructions, inputs, function calls, outputs, }, }, ]); - assert.deepEqual(result.tool_choice, { type: "function", function: { name: "read_file" } }); + (assert as any).deepEqual((result as any).tool_choice, { + type: "function", + function: { name: "read_file" }, + }); }); test("Responses -> Chat filters orphan tool outputs and supports role-based message items", () => { @@ -97,10 +100,10 @@ test("Responses -> Chat filters orphan tool outputs and supports role-based mess null ); - assert.equal(result.messages.length, 3); - assert.equal(result.messages[0].role, "user"); - assert.equal(result.messages[1].tool_calls[0].id, "call_2"); - assert.deepEqual(result.messages[2], { + assert.equal((result as any).messages.length, 3); + assert.equal((result as any).messages[0].role, "user"); + assert.equal((result as any).messages[1].tool_calls[0].id, "call_2"); + (assert as any).deepEqual((result as any).messages[2], { role: "tool", tool_call_id: "call_2", content: "found", @@ -119,7 +122,7 @@ test("Responses -> Chat rejects unsupported built-in tools and background mode", false, null ), - (error) => error.statusCode === 400 && error.errorType === "unsupported_feature" + (error: any) => error.statusCode === 400 && error.errorType === "unsupported_feature" ); assert.throws( @@ -133,7 +136,7 @@ test("Responses -> Chat rejects unsupported built-in tools and background mode", false, null ), - (error) => error.statusCode === 400 && error.errorType === "unsupported_feature" + (error: any) => error.statusCode === 400 && error.errorType === "unsupported_feature" ); }); @@ -188,11 +191,11 @@ test("Chat -> Responses converts messages, tool calls, tool outputs, tools and p null ); - assert.equal(result.instructions, "Rules"); - assert.equal(result.stream, true); - assert.equal(result.store, false); - assert.equal(result.previous_response_id, "resp_prev_123"); - assert.deepEqual(result.input, [ + assert.equal((result as any).instructions, "Rules"); + assert.equal((result as any).stream, true); + assert.equal((result as any).store, false); + assert.equal((result as any).previous_response_id, "resp_prev_123"); + assert.deepEqual((result as any).input, [ { type: "message", role: "user", @@ -219,7 +222,7 @@ test("Chat -> Responses converts messages, tool calls, tool outputs, tools and p output: [{ type: "input_text", text: "ok" }], }, ]); - assert.deepEqual(result.tools, [ + assert.deepEqual((result as any).tools, [ { type: "function", name: "read_file", @@ -228,10 +231,10 @@ test("Chat -> Responses converts messages, tool calls, tool outputs, tools and p strict: true, }, ]); - assert.deepEqual(result.tool_choice, { type: "function", name: "read_file" }); - assert.equal(result.temperature, 0.2); - assert.equal(result.max_output_tokens, 100); - assert.equal(result.top_p, 0.9); + assert.deepEqual((result as any).tool_choice, { type: "function", name: "read_file" }); + assert.equal((result as any).temperature, 0.2); + assert.equal((result as any).max_output_tokens, 100); + assert.equal((result as any).top_p, 0.9); }); test("Responses round-trip preserves store and previous_response_id when opt-in is enabled", () => { @@ -255,9 +258,9 @@ test("Responses round-trip preserves store and previous_response_id when opt-in const result = openaiToOpenAIResponsesRequest("gpt-4o", chatBody, false, credentials); - assert.equal(result.previous_response_id, "resp_prev_store"); - assert.equal(result.store, true); - assert.equal(result.instructions, "Rules"); + assert.equal((result as any).previous_response_id, "resp_prev_store"); + assert.equal((result as any).store, true); + assert.equal((result as any).instructions, "Rules"); }); test("Chat -> Responses preserves prompt_cache_key and session affinity fields", () => { @@ -273,10 +276,10 @@ test("Chat -> Responses preserves prompt_cache_key and session affinity fields", { providerSpecificData: { openaiStoreEnabled: true } } ); - assert.equal(result.prompt_cache_key, "cache-key-1"); - assert.equal(result.session_id, "omniroute-session-abc"); - assert.equal(result.conversation_id, "conv-123"); - assert.equal(result.store, undefined); + (assert as any).equal((result as any).prompt_cache_key, "cache-key-1"); + (assert as any).equal((result as any).session_id, "omniroute-session-abc"); + assert.equal((result as any).conversation_id, "conv-123"); + assert.equal((result as any).store, undefined); }); test("Chat -> Responses preserves explicit reasoning objects", () => { @@ -290,8 +293,8 @@ test("Chat -> Responses preserves explicit reasoning objects", () => { null ); - assert.deepEqual(result.reasoning, { effort: "low" }); - assert.equal(result.store, false); + assert.deepEqual((result as any).reasoning, { effort: "low" }); + assert.equal((result as any).store, false); }); test("Chat -> Responses maps reasoning_effort into Responses reasoning", () => { @@ -305,9 +308,9 @@ test("Chat -> Responses maps reasoning_effort into Responses reasoning", () => { null ); - assert.deepEqual(result.reasoning, { effort: "low" }); - assert.equal(result.reasoning_effort, undefined); - assert.equal(result.store, false); + assert.deepEqual((result as any).reasoning, { effort: "low" }); + assert.equal((result as any).reasoning_effort, undefined); + assert.equal((result as any).store, false); }); test("Chat -> Responses filters orphan function_call_output items and leaves empty instructions when absent", () => { @@ -334,13 +337,19 @@ test("Chat -> Responses filters orphan function_call_output items and leaves emp null ); - assert.equal(result.instructions, ""); + assert.equal((result as any).instructions, ""); assert.equal( - result.input.some((item) => item.call_id === "orphan"), + (result as any).input.some((item) => item.call_id === "orphan"), false ); - assert.equal(result.input.filter((item) => item.type === "function_call_output").length, 1); - assert.equal(result.input.find((item) => item.type === "function_call_output").call_id, "call_2"); + assert.equal( + (result as any).input.filter((item) => item.type === "function_call_output").length, + 1 + ); + assert.equal( + (result as any).input.find((item) => item.type === "function_call_output").call_id, + "call_2" + ); }); test("Chat -> Responses maps max_completion_tokens to max_output_tokens", () => { @@ -354,9 +363,9 @@ test("Chat -> Responses maps max_completion_tokens to max_output_tokens", () => null ); - assert.equal(result.max_output_tokens, 2048); - assert.equal(result.max_tokens, undefined); - assert.equal(result.max_completion_tokens, undefined); + (assert as any).equal((result as any).max_output_tokens, 2048); + assert.equal((result as any).max_tokens, undefined); + assert.equal((result as any).max_completion_tokens, undefined); }); test("Chat -> Responses maps legacy max_tokens to max_output_tokens when max_completion_tokens is absent", () => { @@ -370,8 +379,8 @@ test("Chat -> Responses maps legacy max_tokens to max_output_tokens when max_com null ); - assert.equal(result.max_output_tokens, 512); - assert.equal(result.max_tokens, undefined); + assert.equal((result as any).max_output_tokens, 512); + assert.equal((result as any).max_tokens, undefined); }); test("Chat -> Responses prefers max_completion_tokens over max_tokens when both are present", () => { @@ -386,7 +395,7 @@ test("Chat -> Responses prefers max_completion_tokens over max_tokens when both null ); - assert.equal(result.max_output_tokens, 4096); - assert.equal(result.max_tokens, undefined); - assert.equal(result.max_completion_tokens, undefined); + (assert as any).equal((result as any).max_output_tokens, 4096); + assert.equal((result as any).max_tokens, undefined); + assert.equal((result as any).max_completion_tokens, undefined); }); diff --git a/tests/unit/translator-openai-to-kiro.test.ts b/tests/unit/translator-openai-to-kiro.test.ts index 73419eea08..9694128f13 100644 --- a/tests/unit/translator-openai-to-kiro.test.ts +++ b/tests/unit/translator-openai-to-kiro.test.ts @@ -96,7 +96,7 @@ test("OpenAI -> Kiro preserves prior history, tool uses and accumulated tool res }); const context = result.conversationState.currentMessage.userInputMessage.userInputMessageContext; - assert.equal(context.toolResults.length, 2); + assert.equal((context.toolResults as any).length, 2); assert.deepEqual(context.toolResults[0], { toolUseId: "call_1", status: "success", @@ -114,8 +114,14 @@ test("OpenAI -> Kiro derives a stable conversationId for the same first history const first = buildSamplePayload(); const second = buildSamplePayload(); - assert.equal(first.conversationState.history[0].userInputMessage.content, "Rules\n\nHello"); - assert.equal(second.conversationState.history[0].userInputMessage.content, "Rules\n\nHello"); + assert.equal( + (first.conversationState as any).history[0].userInputMessage.content, + "Rules\n\nHello" + ); + assert.equal( + (second as any).conversationState.history[0].userInputMessage.content, + "Rules\n\nHello" + ); assert.equal(first.conversationState.conversationId, second.conversationState.conversationId); }); diff --git a/tests/unit/translator-resp-claude-to-openai.test.ts b/tests/unit/translator-resp-claude-to-openai.test.ts index af7f873610..7d3b598dd4 100644 --- a/tests/unit/translator-resp-claude-to-openai.test.ts +++ b/tests/unit/translator-resp-claude-to-openai.test.ts @@ -40,18 +40,18 @@ test("Claude non-stream: text, thinking and tool_use become OpenAI assistant mes new Map([["proxy_read_file", "read_file"]]) ); - assert.equal(result.id, "chatcmpl-msg_123"); - assert.equal(result.model, "claude-3-7-sonnet"); - assert.equal(result.choices[0].message.content, "Final answer"); - assert.equal(result.choices[0].message.reasoning_content, "Plan first."); - assert.equal(result.choices[0].message.tool_calls[0].id, "tool_1"); - assert.equal(result.choices[0].message.tool_calls[0].function.name, "read_file"); - assert.equal( - result.choices[0].message.tool_calls[0].function.arguments, + assert.equal((result as any).id, "chatcmpl-msg_123"); + (assert as any).equal((result as any).model, "claude-3-7-sonnet"); + (assert as any).equal((result as any).choices[0].message.content, "Final answer"); + assert.equal((result as any).choices[0].message.reasoning_content, "Plan first."); + assert.equal((result as any).choices[0].message.tool_calls[0].id, "tool_1"); + assert.equal((result as any).choices[0].message.tool_calls[0].function.name, "read_file"); + (assert as any).equal( + (result as any).choices[0].message.tool_calls[0].function.arguments, JSON.stringify({ path: "/tmp/a" }) ); - assert.equal(result.choices[0].finish_reason, "tool_calls"); - assert.deepEqual(result.usage, { + assert.equal((result as any).choices[0].finish_reason, "tool_calls"); + assert.deepEqual((result as any).usage, { prompt_tokens: 10, completion_tokens: 4, total_tokens: 14, @@ -68,12 +68,12 @@ test("Claude non-stream: end_turn becomes stop and empty text is preserved", () usage: { input_tokens: 2, output_tokens: 1 }, }, FORMATS.CLAUDE, - FORMATS.OPENAI + (FORMATS as any).OPENAI ); - assert.equal(result.choices[0].message.content, ""); - assert.equal(result.choices[0].finish_reason, "stop"); - assert.equal(result.model, "claude-3-5-haiku"); + assert.equal(((result as any).choices[0] as any).message.content, ""); + assert.equal((result as any).choices[0].finish_reason, "stop"); + assert.equal((result as any).model, "claude-3-5-haiku"); }); test("Claude stream: message_start emits initial assistant role chunk", () => { diff --git a/tests/unit/translator-resp-gemini-to-openai.test.ts b/tests/unit/translator-resp-gemini-to-openai.test.ts index a63875f41d..e16539c375 100644 --- a/tests/unit/translator-resp-gemini-to-openai.test.ts +++ b/tests/unit/translator-resp-gemini-to-openai.test.ts @@ -37,14 +37,14 @@ test("Gemini non-stream: single candidate text maps to one OpenAI choice", () => FORMATS.OPENAI ); - assert.equal(result.object, "chat.completion"); - assert.equal(result.id, "chatcmpl-resp-single"); - assert.equal(result.model, "gemini-2.5-flash"); - assert.equal(result.choices.length, 1); - assert.equal(result.choices[0].message.role, "assistant"); - assert.equal(result.choices[0].message.content, "Hello from Gemini"); - assert.equal(result.choices[0].finish_reason, "stop"); - assert.deepEqual(result.usage, { + assert.equal((result as any).object, "chat.completion"); + (assert as any).equal((result as any).id, "chatcmpl-resp-single"); + (assert as any).equal((result as any).model, "gemini-2.5-flash"); + assert.equal((result as any).choices.length, 1); + assert.equal((result as any).choices[0].message.role, "assistant"); + assert.equal((result as any).choices[0].message.content, "Hello from Gemini"); + assert.equal((result as any).choices[0].finish_reason, "stop"); + (assert as any).deepEqual((result as any).usage, { prompt_tokens: 3, completion_tokens: 5, total_tokens: 8, @@ -85,26 +85,29 @@ test("Gemini non-stream: multiple candidates keep multimodal content, reasoning }, }, FORMATS.GEMINI, - FORMATS.OPENAI + (FORMATS as any).OPENAI ); - assert.equal(result.choices.length, 2); - assert.equal(result.choices[0].finish_reason, "tool_calls"); - assert.equal(result.choices[0].message.reasoning_content, "Plan first."); - assert.equal(result.choices[0].message.content[0].text, "Answer:"); - assert.equal(result.choices[0].message.content[1].image_url.url, "data:image/png;base64,abc123"); - assert.equal(result.choices[0].message.tool_calls[0].function.name, "read_file"); + assert.equal((result as any).choices.length, 2); + assert.equal(((result as any).choices as any)[0].finish_reason, "tool_calls"); + assert.equal(((result as any).choices[0] as any).message.reasoning_content, "Plan first."); + assert.equal((result as any).choices[0].message.content[0].text, "Answer:"); assert.equal( - result.choices[0].message.tool_calls[0].function.arguments, + ((result as any).choices[0].message as any).content[1].image_url.url, + "data:image/png;base64,abc123" + ); + assert.equal((result as any).choices[0].message.tool_calls[0].function.name, "read_file"); + assert.equal( + ((result as any).choices[0].message as any).tool_calls[0].function.arguments, JSON.stringify({ path: "/tmp/a" }) ); - assert.equal(result.choices[1].message.content, "Second option"); - assert.equal(result.choices[1].finish_reason, "length"); - assert.equal(result.usage.prompt_tokens, 4); - assert.equal(result.usage.completion_tokens, 8); - assert.equal(result.usage.total_tokens, 12); - assert.equal(result.usage.prompt_tokens_details.cached_tokens, 1); - assert.equal(result.usage.completion_tokens_details.reasoning_tokens, 2); + assert.equal(((result as any).choices[1].message as any).content, "Second option"); + (assert as any).equal((result as any).choices[1].finish_reason, "length"); + assert.equal((result as any).usage.prompt_tokens, 4); + assert.equal((result as any).usage.completion_tokens, 8); + (assert as any).equal((result as any).usage.total_tokens, 12); + assert.equal((result as any).usage.prompt_tokens_details.cached_tokens, 1); + assert.equal((result as any).usage.completion_tokens_details.reasoning_tokens, 2); }); test("Gemini non-stream: promptFeedback-only block becomes content_filter", () => { @@ -115,13 +118,13 @@ test("Gemini non-stream: promptFeedback-only block becomes content_filter", () = promptFeedback: { blockReason: "SAFETY" }, }, FORMATS.GEMINI, - FORMATS.OPENAI + (FORMATS as any).OPENAI ); - assert.equal(result.object, "chat.completion"); - assert.equal(result.choices.length, 1); - assert.equal(result.choices[0].message.content, ""); - assert.equal(result.choices[0].finish_reason, "content_filter"); + assert.equal((result as any).object, "chat.completion"); + assert.equal((result as any).choices.length, 1); + assert.equal((result as any).choices[0].message.content, ""); + assert.equal((result as any).choices[0].finish_reason, "content_filter"); }); test("Gemini non-stream: restores sanitized tool names from the request map", () => { @@ -154,7 +157,7 @@ test("Gemini non-stream: restores sanitized tool names from the request map", () new Map([[sanitizedToolName, originalToolName]]) ); - assert.equal(result.choices[0].message.tool_calls[0].function.name, originalToolName); + assert.equal((result as any).choices[0].message.tool_calls[0].function.name, originalToolName); }); test("Gemini stream: first text chunk emits assistant role then content delta", () => { diff --git a/tests/unit/translator-resp-openai-responses.test.ts b/tests/unit/translator-resp-openai-responses.test.ts index 515256c273..78ba82d5bb 100644 --- a/tests/unit/translator-resp-openai-responses.test.ts +++ b/tests/unit/translator-resp-openai-responses.test.ts @@ -291,10 +291,10 @@ test("Responses -> OpenAI: tool-call delta, reasoning delta and completed usage assert.equal(args.choices[0].delta.tool_calls[0].function.arguments, '{"city":"SP"}'); assert.equal(reasoning.choices[0].delta.reasoning.summary, "Need weather info."); assert.equal(completed.choices[0].finish_reason, "tool_calls"); - assert.equal(completed.usage.prompt_tokens, 8); - assert.equal(completed.usage.completion_tokens, 2); - assert.equal(completed.usage.prompt_tokens_details.cached_tokens, 1); - assert.equal(completed.usage.prompt_tokens_details.cache_creation_tokens, 2); + assert.equal((completed as any).usage.prompt_tokens, 8); + assert.equal((completed as any).usage.completion_tokens, 2); + (assert as any).equal((completed as any).usage.prompt_tokens_details.cached_tokens, 1); + assert.equal((completed as any).usage.prompt_tokens_details.cache_creation_tokens, 2); }); test("Responses -> OpenAI: preserves upstream model instead of defaulting to gpt-4", () => { diff --git a/tests/unit/translator-resp-openai-to-claude.test.ts b/tests/unit/translator-resp-openai-to-claude.test.ts index 89d3e96186..508d0b7c01 100644 --- a/tests/unit/translator-resp-openai-to-claude.test.ts +++ b/tests/unit/translator-resp-openai-to-claude.test.ts @@ -184,17 +184,17 @@ test("OpenAI non-stream: chat completion becomes Claude message with thinking an FORMATS.CLAUDE ); - assert.equal(result.type, "message"); - assert.equal(result.model, "gpt-4.1"); - assert.equal(result.content[0].type, "thinking"); - assert.equal(result.content[0].thinking, "Think first"); - assert.equal(result.content[1].type, "text"); - assert.equal(result.content[1].text, "Final answer"); - assert.equal(result.content[2].type, "tool_use"); - assert.equal(result.content[2].name, "read_file"); - assert.deepEqual(result.content[2].input, { path: "/tmp/a" }); - assert.equal(result.stop_reason, "tool_use"); - assert.deepEqual(result.usage, { + assert.equal((result as any).type, "message"); + (assert as any).equal((result as any).model, "gpt-4.1"); + (assert as any).equal((result as any).content[0].type, "thinking"); + assert.equal((result as any).content[0].thinking, "Think first"); + assert.equal((result as any).content[1].type, "text"); + assert.equal((result as any).content[1].text, "Final answer"); + assert.equal((result as any).content[2].type, "tool_use"); + assert.equal((result as any).content[2].name, "read_file"); + (assert as any).deepEqual((result as any).content[2].input, { path: "/tmp/a" }); + assert.equal((result as any).stop_reason, "tool_use"); + assert.deepEqual((result as any).usage, { input_tokens: 5, output_tokens: 3, }); diff --git a/tests/unit/usage-analytics.test.ts b/tests/unit/usage-analytics.test.ts index ed148ac656..45b4c25a45 100644 --- a/tests/unit/usage-analytics.test.ts +++ b/tests/unit/usage-analytics.test.ts @@ -224,9 +224,24 @@ test("getUsageStats aggregates totals, buckets, pending requests, and cost break timestamp: new Date(Date.now() - 20 * 60 * 1000).toISOString(), }); - usageHistory.trackPendingRequest("pricing-model", "pricing-provider", connection.id, true); - usageHistory.trackPendingRequest("pricing-model", "pricing-provider", connection.id, true); - usageHistory.trackPendingRequest("pricing-model", "pricing-provider", connection.id, false); + usageHistory.trackPendingRequest( + "pricing-model", + "pricing-provider", + (connection as any).id, + true + ); + usageHistory.trackPendingRequest( + "pricing-model", + "pricing-provider" as any, + (connection as any).id, + true + ); + usageHistory.trackPendingRequest( + "pricing-model", + "pricing-provider" as any, + (connection as any).id, + false + ); const stats = await usageStats.getUsageStats(); const expectedCost = diff --git a/tests/unit/usage-migrations.test.ts b/tests/unit/usage-migrations.test.ts index 4738890a1a..7a69df1216 100644 --- a/tests/unit/usage-migrations.test.ts +++ b/tests/unit/usage-migrations.test.ts @@ -296,23 +296,23 @@ test("migrateUsageJsonToSqlite migrates call logs to summary rows and ignores du account: "acct-a", connection_id: "conn-a", detail_state: "ready", - artifact_relpath: rows[0].artifact_relpath, + artifact_relpath: (rows[0] as any).artifact_relpath, has_request_body: 1, has_response_body: 1, error_summary: "bad upstream", }); assert.equal(typeof rows[0].artifact_relpath, "string"); - assert.equal(rows[1].id.length > 0, true); - assert.equal(rows[1].method, "POST"); - assert.equal(rows[1].path, null); - assert.equal(rows[1].status, 0); - assert.equal(rows[1].provider, null); - assert.equal(rows[1].account, null); - assert.equal(rows[1].connection_id, null); - assert.equal(rows[1].detail_state, "ready"); - assert.equal(rows[1].has_request_body, 1); - assert.equal(rows[1].has_response_body, 0); - assert.equal(rows[1].error_summary, null); + assert.equal((rows[1] as any).id.length > 0, true); + assert.equal((rows[1] as any).method, "POST"); + assert.equal((rows[1] as any).path, null); + assert.equal((rows[1] as any).status, 0); + assert.equal((rows[1] as any).provider, null); + assert.equal((rows[1] as any).account, null); + assert.equal((rows[1] as any).connection_id, null); + assert.equal((rows[1] as any).detail_state, "ready"); + assert.equal((rows[1] as any).has_request_body, 1); + assert.equal((rows[1] as any).has_response_body, 0); + assert.equal((rows[1] as any).error_summary, null); const firstArtifact = JSON.parse( fs.readFileSync(path.join(TEST_DATA_DIR, "call_logs", rows[0].artifact_relpath), "utf8") @@ -321,7 +321,10 @@ test("migrateUsageJsonToSqlite migrates call logs to summary rows and ignores du assert.deepEqual(firstArtifact.responseBody, { id: "resp-1" }); const secondArtifact = JSON.parse( - fs.readFileSync(path.join(TEST_DATA_DIR, "call_logs", rows[1].artifact_relpath), "utf8") + fs.readFileSync( + path.join(TEST_DATA_DIR, "call_logs", (rows as any)[1].artifact_relpath), + "utf8" + ) ); assert.deepEqual(secondArtifact.requestBody, { foo: "bar" }); assert.equal(secondArtifact.responseBody, null); @@ -337,8 +340,8 @@ test("migrateUsageJsonToSqlite renames empty JSON payloads without inserting row assert.equal(fs.existsSync(`${CALL_LOGS_JSON_FILE}.migrated`), true); const db = getDbInstance(); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM usage_history").get().count, 0); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM call_logs").get().count, 0); + assert.equal((db.prepare("SELECT COUNT(*) AS count FROM usage_history").get() as any).count, 0); + assert.equal((db.prepare("SELECT COUNT(*) AS count FROM call_logs").get() as any).count, 0); }); test("migrateUsageJsonToSqlite leaves malformed JSON files in place and reports both failures", () => { @@ -364,8 +367,8 @@ test("migrateUsageJsonToSqlite leaves malformed JSON files in place and reports assert.equal(fs.existsSync(`${CALL_LOGS_JSON_FILE}.migrated`), false); const db = getDbInstance(); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM usage_history").get().count, 0); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM call_logs").get().count, 0); + assert.equal((db.prepare("SELECT COUNT(*) AS count FROM usage_history").get() as any).count, 0); + assert.equal((db.prepare("SELECT COUNT(*) AS count FROM call_logs").get() as any).count, 0); assert.ok(errors.some((entry) => entry.includes("Failed to migrate usage.json"))); assert.ok(errors.some((entry) => entry.includes("Failed to migrate call_logs.json"))); }); diff --git a/tests/unit/v1-ws-bridge.test.ts b/tests/unit/v1-ws-bridge.test.ts index e98c49ae88..859ad6b805 100644 --- a/tests/unit/v1-ws-bridge.test.ts +++ b/tests/unit/v1-ws-bridge.test.ts @@ -43,7 +43,7 @@ function waitFor(predicate, { timeoutMs = 3000, intervalMs = 10 } = {}) { clearInterval(timer); reject(new Error("Timed out waiting for condition")); } - } catch (error) { + } catch (error: any) { clearInterval(timer); reject(error); } @@ -62,7 +62,7 @@ test("v1 ws bridge streams correlated request chunks and survives protocol error } if (url.pathname === "/v1/chat/completions" || url.pathname === "/v1/messages") { - const body = JSON.parse((await readRequestBody(req)) || "{}"); + const body = JSON.parse(((await readRequestBody(req)) as any) || "{}"); const firstMessage = Array.isArray(body.messages) ? body.messages[0] : null; const content = typeof firstMessage?.content === "string" ? firstMessage.content : body.model; diff --git a/tests/unit/v1-ws-route.test.ts b/tests/unit/v1-ws-route.test.ts index 5b19f41d5c..e4e75ae34b 100644 --- a/tests/unit/v1-ws-route.test.ts +++ b/tests/unit/v1-ws-route.test.ts @@ -60,7 +60,7 @@ test("v1 ws handshake succeeds without credentials when wsAuth is disabled", asy ); assert.equal(response.status, 200); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(body.ok, true); assert.equal(body.wsAuth, false); assert.equal(body.authenticated, false); @@ -73,7 +73,7 @@ test("v1 ws handshake requires credentials when wsAuth is enabled", async () => const response = await wsRoute.GET(new Request("http://localhost/api/v1/ws?handshake=1")); assert.equal(response.status, 401); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(body.error.code, "ws_auth_required"); assert.equal(body.wsAuth, true); }); @@ -87,7 +87,7 @@ test("v1 ws handshake accepts valid API key query credentials when wsAuth is ena ); assert.equal(response.status, 200); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(body.ok, true); assert.equal(body.authenticated, true); assert.equal(body.authType, "api_key"); @@ -98,6 +98,6 @@ test("v1 ws HTTP GET reports upgrade required outside handshake mode", async () assert.equal(response.status, 426); assert.equal(response.headers.get("upgrade"), "websocket"); - const body = await response.json(); + const body = (await response.json()) as any; assert.equal(body.error.code, "upgrade_required"); }); diff --git a/tests/unit/version-manager.test.ts b/tests/unit/version-manager.test.ts index 87a0967a86..6aea04adc3 100644 --- a/tests/unit/version-manager.test.ts +++ b/tests/unit/version-manager.test.ts @@ -38,7 +38,7 @@ async function resetStorage() { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } break; - } catch (error) { + } catch (error: any) { if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) { await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); } else { diff --git a/tests/unit/versionManager-orchestrator.test.ts b/tests/unit/versionManager-orchestrator.test.ts index 8afa6c3efb..6a48514438 100644 --- a/tests/unit/versionManager-orchestrator.test.ts +++ b/tests/unit/versionManager-orchestrator.test.ts @@ -35,7 +35,7 @@ describe("versionManager orchestrator (index.ts)", () => { try { await mod.startTool("nonexistent-tool-xyz"); assert.fail("Should have thrown"); - } catch (err) { + } catch (err: any) { assert.ok(true); // Expected to throw } }); @@ -44,7 +44,7 @@ describe("versionManager orchestrator (index.ts)", () => { try { await mod.restartTool("nonexistent-tool-xyz"); assert.fail("Should have thrown"); - } catch (err) { + } catch (err: any) { assert.ok(true); // Expected to throw } }); diff --git a/tests/unit/web-runtime-env.test.ts b/tests/unit/web-runtime-env.test.ts index 8d1296499e..f7c84159bd 100644 --- a/tests/unit/web-runtime-env.test.ts +++ b/tests/unit/web-runtime-env.test.ts @@ -67,7 +67,7 @@ test("getWebRuntimeEnv throws sanitized messages without leaking secret values", assert.throws( () => getWebRuntimeEnv(env), - (error) => { + (error: any) => { assert.match(error.message, /API_KEY_SECRET/); assert.doesNotMatch(error.message, /short-secret/); return true; From 1ef354465ab37c25e0f33938491fbef52abd07e1 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 21 Apr 2026 22:11:19 -0300 Subject: [PATCH 040/281] fix(release): resolve combo prefixing, electron packaging, and CLI auth regressions (#1471, #1492, #1496, #1497, #1486) --- electron/package.json | 3 +- next.config.mjs | 2 + open-sse/handlers/chatCore.ts | 6 +- package-lock.json | 105 +++++++++++++++++++++++- package.json | 1 + scripts/prepare-electron-standalone.mjs | 18 +++- src/shared/services/cliRuntime.ts | 23 ++++-- src/sse/services/model.ts | 16 +++- 8 files changed, 155 insertions(+), 19 deletions(-) diff --git a/electron/package.json b/electron/package.json index 58f200b0f2..3acc5c815d 100644 --- a/electron/package.json +++ b/electron/package.json @@ -53,7 +53,8 @@ "from": "../.next/electron-standalone", "to": "app", "filter": [ - "**/*" + "**/*", + "node_modules/**/*" ] }, { diff --git a/next.config.mjs b/next.config.mjs index 375b966e96..6c2a060519 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -35,6 +35,7 @@ const nextConfig = { "pino", "pino-pretty", "thread-stream", + "pino-abstract-transport", "better-sqlite3", "keytar", "wreq-js", @@ -96,6 +97,7 @@ const nextConfig = { "zod", "pino", "pino-pretty", + "pino-abstract-transport", "child_process", "fs", "path", diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 11dc1b1865..062a0ae8ce 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -1227,8 +1227,10 @@ export async function handleChatCore({ const { getComboByName } = await import("../../src/lib/localDb"); const { parseModel } = await import("../services/model.ts"); const { resolveComboTargets } = await import("../services/combo.ts"); - const comboToSearch = comboName.startsWith("combo/") ? comboName.substring(6) : comboName; - const comboConfig = await getComboByName(comboToSearch); + let comboConfig = await getComboByName(comboName); + if (!comboConfig && comboName.startsWith("combo/")) { + comboConfig = await getComboByName(comboName.substring(6)); + } if (comboConfig) { const targets = await resolveComboTargets(comboConfig, null); const limits = targets.map((t: { modelStr?: string }) => { diff --git a/package-lock.json b/package-lock.json index 75a5030333..21871618bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "open": "^11.0.0", "ora": "^9.1.0", "pino": "^10.3.1", + "pino-abstract-transport": "^1.2.0", "pino-pretty": "^13.1.3", "react": "19.2.5", "react-dom": "19.2.5", @@ -7298,6 +7299,18 @@ "dev": true, "license": "MIT" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -10670,12 +10683,30 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -16274,14 +16305,55 @@ } }, "node_modules/pino-abstract-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", - "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-1.2.0.tgz", + "integrity": "sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==", "license": "MIT", "dependencies": { + "readable-stream": "^4.0.0", "split2": "^4.0.0" } }, + "node_modules/pino-abstract-transport/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/pino-abstract-transport/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/pino-pretty": { "version": "13.1.3", "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", @@ -16306,6 +16378,15 @@ "pino-pretty": "bin.js" } }, + "node_modules/pino-pretty/node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, "node_modules/pino-pretty/node_modules/strip-json-comments": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", @@ -16324,6 +16405,15 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/pino/node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -16584,6 +16674,15 @@ "license": "MIT", "peer": true }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", diff --git a/package.json b/package.json index 36ab8342b5..d3c2d7acab 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,7 @@ "open": "^11.0.0", "ora": "^9.1.0", "pino": "^10.3.1", + "pino-abstract-transport": "^1.2.0", "pino-pretty": "^13.1.3", "react": "19.2.5", "react-dom": "19.2.5", diff --git a/scripts/prepare-electron-standalone.mjs b/scripts/prepare-electron-standalone.mjs index fec1b81ecb..30647e2b03 100644 --- a/scripts/prepare-electron-standalone.mjs +++ b/scripts/prepare-electron-standalone.mjs @@ -8,6 +8,7 @@ import { readFileSync, rmSync, writeFileSync, + readdirSync, } from "node:fs"; import { basename, dirname, join, relative } from "node:path"; import { fileURLToPath } from "node:url"; @@ -146,12 +147,21 @@ ensurePackage( join(ROOT, "node_modules", "@swc", "helpers") ); -// removed better-sqlite3 to ensure ABI compatibility via electron-builder -const bundledSqlite = join(ELECTRON_STANDALONE_DIR, "node_modules", "better-sqlite3"); -if (existsSync(bundledSqlite)) { - rmSync(bundledSqlite, { recursive: true, force: true }); +// Remove native modules to ensure ABI compatibility via electron-builder +function removeNativeModules(baseDir) { + if (!existsSync(baseDir)) return; + const dirs = readdirSync(baseDir); + for (const dir of dirs) { + if (dir.startsWith("better-sqlite3") || dir.startsWith("keytar")) { + const fullPath = join(baseDir, dir); + rmSync(fullPath, { recursive: true, force: true }); + } + } } +removeNativeModules(join(ELECTRON_STANDALONE_DIR, "node_modules")); +removeNativeModules(join(ELECTRON_STANDALONE_DIR, ".next", "node_modules")); + console.log( `[electron] prepared standalone bundle: ${relative(ROOT, ELECTRON_STANDALONE_DIR) || "."}` ); diff --git a/src/shared/services/cliRuntime.ts b/src/shared/services/cliRuntime.ts index ea73f454ab..ac49712a76 100644 --- a/src/shared/services/cliRuntime.ts +++ b/src/shared/services/cliRuntime.ts @@ -15,7 +15,7 @@ const CLI_TOOLS: Record = { healthcheckTimeoutMs: 4000, paths: { settings: ".claude/settings.json", - auth: ".claude/.credentials.json", + auth: [".claude/.credentials.json", ".config/claude/credentials.json"], }, }, codex: { @@ -821,10 +821,23 @@ export const getCliConfigPaths = (toolId: string) => { const home = getCliConfigHome(); return Object.fromEntries( - Object.entries(tool.paths).map(([key, relativePath]) => [ - key, - path.join(home, relativePath as string), - ]) + Object.entries(tool.paths).map(([key, relativePath]) => { + let resolvedPath = ""; + if (Array.isArray(relativePath)) { + // Find the first path that exists, or default to the first one + resolvedPath = path.join(home, relativePath[0]); + for (const p of relativePath) { + const candidate = path.join(home, p); + if (fsSync.existsSync(candidate)) { + resolvedPath = candidate; + break; + } + } + } else { + resolvedPath = path.join(home, relativePath as string); + } + return [key, resolvedPath]; + }) ); }; diff --git a/src/sse/services/model.ts b/src/sse/services/model.ts index d50ee138a2..55cbf9cb84 100644 --- a/src/sse/services/model.ts +++ b/src/sse/services/model.ts @@ -105,13 +105,21 @@ export async function getModelInfo(modelStr) { * @returns {Promise} Full combo object or null if not a combo */ export async function getCombo(modelStr) { - // Check combo DB first (supports names with /) - // Strip combo/ prefix if present - const nameToSearch = modelStr.startsWith("combo/") ? modelStr.substring(6) : modelStr; - const combo = await getComboByName(nameToSearch); + // Try exact match first (supports combos actually named "combo/ANY") + let combo = await getComboByName(modelStr); if (combo && combo.models && combo.models.length > 0) { return combo; } + + // Fallback: Strip combo/ prefix if present + if (modelStr.startsWith("combo/")) { + const nameToSearch = modelStr.substring(6); + combo = await getComboByName(nameToSearch); + if (combo && combo.models && combo.models.length > 0) { + return combo; + } + } + return null; } From 7549a68d6cd8455db1d0cf1ee96354cd60e9de86 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 22 Apr 2026 01:39:49 -0300 Subject: [PATCH 041/281] feat(release): implement Hermes CLI config and message content stripping (#1475, #1387) --- open-sse/config/providerModels.ts | 7 + open-sse/config/providerRegistry.ts | 68 ++++++- open-sse/executors/azure-openai.ts | 41 ++++ open-sse/executors/index.ts | 4 + open-sse/handlers/chatCore.ts | 19 ++ open-sse/services/antigravityHeaders.ts | 4 +- open-sse/services/modelStrip.ts | 60 ++++++ src/app/(dashboard)/dashboard/logs/page.tsx | 8 +- .../dashboard/providers/[id]/page.tsx | 10 +- .../components/VisionBridgeSettingsTab.tsx | 186 ++++++++++++++++++ .../(dashboard)/dashboard/settings/page.tsx | 2 + src/app/api/logs/active/route.ts | 43 ++++ src/app/api/providers/[id]/models/route.ts | 16 ++ src/lib/providers/validation.ts | 120 +++++++++++ src/lib/usage/usageHistory.ts | 52 +++++ src/shared/components/ActiveRequestsPanel.tsx | 108 ++++++++++ src/shared/constants/cliTools.ts | 36 ++++ src/shared/constants/providers.ts | 31 +++ src/shared/services/cliRuntime.ts | 9 + src/shared/validation/settingsSchemas.ts | 6 + tests/unit/azure-openai-executor.test.ts | 44 +++++ tests/unit/cli-tools.test.ts | 10 + tests/unit/model-strip.test.ts | 48 +++++ .../provider-validation-azure-vertex.test.ts | 40 ++++ tests/unit/t20-t22-provider-headers.test.ts | 20 +- tests/unit/t28-model-catalog-updates.test.ts | 9 + .../vision-bridge-settings-schema.test.ts | 25 +++ 27 files changed, 1014 insertions(+), 12 deletions(-) create mode 100644 open-sse/executors/azure-openai.ts create mode 100644 open-sse/services/modelStrip.ts create mode 100644 src/app/(dashboard)/dashboard/settings/components/VisionBridgeSettingsTab.tsx create mode 100644 src/app/api/logs/active/route.ts create mode 100644 src/shared/components/ActiveRequestsPanel.tsx create mode 100644 tests/unit/azure-openai-executor.test.ts create mode 100644 tests/unit/model-strip.test.ts create mode 100644 tests/unit/provider-validation-azure-vertex.test.ts create mode 100644 tests/unit/vision-bridge-settings-schema.test.ts diff --git a/open-sse/config/providerModels.ts b/open-sse/config/providerModels.ts index a1c37e4d87..6b210ec20e 100644 --- a/open-sse/config/providerModels.ts +++ b/open-sse/config/providerModels.ts @@ -47,6 +47,13 @@ export function getModelTargetFormat(aliasOrId: string, modelId: string): string return found?.targetFormat || null; } +export function getModelStripTypes(aliasOrId: string, modelId: string): string[] { + const models = PROVIDER_MODELS[aliasOrId]; + if (!models) return []; + const found = models.find((m) => m.id === modelId); + return Array.isArray(found?.strip) ? [...found.strip] : []; +} + export function getModelsByProviderId(providerId: string): RegistryModel[] { const alias = PROVIDER_ID_TO_ALIAS[providerId] || providerId; return PROVIDER_MODELS[alias] || []; diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 38f5c4323d..f6e4e92ea1 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -46,6 +46,8 @@ export interface RegistryModel { supportsXHighEffort?: boolean; targetFormat?: string; unsupportedParams?: readonly string[]; + /** Content types to remove before translation/upstream dispatch. */ + strip?: readonly string[]; /** Maximum context window in tokens */ contextLength?: number; } @@ -596,6 +598,19 @@ export const REGISTRY: Record = { ], }, + "azure-openai": { + id: "azure-openai", + alias: "azure", + format: "openai", + executor: "azure-openai", + baseUrl: "https://example-resource.openai.azure.com", + authType: "apikey", + authHeader: "api-key", + defaultContextLength: 128000, + models: [], + passthroughModels: true, + }, + anthropic: { id: "anthropic", alias: "anthropic", @@ -628,6 +643,7 @@ export const REGISTRY: Record = { format: "openai", executor: "opencode", baseUrl: "https://opencode.ai/zen/go/v1", + modelsUrl: "https://opencode.ai/zen/go/v1/models", // (#532) Key validation must hit the main zen endpoint (same key works for both tiers) testKeyBaseUrl: "https://opencode.ai/zen/v1", authType: "apikey", @@ -710,6 +726,25 @@ export const REGISTRY: Record = { models: [...GLM_SHARED_MODELS], }, + "glm-cn": { + id: "glm-cn", + alias: "glmcn", + format: "openai", + executor: "default", + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions", + modelsUrl: "https://open.bigmodel.cn/api/coding/paas/v4/models", + authType: "apikey", + authHeader: "bearer", + defaultContextLength: 200000, + models: [ + { id: "glm-5.1", name: "GLM 5.1" }, + { id: "glm-5", name: "GLM 5" }, + { id: "glm-4.7", name: "GLM 4.7" }, + { id: "glm-4.6", name: "GLM 4.6" }, + { id: "glm-4.5-air", name: "GLM 4.5 Air" }, + ], + }, + "bailian-coding-plan": { id: "bailian-coding-plan", alias: "bcp", @@ -727,7 +762,7 @@ export const REGISTRY: Record = { models: [ { id: "qwen3.5-plus", name: "Qwen3.5 Plus" }, { id: "qwen3-max-2026-01-23", name: "Qwen3 Max (2026-01-23)" }, - { id: "qwen3-coder-next", name: "Qwen3 Coder Next" }, + { id: "qwen3-coder-next", name: "Qwen3 Coder Next", strip: ["image", "audio"] }, { id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" }, { id: "MiniMax-M2.5", name: "MiniMax M2.5" }, { id: "glm-5", name: "GLM 5" }, @@ -965,8 +1000,8 @@ export const REGISTRY: Record = { authType: "apikey", authHeader: "bearer", models: [ - { id: "deepseek-chat", name: "DeepSeek V3.2 Chat" }, - { id: "deepseek-reasoner", name: "DeepSeek V3.2 Reasoner" }, + { id: "deepseek-chat", name: "DeepSeek V3.2 Chat", strip: ["image", "audio"] }, + { id: "deepseek-reasoner", name: "DeepSeek V3.2 Reasoner", strip: ["image", "audio"] }, ], }, @@ -1253,8 +1288,16 @@ export const REGISTRY: Record = { authType: "apikey", authHeader: "bearer", models: [ - { id: "deepseek-ai/DeepSeek-V3.2", name: "DeepSeek V3.2" }, - { id: "deepseek-ai/DeepSeek-V3.1", name: "DeepSeek V3.1" }, + { + id: "deepseek-ai/DeepSeek-V3.2", + name: "DeepSeek V3.2", + strip: ["image", "audio"], + }, + { + id: "deepseek-ai/DeepSeek-V3.1", + name: "DeepSeek V3.1", + strip: ["image", "audio"], + }, { id: "deepseek-ai/DeepSeek-R1", name: "DeepSeek R1" }, { id: "Qwen/Qwen3-235B-A22B-Instruct-2507", name: "Qwen3 235B" }, { id: "Qwen/Qwen3-Coder-480B-A35B-Instruct", name: "Qwen3 Coder 480B" }, @@ -1381,6 +1424,21 @@ export const REGISTRY: Record = { ], }, + "vertex-partner": { + id: "vertex-partner", + alias: "vp", + format: "gemini", + executor: "vertex", + baseUrl: "https://us-central1-aiplatform.googleapis.com/v1/projects", + authType: "apikey", + authHeader: "bearer", + models: [ + { id: "deepseek-v3.2", name: "DeepSeek V3.2 (Vertex Partner)" }, + { id: "qwen3-next-80b", name: "Qwen3 Next 80B (Vertex Partner)" }, + { id: "glm-5", name: "GLM-5 (Vertex Partner)" }, + ], + }, + alibaba: { id: "alibaba", alias: "ali", diff --git a/open-sse/executors/azure-openai.ts b/open-sse/executors/azure-openai.ts new file mode 100644 index 0000000000..55cef12ffa --- /dev/null +++ b/open-sse/executors/azure-openai.ts @@ -0,0 +1,41 @@ +import { DefaultExecutor } from "./default.ts"; + +const DEFAULT_API_VERSION = "2024-12-01-preview"; + +function normalizeAzureBaseUrl(rawBaseUrl?: string | null): string { + const normalized = (rawBaseUrl || "").trim().replace(/\/+$/, ""); + if (!normalized) return ""; + + return normalized + .replace(/\/openai$/i, "") + .replace(/\/openai\/deployments\/[^/]+\/chat\/completions.*$/i, ""); +} + +export class AzureOpenAIExecutor extends DefaultExecutor { + constructor() { + super("azure-openai"); + } + + buildUrl(model: string, stream: boolean, urlIndex = 0, credentials: any = null) { + void urlIndex; + + const providerSpecificData = credentials?.providerSpecificData || {}; + const baseUrl = normalizeAzureBaseUrl(providerSpecificData.baseUrl || this.config.baseUrl); + const apiVersion = + typeof providerSpecificData.apiVersion === "string" && providerSpecificData.apiVersion.trim() + ? providerSpecificData.apiVersion.trim() + : DEFAULT_API_VERSION; + return `${baseUrl}/openai/deployments/${encodeURIComponent(model)}/chat/completions?api-version=${encodeURIComponent(apiVersion)}`; + } + + buildHeaders(credentials: any, stream = true) { + const apiKey = credentials?.apiKey || credentials?.accessToken || ""; + const headers: Record = { + "Content-Type": "application/json", + "api-key": apiKey, + }; + + headers.Accept = stream ? "text/event-stream" : "application/json"; + return headers; + } +} diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index d6386eb066..8d5a15fbe2 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -14,6 +14,7 @@ import { VertexExecutor } from "./vertex.ts"; import { CliproxyapiExecutor } from "./cliproxyapi.ts"; import { PerplexityWebExecutor } from "./perplexity-web.ts"; import { GrokWebExecutor } from "./grok-web.ts"; +import { AzureOpenAIExecutor } from "./azure-openai.ts"; const executors = { antigravity: new AntigravityExecutor(), @@ -24,6 +25,7 @@ const executors = { codex: new CodexExecutor(), cursor: new CursorExecutor(), cu: new CursorExecutor(), // Alias for cursor + "azure-openai": new AzureOpenAIExecutor(), pollinations: new PollinationsExecutor(), pol: new PollinationsExecutor(), // Alias "cloudflare-ai": new CloudflareAIExecutor(), @@ -33,6 +35,7 @@ const executors = { puter: new PuterExecutor(), pu: new PuterExecutor(), // Alias vertex: new VertexExecutor(), + "vertex-partner": new VertexExecutor(), cliproxyapi: new CliproxyapiExecutor(), cpa: new CliproxyapiExecutor(), // Alias "perplexity-web": new PerplexityWebExecutor(), @@ -69,3 +72,4 @@ export { CliproxyapiExecutor } from "./cliproxyapi.ts"; export { VertexExecutor } from "./vertex.ts"; export { PerplexityWebExecutor } from "./perplexity-web.ts"; export { GrokWebExecutor } from "./grok-web.ts"; +export { AzureOpenAIExecutor } from "./azure-openai.ts"; diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 062a0ae8ce..d608a8febe 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -12,6 +12,10 @@ import { addBufferToUsage, filterUsageForFormat, estimateUsage } from "../utils/ import { refreshWithRetry } from "../services/tokenRefresh.ts"; import { createRequestLogger } from "../utils/requestLogger.ts"; import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts"; +import { + getStripTypesForProviderModel, + stripIncompatibleMessageContent, +} from "../services/modelStrip.ts"; import { resolveModelAlias } from "../services/modelDeprecation.ts"; import { getUnsupportedParams } from "../config/providerRegistry.ts"; import { @@ -1316,6 +1320,21 @@ export async function handleChatCore({ const isClaudeCodeCompatible = isClaudeCodeCompatibleProvider(provider); const upstreamStream = stream || isClaudeCodeCompatible; let ccSessionId: string | null = null; + const stripTypes = getStripTypesForProviderModel(provider || "", model || ""); + + if (Array.isArray(translatedBody?.messages) && stripTypes.length > 0) { + const stripResult = stripIncompatibleMessageContent(translatedBody.messages, stripTypes); + if (stripResult.removedParts > 0) { + translatedBody = { + ...translatedBody, + messages: stripResult.messages, + }; + log?.warn?.( + "CONTENT", + `Stripped ${stripResult.removedParts} incompatible content part(s) for ${provider}/${model}` + ); + } + } // Determine if we should preserve client-side cache_control headers // Fetch settings from DB to get user preference diff --git a/open-sse/services/antigravityHeaders.ts b/open-sse/services/antigravityHeaders.ts index 8c8d3d32ec..542b8f5e90 100644 --- a/open-sse/services/antigravityHeaders.ts +++ b/open-sse/services/antigravityHeaders.ts @@ -16,7 +16,7 @@ import { type AntigravityHeaderProfile = "loadCodeAssist" | "fetchAvailableModels" | "models"; const ANTIGRAVITY_VERSION = ANTIGRAVITY_FALLBACK_VERSION; -const GEMINI_CLI_VERSION = "1.0.0"; +const GEMINI_CLI_VERSION = "0.31.0"; const GEMINI_SDK_VERSION = "1.41.0"; const NODE_VERSION = "v22.19.0"; const LOAD_CODE_ASSIST_USER_AGENT = "google-api-nodejs-client/9.15.1"; @@ -42,8 +42,6 @@ function getPlatform(): string { switch (p) { case "win32": return "windows"; - case "darwin": - return "macos"; default: return p; // "linux", etc. } diff --git a/open-sse/services/modelStrip.ts b/open-sse/services/modelStrip.ts new file mode 100644 index 0000000000..d3f705d20e --- /dev/null +++ b/open-sse/services/modelStrip.ts @@ -0,0 +1,60 @@ +import { PROVIDER_ID_TO_ALIAS, getModelStripTypes } from "../config/providerModels.ts"; + +type JsonRecord = Record; + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function shouldStripPart(part: JsonRecord, stripTypes: Set): boolean { + const type = typeof part.type === "string" ? part.type : ""; + if (!type) return false; + + if (stripTypes.has(type)) return true; + if (stripTypes.has("image") && (type === "image_url" || type === "image")) return true; + if (stripTypes.has("audio") && (type === "input_audio" || type === "audio")) return true; + return false; +} + +export function stripIncompatibleMessageContent( + messages: unknown, + stripTypes: readonly string[] +): { messages: unknown; removedParts: number } { + if (!Array.isArray(messages) || stripTypes.length === 0) { + return { messages, removedParts: 0 }; + } + + const stripSet = new Set(stripTypes); + let removedParts = 0; + + const sanitizedMessages = messages.map((message) => { + const record = asRecord(message); + if (!Array.isArray(record.content)) { + return message; + } + + const filteredContent = record.content.filter((part) => { + const shouldStrip = shouldStripPart(asRecord(part), stripSet); + if (shouldStrip) { + removedParts += 1; + } + return !shouldStrip; + }); + + if (filteredContent.length > 0) { + return { ...record, content: filteredContent }; + } + + return { + ...record, + content: [{ type: "text", text: "[unsupported image/audio content removed]" }], + }; + }); + + return { messages: sanitizedMessages, removedParts }; +} + +export function getStripTypesForProviderModel(providerId: string, modelId: string): string[] { + const alias = PROVIDER_ID_TO_ALIAS[providerId] || providerId; + return getModelStripTypes(alias, modelId); +} diff --git a/src/app/(dashboard)/dashboard/logs/page.tsx b/src/app/(dashboard)/dashboard/logs/page.tsx index 9df2d0ada2..f547814b7a 100644 --- a/src/app/(dashboard)/dashboard/logs/page.tsx +++ b/src/app/(dashboard)/dashboard/logs/page.tsx @@ -3,6 +3,7 @@ import { useState, useRef, useEffect } from "react"; import { RequestLoggerV2, ProxyLogger, SegmentedControl } from "@/shared/components"; import ConsoleLogViewer from "@/shared/components/ConsoleLogViewer"; +import ActiveRequestsPanel from "@/shared/components/ActiveRequestsPanel"; import AuditLogTab from "./AuditLogTab"; import { useTranslations } from "next-intl"; @@ -132,7 +133,12 @@ export default function LogsPage() {
{/* Content */} - {activeTab === "request-logs" && } + {activeTab === "request-logs" && ( +
+ + +
+ )} {activeTab === "proxy-logs" && } {activeTab === "audit-logs" && } {activeTab === "console" && } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 9eec81ee67..c8c1cfea04 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -5271,6 +5271,7 @@ ConnectionRow.propTypes = { }; const CONFIGURABLE_BASE_URL_PROVIDERS = new Set([ + "azure-openai", "bailian-coding-plan", "xiaomi-mimo", "heroku", @@ -5280,6 +5281,7 @@ const CONFIGURABLE_BASE_URL_PROVIDERS = new Set([ ]); const DEFAULT_PROVIDER_BASE_URLS: Record = { + "azure-openai": "https://example-resource.openai.azure.com", "bailian-coding-plan": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1", "xiaomi-mimo": "https://token-plan-ams.xiaomimimo.com/v1", "searxng-search": "http://localhost:8888/search", @@ -5291,6 +5293,8 @@ function getProviderBaseUrlDefault(providerId?: string | null) { function getProviderBaseUrlHint(providerId?: string | null) { switch (providerId) { + case "azure-openai": + return "Required: paste your Azure OpenAI resource endpoint. OmniRoute will append /openai/deployments/{model}/chat/completions?api-version=...."; case "bailian-coding-plan": return "Optional: Custom base URL for bailian-coding-plan provider"; case "xiaomi-mimo": @@ -5310,6 +5314,8 @@ function getProviderBaseUrlHint(providerId?: string | null) { function getProviderBaseUrlPlaceholder(providerId?: string | null) { switch (providerId) { + case "azure-openai": + return "https://my-resource.openai.azure.com"; case "bailian-coding-plan": case "xiaomi-mimo": return getProviderBaseUrlDefault(providerId); @@ -5379,7 +5385,7 @@ function AddApiKeyModal({ const t = useTranslations("providers"); const usesBaseUrl = CONFIGURABLE_BASE_URL_PROVIDERS.has(provider || ""); const defaultBaseUrl = getProviderBaseUrlDefault(provider); - const isVertex = provider === "vertex"; + const isVertex = provider === "vertex" || provider === "vertex-partner"; const defaultRegion = "us-central1"; const isGlm = provider === "glm" || provider === "glmt"; const isQoder = provider === "qoder"; @@ -5855,7 +5861,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec const usesBaseUrl = CONFIGURABLE_BASE_URL_PROVIDERS.has(connection?.provider || ""); const defaultBaseUrl = getProviderBaseUrlDefault(connection?.provider); - const isVertex = connection?.provider === "vertex"; + const isVertex = connection?.provider === "vertex" || connection?.provider === "vertex-partner"; const isGlm = connection?.provider === "glm" || connection?.provider === "glmt"; const isCloudflare = connection?.provider === "cloudflare-ai"; const isCodex = connection?.provider === "codex"; diff --git a/src/app/(dashboard)/dashboard/settings/components/VisionBridgeSettingsTab.tsx b/src/app/(dashboard)/dashboard/settings/components/VisionBridgeSettingsTab.tsx new file mode 100644 index 0000000000..3a5f65368f --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/VisionBridgeSettingsTab.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Card, Toggle } from "@/shared/components"; +import { VISION_BRIDGE_DEFAULTS } from "@/shared/constants/visionBridgeDefaults"; + +type SettingsState = { + visionBridgeEnabled: boolean; + visionBridgeModel: string; + visionBridgePrompt: string; + visionBridgeTimeout: number; + visionBridgeMaxImages: number; +}; + +export default function VisionBridgeSettingsTab() { + const [settings, setSettings] = useState({ + visionBridgeEnabled: VISION_BRIDGE_DEFAULTS.enabled, + visionBridgeModel: VISION_BRIDGE_DEFAULTS.model, + visionBridgePrompt: VISION_BRIDGE_DEFAULTS.prompt, + visionBridgeTimeout: VISION_BRIDGE_DEFAULTS.timeoutMs, + visionBridgeMaxImages: VISION_BRIDGE_DEFAULTS.maxImagesPerRequest, + }); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch("/api/settings") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!data) return; + setSettings({ + visionBridgeEnabled: data.visionBridgeEnabled ?? VISION_BRIDGE_DEFAULTS.enabled, + visionBridgeModel: data.visionBridgeModel ?? VISION_BRIDGE_DEFAULTS.model, + visionBridgePrompt: data.visionBridgePrompt ?? VISION_BRIDGE_DEFAULTS.prompt, + visionBridgeTimeout: data.visionBridgeTimeout ?? VISION_BRIDGE_DEFAULTS.timeoutMs, + visionBridgeMaxImages: + data.visionBridgeMaxImages ?? VISION_BRIDGE_DEFAULTS.maxImagesPerRequest, + }); + }) + .finally(() => setLoading(false)); + }, []); + + const updateSetting = async (patch: Partial) => { + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + if (res.ok) { + setSettings((prev) => ({ ...prev, ...patch })); + } + } catch (error) { + console.error("Failed to update Vision Bridge settings:", error); + } + }; + + return ( + +
+
+ +
+
+

Vision Bridge

+

+ Run an automatic vision-to-text fallback before routing image requests to text-only + models. +

+
+
+ +
+
+
+

Enabled

+

+ Toggle the pre-call bridge that replaces image parts with extracted text. +

+
+ updateSetting({ visionBridgeEnabled: checked })} + disabled={loading} + /> +
+ +
+
+ + + setSettings((prev) => ({ ...prev, visionBridgeModel: e.target.value })) + } + onBlur={() => updateSetting({ visionBridgeModel: settings.visionBridgeModel.trim() })} + className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm" + placeholder="openai/gpt-4o-mini" + /> +

+ Any OmniRoute model ID that supports vision can be used here. +

+
+ +
+ +