From 19d91d82e2257b9475724109f2cca9d869af5907 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 23 Jun 2026 03:08:29 -0300 Subject: [PATCH] Release v3.8.34 (#4614) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(release): open v3.8.34 development cycle * chore(quality): release-green pre-flight validator + nightly signal (C+D) (#4622) C — scripts/quality/validate-release-green.mjs (npm run check:release-green): reproduces the release-equivalent validation (typecheck, eslint, db-rules, public-creds, full unit, vitest, ratchets, optional --with-build package-artifact) against the current working tree and classifies each red as HARD (real defect, exit 1) vs DRIFT (ratchet — reported, never affects exit / never blocks). Pure helpers exported + orchestration behind a direct-run guard; unit-tested. D — .github/workflows/nightly-release-green.yml: runs C on the active release branch nightly (and on workflow_dispatch) and opens/updates a single tracking issue on HARD failures. Never a required check, never touches a contributor PR. Closes the gap where the full gate (ci.yml) only ran on the release PR, so reds accrued silently on release/** and surfaced in 40-min layers at release time. Non-blocking by construction; drift is the maintainer's to rebaseline at release. Co-authored-by: Diego Rodrigues de Sa e Souza * fix(providers): show revealed connection API keys (#4583) Integrated into release/v3.8.34 * fix(resilience): respect upstream retry hint toggle (#4585) Integrated into release/v3.8.34 * feat(settings): expose stream recovery feature flags (#4586) Integrated into release/v3.8.34 * fix(logs): make active request stale sweep configurable (#4599) Integrated into release/v3.8.34 * fix(plugin): auto-prefix providerId with 'opencode-' for OC 1.17.8+ native gate (#4527) Integrated into release/v3.8.34 (supersedes #4445) * fix(models): treat unknown output caps as unset (#4584) Integrated into release/v3.8.34 * fix(executors): strip temperature for GitHub Copilot gpt-5.4 family (#4564) Integrated into release/v3.8.34 (rebuilt onto tip) * fix(oauth): update Qwen OAuth URLs from chat.qwen.ai to qwen.ai (#4561) Integrated into release/v3.8.34 (rebuilt onto tip) * fix(api/settings): prevent cached /api/settings responses (port from 9router#951) (#4566) Integrated into release/v3.8.34 (rebuilt onto tip) * feat(audio): MiniMax T2A v2 TTS dispatch in audioSpeech (port #1043) (#4553) Integrated into release/v3.8.34 (rebuilt onto tip) * fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails (#4562) Integrated into release/v3.8.34 (rebuilt onto tip) * feat(providers): optional model ID for custom API-key validation (#4555) Integrated into release/v3.8.34 (rebuilt onto tip) * fix(cli): align data dir and env loading with runtime (#4607) Integrated into release/v3.8.34 (rebuilt onto tip) * fix(quota): expose Bailian quota windows (#4610) Integrated into release/v3.8.34 (rebuilt onto tip) * fix: retain provider cooldowns for configured max window (#4588) Integrated into release/v3.8.34 (rebuilt — bundled commits stripped) * fix: reject invalid provider cooldown bounds (#4589) Integrated into release/v3.8.34 (rebuilt — bundled commits stripped) * fix: preserve production combo metrics on shadow eviction (#4590) Integrated into release/v3.8.34 (rebuilt — bundled commits stripped) * fix(stream): estimate input tokens when upstream reports prompt_tokens=0 (#4615) Integrated into release/v3.8.34 (rebuilt onto tip) * fix(catalog): shorten no-thinking gateway prefix to no-think/ (#4525) Integrated into release/v3.8.34 (rebuilt — kept only the prefix rename, dropped stale-base reverts) * fix(relay): apply IP rate limit to bifrost sidecar (#4593) Integrated into release/v3.8.34 (rebuilt onto tip; merge before #4612) * fix(bifrost): finalize SSE relay usage after stream (#4612) Integrated into release/v3.8.34 (rebuilt + reconciled with #4593) * feat(compression): per-request `x-omniroute-compression` header (Phase 3) (#4645) * docs(compression): Phase 3 per-request header design spec Approved brainstorming output for the x-omniroute-compression header: header-first precedence, name-first combo matching (Decision A), explicit value bypasses auto-trigger (Decision B), DerivedPlan.source, and the X-OmniRoute-Compression response header. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(compression): Phase 3 per-request header implementation plan 4-task TDD plan (resolver header-first + source, parser, chatCore wiring + response header, docs/file-size) with full code and exact commands. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(compression): header-first resolver + plan source (Phase 3 core) Co-Authored-By: Claude Opus 4.8 (1M context) * feat(compression): resolveCompressionHeader parser (Phase 3) Co-Authored-By: Claude Opus 4.8 (1M context) * feat(compression): wire x-omniroute-compression header + response header (Phase 3) Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(compression): extract plan-resolution leaf (planResolution.ts) under size cap (Phase 3) Co-Authored-By: Claude Opus 4.8 (1M context) * docs(compression): document x-omniroute-compression header (Phase 3) Co-Authored-By: Claude Opus 4.8 (1M context) * fix(compression): harden named-combo map + trim engine: header id (Phase 3 review) Addresses gemini-code-assist review on #4645: - Extract buildNamedComboLookup (pure) so a blank/whitespace/null combo name contributes only its id key (no '' key, no throw that disables all combos). - Trim the engine: header value so 'engine: rtk' resolves. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Diego Rodrigues de Sa e Souza Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Diego Rodrigues de Sa e Souza * fix: exclude exhausted connections from auto scoring (#4592) Integrated into release/v3.8.34 (rebuilt + opt-in gate fix) * fix(dashboard): memoize compatible provider groups (#4613) Integrated into release/v3.8.34 (rebuilt + test added) * fix(dashboard): isolate quota widget refresh clock (#4611) Integrated into release/v3.8.34 (rebuilt + jsdom test) * fix(dashboard): gate topology side effects behind widget visibility (#4606) Integrated into release/v3.8.34 (rebuilt + jsdom test) * fix(dashboard): keep play_arrow spinning on provider Test All buttons (#4563) Integrated into release/v3.8.34 (rebuilt onto tip; UI-cosmetic per owner) * fix(db): schedule retention cleanup + fix cleanup table/column names (extracted from #4428) (#4691) Integrated into release/v3.8.34 (cleanup core extracted from #4428, credit @oyi77) * fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable (#4604) (#4687) Co-authored-by: Diego Rodrigues de Sa e Souza * fix(api): serve GET /v1/models/{model} as JSON, not the HTML dashboard (#4674) (#4677) Co-authored-by: Diego Rodrigues de Sa e Souza * feat(opencode): add go deepseek reasoning variants (#4647) Integrated into release/v3.8.34 * fix(executors): robust deepseek-web tool-call parsing and agentic context retention (#4644) Integrated into release/v3.8.34 * fix(cli): authenticate `omniroute logs` and honor active context (#4638) Integrated into release/v3.8.34 (authored by Rahul Sharma, AI co-author trailer stripped per project policy) * fix(proxy): apply pipelining:0 + connections cap to the direct dispatcher (#4580) (#4684) Co-authored-by: Diego Rodrigues de Sa e Souza * fix(executors): Firecrawl web_fetch 500 with include_metadata=true (#4692) Integrated into release/v3.8.34 * fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template (#4621) Integrated into release/v3.8.34 (dead getFirstRegistryModelId dropped, rebuilt onto tip) * fix(dashboard): gate home topology live-WS networking (#4596) (#4618) Integrated into release/v3.8.34 (adapted onto #4606's extracted topology section: default-hidden flip + enabled gate on useLiveDashboard) * fix(cli): align `omniroute` env loading with the runtime data dir (#4597) (#4619) Integrated into release/v3.8.34 (data-dir.mjs refactor reconciled with #4607; loadEnvFile aligned to getDefaultDataDir) * chore(quality): reconcile file-size baseline for #4644 (deepseek-web.ts 1117->1125) (#4695) file-size reconcile for #4644 * Support quota scraping for OpenCode Go and Ollama Cloud (#4642) Integrated into release/v3.8.34 (Ollama Cloud + OpenCode Go dashboard quota scraping; rebuilt onto tip, gates green: typecheck/public-creds/file-size/lint/docs-sync + 31 tests) * feat(executors): land M365 Copilot pure framing + connection helpers (#4042) (#4696) Land M365 pure modules ahead of draft #4400 * deps: bump production + development groups; migrate js-yaml to v5 ESM (#4697) Incorporates Dependabot #4667 + #4668 + js-yaml v5 ESM migration into release/v3.8.34 * fix: noAuth provider validation + kimi executor routing (#4699) Integrated into release/v3.8.34 (noAuth in NOAUTH_PROVIDERS dynamic check + remove misrouted kimi web alias; 9 tests) * refactor(imageGeneration): extract 8 provider families to co-located files (#4609) Integrated into release/v3.8.34 (extraction completed: added missing imports/exports per module, main imports handlers locally; 145 image-gen tests pass, typecheck/cycles/file-size green) * chore(release): v3.8.34 — finalize changelog, rebaseline drift, fix release-green reds - Finalize CHANGELOG [3.8.34] (43 bullets, full contributor attribution) + seed i18n mirrors - Rebaseline inherited cycle drift surfaced by release-green pre-flight: eslint warnings 3900->3907, cognitive-complexity 797->801 (release-finalize touches no prod code; all drift is from this cycle's contributor merges) - fix(providers): keep reka-flash-3 as the Reka provider default. #4621 inserted reka-flash at the head of the model list, silently changing the default from reka-flash-3 (the free-tier model) to reka-flash; reorder so reka-flash-3 stays default, reka-flash retained. - test: align provider-models-config / provider-models-route / web-cookie-providers-new with #4621 (reka-flash now in the Reka catalog) and #4699 (the `kimi` API-key provider correctly falls through to DefaultExecutor instead of KimiWebExecutor) - chore(quality): allowlist the COMPRESSION_GUIDE doc name in check-fabricated-docs (false-positive env-var match; docs/compression/COMPRESSION_GUIDE.md exists) * fix(release-green): resolve release-PR full-CI reds for v3.8.34 Surfaced only on the release PR (these gates don't run on PR->release fast-gates): - fix(quota): complete HTML-comment sanitization in opencodeOllamaUsage SSR reset-time parsing — strip any generically instead of the two literal React hydration markers, so no partial "|$)` arm consumes a trailing "", so no partial " form because an unclosed comment could still leave " / hydration markers). The `(?:-->|$)` arm consumes a + // trailing "" too, so no partial "|$)/g, "").replace(/Resets?\s*in\s*/i, "") + ); + if (resetInSec === null || !Number.isFinite(resetInSec)) continue; + const window = { + usagePercent, + resetAt: safeToIsoString(Date.now() + Math.max(0, resetInSec) * 1000), + }; + if (label.includes("rolling")) usage.session = window; + else if (label.includes("weekly")) usage.weekly = window; + else if (label.includes("monthly")) usage.mcp_monthly = window; + } + return usage.session || usage.weekly || usage.mcp_monthly ? usage : null; +} + +async function fetchOpenCodeGoDashboardUsage( + config: Extract +) { + const url = `${OPENCODE_GO_DASHBOARD_BASE_URL.replace(/\/+$/, "")}/${encodeURIComponent( + config.workspaceId + )}/go`; + const response = await fetch(url, { + headers: { + Accept: "text/html", + Cookie: `auth=${normalizeOpenCodeGoAuthCookie(config.authCookie)}`, + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Gecko/20100101 Firefox/148.0", + }, + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) + return { usage: null, message: `OpenCode Go dashboard error (${response.status}).` }; + const usage = parseOpenCodeGoDashboardHtml(await response.text()); + return { + usage, + message: usage ? undefined : "OpenCode Go dashboard response did not contain quota windows.", + }; +} + +export async function getOpenCodeGoUsage(apiKey: string, providerSpecificData?: JsonRecord) { + const dashboardConfig = resolveOpenCodeGoDashboardConfig(providerSpecificData); + if (dashboardConfig.state === "incomplete") { + return { + message: `OpenCode Go dashboard quota config is incomplete. Missing ${dashboardConfig.missing}.`, + }; + } + if (dashboardConfig.state === "configured") { + try { + const dashboard = await fetchOpenCodeGoDashboardUsage(dashboardConfig); + if (!dashboard.usage) { + return { message: dashboard.message || "OpenCode Go dashboard quota data unavailable." }; + } + const quotas: Record = {}; + for (const quotaName of OPENCODE_GO_QUOTA_ORDER) { + const usage = dashboard.usage[quotaName]; + if (usage) { + quotas[quotaName] = buildOpenCodeGoDollarQuota( + quotaName, + usage.usagePercent, + usage.resetAt + ); + } + } + return { plan: "OpenCode Go", quotas: orderOpenCodeGoQuotas(quotas) }; + } catch (error) { + return { message: `OpenCode Go dashboard quota error: ${sanitizeErrorMessage(error)}` }; + } + } + + const token = apiKey.trim().replace(/^Bearer\s+/i, ""); + if (!token) { + return { + message: + "OpenCode Go quota requires OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE. " + + "The API key can be used for chat/models, but OpenCode Go does not expose quota via API key.", + }; + } + + try { + const res = await fetch(OPENCODE_GO_QUOTA_URL, { + headers: { + Authorization: buildBearerAuthorization(token), + "Accept-Language": "en-US,en", + "Content-Type": "application/json", + Accept: "application/json", + }, + }); + if (!res.ok) { + if (res.status === 401 || res.status === 403) { + return { + message: + "OpenCode Go API key is valid for chat/models but cannot read quota from the Z.AI quota API. " + + "Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping.", + }; + } + return { + message: + `OpenCode Go quota API error (${res.status}). ` + + "Set OMNIROUTE_OPENCODE_GO_QUOTA_URL to a working endpoint, or follow " + + "https://github.com/anomalyco/opencode/issues/16017 for upstream status.", + }; + } + + let json: unknown; + try { + json = await res.json(); + } catch { + return { message: "OpenCode Go quota response parsing failed." }; + } + const root = toRecord(json); + if ( + toNumber(root.code, 200) === 401 || + toNumber(root.code, 200) === 403 || + root.success === false + ) { + return { + message: + "OpenCode Go API key is valid for chat/models but cannot read quota from the Z.AI quota API. " + + "Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping.", + }; + } + + const data = toRecord(root.data); + const quotas: Record = {}; + for (const limit of Array.isArray(data.limits) ? data.limits : []) { + const src = toRecord(limit); + const type = String(src.type || "").toUpperCase(); + const resetAt = parseResetTime(src.nextResetTime); + if (type === "TOKENS_LIMIT" || type === "TOKEN_LIMIT") { + const quotaName = getOpenCodeGoTokenQuotaName(src, quotas); + quotas[quotaName] = buildOpenCodeGoDollarQuota( + quotaName, + src.percentage, + resetAt, + undefined, + Array.isArray(src.models) + ? src.models.map((model) => { + const modelInfo = toRecord(model); + return { + name: String(modelInfo.model || modelInfo.modelCode || "usage"), + used: toNumber(modelInfo.percentage, 0), + }; + }) + : undefined + ); + } else if (type === "TIME_LIMIT" || type === "TIME_USAGE_LIMIT") { + quotas.mcp_monthly = buildOpenCodeGoDollarQuota( + "mcp_monthly", + src.percentage, + resetAt, + src.currentValue, + Array.isArray(src.usageDetails) + ? src.usageDetails.map((item) => { + const detail = toRecord(item); + return { + name: String(detail.modelCode || detail.name || "usage"), + used: toNumber(detail.usage, 0), + }; + }) + : undefined + ); + } + } + + const levelRaw = + typeof data.planName === "string" + ? data.planName + : typeof data.level === "string" + ? data.level + : ""; + const planLabel = toTitleCase(levelRaw.replace(/\s*plan$/i, "")); + return { + plan: planLabel + ? /^opencode\s+go\b/i.test(planLabel) + ? planLabel + : `OpenCode Go ${planLabel}` + : null, + quotas: orderOpenCodeGoQuotas(quotas), + }; + } catch (error) { + return { message: `OpenCode Go quota API error: ${sanitizeErrorMessage(error)}` }; + } +} + +function resolveOllamaCloudConfig(providerSpecificData?: JsonRecord): OllamaCloudConfig { + const cookie = + process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE?.trim() || + process.env.OLLAMA_USAGE_COOKIE?.trim() || + process.env.OLLAMA_CLOUD_USAGE_COOKIE?.trim() || + getProviderSpecificString(providerSpecificData, [ + "ollamaUsageCookie", + "ollamaCloudUsageCookie", + "ollamaCloudCookie", + "usageCookie", + "cookie", + ]); + if (!cookie) return { state: "none" }; + if (cookie.includes("\r") || cookie.includes("\n")) { + return { state: "invalid", error: "Ollama Cloud cookie contains invalid CRLF characters." }; + } + return { state: "configured", cookie }; +} + +function normalizeOllamaCloudCookie(value: string): string { + const trimmed = value.trim(); + return trimmed.toLowerCase().startsWith(`${OLLAMA_CLOUD_SESSION_COOKIE.toLowerCase()}=`) + ? trimmed.slice(OLLAMA_CLOUD_SESSION_COOKIE.length + 1).trim() + : trimmed; +} + +function extractOllamaUsagePercent(trackHtml: string): number | null { + const tagHeader = trackHtml.match(/^[^>]*/)?.[0] ?? ""; + const ariaMatch = tagHeader.match(/(\d+(?:\.\d+)?)%\s*used/); + if (ariaMatch) { + const pct = toNumber(ariaMatch[1], Number.NaN); + if (Number.isFinite(pct) && pct >= 0 && pct <= 100) return pct; + } + const style = tagHeader.match(/style="([^"]*)"/)?.[1] ?? ""; + const pct = toNumber(style.match(/(?:^|;)\s*width\s*:\s*([0-9.]+)%/)?.[1], Number.NaN); + return Number.isFinite(pct) && pct >= 0 && pct <= 100 ? pct : null; +} + +function parseOllamaCloudSettingsHtml(html: string): OllamaCloudUsage | null { + const parts = html.split(/\bdata-usage-track\b/); + if (parts.length < 2) return null; + const extractTime = (text: string): string | null => { + const match = text.match(/class="[^"]*local-time[^"]*"[^>]*data-time="([^"]*)"/); + return match?.[1] || null; + }; + const sessionPercent = extractOllamaUsagePercent(parts[1]); + const weeklyPercent = parts[2] ? extractOllamaUsagePercent(parts[2]) : null; + if (sessionPercent === null && weeklyPercent === null) return null; + return { + ...(sessionPercent !== null + ? { session: { usagePercent: sessionPercent, resetAt: extractTime(parts[1]) } } + : {}), + ...(weeklyPercent !== null + ? { weekly: { usagePercent: weeklyPercent, resetAt: extractTime(parts[2]) } } + : {}), + planTier: html.match(/class="[^"]*capitalize[^"]*"[^>]*>([^<]*) +) { + const response = await fetch(OLLAMA_CLOUD_USAGE_URL, { + redirect: "manual", + headers: { + Accept: "text/html", + Cookie: `${OLLAMA_CLOUD_SESSION_COOKIE}=${normalizeOllamaCloudCookie(config.cookie)}`, + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Gecko/20100101 Firefox/148.0", + }, + signal: AbortSignal.timeout(10_000), + }); + if (response.status >= 300 && response.status < 400) { + return { usage: null, message: "Ollama Cloud authentication expired. Refresh the cookie." }; + } + if (!response.ok) + return { usage: null, message: `Ollama Cloud settings error (${response.status}).` }; + const usage = parseOllamaCloudSettingsHtml(await response.text()); + return { + usage, + message: usage ? undefined : "Ollama Cloud settings page did not contain usage quota tracks.", + }; +} + +export async function getOllamaCloudUsage(providerSpecificData?: JsonRecord) { + const config = resolveOllamaCloudConfig(providerSpecificData); + if (config.state === "none") { + return { + message: + "Ollama Cloud quota requires OLLAMA_USAGE_COOKIE. Copy the __Secure-session cookie from ollama.com/settings.", + }; + } + if (config.state === "invalid") return { message: config.error }; + + try { + const result = await fetchOllamaCloudUsageFromSettings(config); + if (!result.usage) return { message: result.message || "Ollama Cloud quota data unavailable." }; + const quotas: Record = {}; + for (const key of ["session", "weekly"] as const) { + const quota = result.usage[key]; + if (!quota) continue; + const pct = toPercentage(quota.usagePercent); + quotas[key] = { + used: pct, + total: 100, + remaining: Math.max(0, 100 - pct), + remainingPercentage: Math.max(0, 100 - pct), + resetAt: quota.resetAt, + unlimited: false, + displayName: key === "session" ? "Session" : "Weekly", + }; + } + return { + plan: result.usage.planTier ? `Ollama Cloud ${result.usage.planTier}` : "Ollama Cloud", + quotas, + }; + } catch (error) { + return { message: `Ollama Cloud quota error: ${sanitizeErrorMessage(error)}` }; + } +} diff --git a/open-sse/services/providerCooldownTracker.ts b/open-sse/services/providerCooldownTracker.ts index afb5a9303f..f5a14d2006 100644 --- a/open-sse/services/providerCooldownTracker.ts +++ b/open-sse/services/providerCooldownTracker.ts @@ -17,13 +17,16 @@ interface CooldownEntry { lastFailureAt: number; /** Number of consecutive failures (resets on success) */ failureCount: number; + /** How long this entry must be retained for cleanup purposes */ + retentionMs: number; } // Global cooldown state: keyed by "provider:connectionId" or "provider" const cooldownMap = new Map(); -// Evict entries older than this to prevent unbounded memory growth -const MAX_ENTRY_AGE_MS = 30 * 60 * 1000; // 30 minutes +// Evict entries older than their configured retention horizon to prevent +// unbounded memory growth without shortening operator-configured cooldowns. +const DEFAULT_ENTRY_RETENTION_MS = 30 * 60 * 1000; // 30 minutes const CLEANUP_INTERVAL_MS = 60 * 1000; // Cleanup every 60s let cleanupTimer: ReturnType | null = null; @@ -31,17 +34,32 @@ let cleanupTimer: ReturnType | null = null; function startCleanupIfNeeded(): void { if (cleanupTimer) return; cleanupTimer = setInterval(() => { - const now = Date.now(); - for (const [key, entry] of cooldownMap) { - if (now - entry.lastFailureAt > MAX_ENTRY_AGE_MS) { - cooldownMap.delete(key); - } - } + cleanupExpiredCooldownEntries(); }, CLEANUP_INTERVAL_MS); // Allow Node.js to exit even if the timer is running if (cleanupTimer.unref) cleanupTimer.unref(); } +function getEntryRetentionMs(settings?: ResilienceSettings): number { + const maxRetryCooldownMs = + settings?.providerCooldown?.maxRetryCooldownMs ?? + DEFAULT_RESILIENCE_SETTINGS.providerCooldown.maxRetryCooldownMs; + return Math.max(DEFAULT_ENTRY_RETENTION_MS, maxRetryCooldownMs); +} + +/** + * Remove expired cooldown entries using each entry's configured retention + * horizon. Exported for diagnostics and focused tests; normal runtime cleanup is + * still performed by the unref'd interval started on first cooldown record. + */ +export function cleanupExpiredCooldownEntries(now = Date.now()): void { + for (const [key, entry] of cooldownMap) { + if (now - entry.lastFailureAt > entry.retentionMs) { + cooldownMap.delete(key); + } + } +} + /** * Build a cooldown key from provider and optional connectionId. */ @@ -66,12 +84,14 @@ export function recordProviderCooldown( const key = cooldownKey(provider, connectionId); const existing = cooldownMap.get(key); const now = Date.now(); + const retentionMs = getEntryRetentionMs(settings); if (existing) { existing.lastFailureAt = now; existing.failureCount++; + existing.retentionMs = Math.max(existing.retentionMs, retentionMs); } else { - cooldownMap.set(key, { lastFailureAt: now, failureCount: 1 }); + cooldownMap.set(key, { lastFailureAt: now, failureCount: 1, retentionMs }); } startCleanupIfNeeded(); diff --git a/open-sse/services/reasoningTokenBuffer.ts b/open-sse/services/reasoningTokenBuffer.ts index 23290de713..65d021e7f5 100644 --- a/open-sse/services/reasoningTokenBuffer.ts +++ b/open-sse/services/reasoningTokenBuffer.ts @@ -1,7 +1,4 @@ import { getResolvedModelCapabilities } from "../../src/lib/modelCapabilities.ts"; -import { MODEL_SPECS } from "../../src/shared/constants/modelSpecs.ts"; - -const DEFAULT_MAX_OUTPUT_TOKENS = MODEL_SPECS.__default__.maxOutputTokens; export function toPositiveInteger(value: unknown): number | null { const numericValue = @@ -29,7 +26,7 @@ export function resolveReasoningBufferedMaxTokens( if (capabilities.supportsThinking !== true) return null; const maxOutputTokens = toPositiveInteger(capabilities.maxOutputTokens); - if (maxOutputTokens === null || maxOutputTokens === DEFAULT_MAX_OUTPUT_TOKENS) return null; + if (maxOutputTokens === null) return null; if (current > maxOutputTokens) return maxOutputTokens; if (current === maxOutputTokens) return current; diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 6db2f04ea3..edd14b8a09 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -20,6 +20,7 @@ import { getDbInstance } from "@/lib/db/core"; import { fetchBailianQuota, type BailianTripleWindowQuota } from "./bailianQuotaFetcher.ts"; import { fetchDeepseekQuota, type DeepseekQuota } from "./deepseekQuotaFetcher.ts"; import { fetchOpencodeQuota, type OpencodeTripleWindowQuota } from "./opencodeQuotaFetcher.ts"; +import { getOllamaCloudUsage, getOpenCodeGoUsage } from "./opencodeOllamaUsage.ts"; import { applyAntigravityClientProfileHeaders, getAntigravityBootstrapHeaders, @@ -91,19 +92,6 @@ const NANOGPT_CONFIG = { usageUrl: "https://nano-gpt.com/api/subscription/v1/usage", }; -const OPENCODE_GO_QUOTA_URL = - // Note: api.z.ai rejects opencode-go keys with {"code":401}. This default is a - // known broken placeholder (see issues #10448, #16017). The env-var override lets - // operators point at a working endpoint once OpenCode ships one. - process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL ?? "https://api.z.ai/api/monitor/usage/quota/limit"; -const OPENCODE_GO_QUOTA_TOTALS = { - session: 12, - weekly: 30, - mcp_monthly: 60, -} as const; -const OPENCODE_GO_QUOTA_ORDER = ["session", "weekly", "mcp_monthly"] as const; -type OpenCodeGoQuotaName = (typeof OPENCODE_GO_QUOTA_ORDER)[number]; - // Cursor dashboard usage API config // The endpoint that powers https://cursor.com/dashboard/spending. Validates the WorkOS // session via the WorkosCursorSessionToken cookie (format: `${userId}::${jwt}`) and @@ -214,77 +202,6 @@ function getGlmQuotaDisplayName(quotaName: string): string { if (quotaName === "weekly") return "Weekly Quota"; return quotaName; } - -function getOpenCodeGoTokenQuotaName( - limit: JsonRecord, - existingQuotas: Record -): "session" | "weekly" { - const unit = toNumber(limit.unit, 0); - const number = toNumber(limit.number, 0); - - if (unit === 3 && number === 5) return "session"; - if (unit === 6 && number === 1) return "weekly"; - if ((unit === 4 && number === 7) || (unit === 3 && number >= 24 * 7)) return "weekly"; - - return existingQuotas.session ? "weekly" : "session"; -} - -function getOpenCodeGoQuotaDisplayName(quotaName: OpenCodeGoQuotaName): string { - if (quotaName === "session") return "5-hour rolling"; - if (quotaName === "weekly") return "Weekly"; - return "Monthly"; -} - -function normalizeOpenCodeGoQuotaToken(apiKey: string): string { - return apiKey.trim().replace(/^Bearer\s+/i, ""); -} - -function buildOpenCodeGoDollarQuota( - quotaName: OpenCodeGoQuotaName, - percentage: unknown, - resetAt: string | null, - usedOverride?: unknown, - details?: UsageQuota["details"] -): UsageQuota { - const total = OPENCODE_GO_QUOTA_TOTALS[quotaName]; - const percentUsed = toPercentage(percentage); - const rawUsed = toNumber(usedOverride, Number.NaN); - const used = roundCurrency( - Number.isFinite(rawUsed) ? Math.max(0, Math.min(total, rawUsed)) : (total * percentUsed) / 100 - ); - const remaining = roundCurrency(Math.max(0, total - used)); - const remainingPercentage = - total > 0 - ? clampPercentage(Math.round((remaining / total) * 100)) - : clampPercentage(100 - percentUsed); - - return { - used, - total, - remaining, - remainingPercentage, - resetAt, - unlimited: false, - displayName: getOpenCodeGoQuotaDisplayName(quotaName), - currency: "USD", - details, - }; -} - -function orderOpenCodeGoQuotas(quotas: Record): Record { - const ordered: Record = {}; - - for (const key of OPENCODE_GO_QUOTA_ORDER) { - if (quotas[key]) ordered[key] = quotas[key]; - } - - for (const [key, quota] of Object.entries(quotas)) { - if (!ordered[key]) ordered[key] = quota; - } - - return ordered; -} - function getFieldValue(source: unknown, snakeKey: string, camelKey: string): unknown { const obj = toRecord(source); return obj[snakeKey] ?? obj[camelKey] ?? null; @@ -966,121 +883,6 @@ async function getGlmUsage(apiKey: string, providerSpecificData?: Record).code, 200); - if (code === 401 || code === 403 || (json as Record).success === false) { - return { - message: - "OpenCode Go does not expose a public quota API. Chat requests still work. " + - "Set OMNIROUTE_OPENCODE_GO_QUOTA_URL to a working endpoint, or follow " + - "https://github.com/anomalyco/opencode/issues/16017 for upstream status.", - }; - } - - const data = toRecord((json as Record).data); - const limits: unknown[] = Array.isArray(data.limits) ? data.limits : []; - const quotas: Record = {}; - - for (const limit of limits) { - const src = toRecord(limit); - const type = String(src.type || "").toUpperCase(); - const resetAt = parseResetTime(src.nextResetTime); - - if (type === "TOKENS_LIMIT" || type === "TOKEN_LIMIT") { - const quotaName = getOpenCodeGoTokenQuotaName(src, quotas); - - quotas[quotaName] = buildOpenCodeGoDollarQuota( - quotaName, - src.percentage, - resetAt, - undefined, - Array.isArray(src.models) - ? (src.models as unknown[]).map((model) => { - const modelInfo = toRecord(model); - return { - name: String(modelInfo.model || modelInfo.modelCode || "usage"), - used: toNumber(modelInfo.percentage, 0), - }; - }) - : undefined - ); - continue; - } - - if (type === "TIME_LIMIT" || type === "TIME_USAGE_LIMIT") { - quotas.mcp_monthly = buildOpenCodeGoDollarQuota( - "mcp_monthly", - src.percentage, - resetAt, - src.currentValue, - Array.isArray(src.usageDetails) - ? src.usageDetails.map((item) => { - const detail = toRecord(item); - return { - name: String(detail.modelCode || detail.name || "usage"), - used: toNumber(detail.usage, 0), - }; - }) - : undefined - ); - } - } - - const levelRaw = - typeof data.planName === "string" - ? data.planName - : typeof data.level === "string" - ? data.level - : ""; - const planLabel = toTitleCase(levelRaw.replace(/\s*plan$/i, "")); - const plan = planLabel - ? /^opencode\s+go\b/i.test(planLabel) - ? planLabel - : `OpenCode Go ${planLabel}` - : null; - - return { plan, quotas: orderOpenCodeGoQuotas(quotas) }; -} - /** * Bailian (Alibaba Coding Plan) Usage * Fetches triple-window quota (5h, weekly, monthly) and returns worst-case. @@ -1589,7 +1391,9 @@ export async function getUsageForProvider( ...(provider === "glm-cn" ? { apiRegion: "china" } : {}), }); case "opencode-go": - return await getOpenCodeGoUsage(apiKey || ""); + return await getOpenCodeGoUsage(apiKey || "", providerSpecificData); + case "ollama-cloud": + return await getOllamaCloudUsage(providerSpecificData); case "minimax": case "minimax-cn": return await getMiniMaxUsage(apiKey || "", provider); @@ -3118,7 +2922,10 @@ async function getKiroUsage(accessToken?: string, providerSpecificData?: JsonRec // "auth expired, chat may still work" message instead of a generic upstream-error blob // so the quota card matches what users with legacy social-auth accounts already see. // Inspired by https://github.com/decolua/9router/pull/620. - if ((response.status === 401 || response.status === 403) && isSocialAuthKiroAccount(providerSpecificData)) { + if ( + (response.status === 401 || response.status === 403) && + isSocialAuthKiroAccount(providerSpecificData) + ) { return { message: "Kiro quota API authentication expired. Chat may still work.", quotas: {}, diff --git a/open-sse/translator/deepseekWebTools.ts b/open-sse/translator/deepseekWebTools.ts new file mode 100644 index 0000000000..34723839f2 --- /dev/null +++ b/open-sse/translator/deepseekWebTools.ts @@ -0,0 +1,441 @@ +// DeepSeek-web-specific tool-call translation. +// +// chat.deepseek.com has no native function calling, so OmniRoute serializes the OpenAI +// `tools[]` into a prompt contract and parses the model's text reply back into OpenAI +// `tool_calls`. The canonical `webTools.ts` parser handles the well-behaved +// `{json}` / bare-JSON shapes used by most web-cookie providers, and it MUST +// stay untouched (it works for the others). +// +// DeepSeek, however, emits a much wider zoo of ad-hoc shapes: +// {json} name in the tag suffix, body is the arguments +// {id,type,params} alternate key names (type → name, params → arguments) +// {json} name in an attribute +// {json} tool name in the id attribute +// {json} doubled / nested wrappers +// x{json} XML children +// parameter style +// +// A single regex cannot robustly cover all of these (nesting + attributes + XML children), +// so this parser tokenizes the tool tags and walks them with a stack instead. It reuses the +// proven JSON-normalization / fuzzy-name-matching / range-stripping helpers from webTools.ts +// rather than duplicating them. + +import { + parseToolCallsFromText, + parseLooseJsonObject, + getRequestedToolNames, + resolveRequestedToolName, + toArgumentsString, + stripRanges, + type OpenAIToolCall, + type RequestedToolName, +} from "./webTools.ts"; + +interface OpenAIToolDef { + type?: string; + function?: { name?: string; description?: string; parameters?: unknown }; +} + +// ── Stricter, compact tool-use prompt ─────────────────────────────────────── + +/** + * Serialize an OpenAI `tools` array into a DeepSeek-specific system-prompt block. + * + * It is deliberately stricter than the generic `serializeToolsToPrompt`: DeepSeek tends to + * (a) invent its own wrappers and (b) merely *describe* a plan instead of emitting a call. + * The wording forces the single canonical `{json}` shape and forbids the + * alternatives, while staying short to avoid wasting tokens. + */ +export function serializeDeepSeekToolPrompt(tools: unknown): string { + if (!Array.isArray(tools) || tools.length === 0) return ""; + + const lines: string[] = []; + for (const t of tools as OpenAIToolDef[]) { + const fn = t?.function; + if (!fn?.name) continue; + const desc = typeof fn.description === "string" && fn.description ? fn.description : ""; + let params = ""; + try { + params = fn.parameters ? JSON.stringify(fn.parameters) : ""; + } catch { + params = ""; + } + lines.push( + `- ${fn.name}${desc ? `: ${desc}` : ""}${params ? `\n parameters: ${params}` : ""}` + ); + } + if (lines.length === 0) return ""; + + return [ + "You can call tools. To call a tool, output ONLY this exact block (no markdown fence):", + '{"name": "", "arguments": { ... }}', + "Rules:", + "- Use exactly .... Do NOT use , , , , id=/name= attributes, or code fences.", + '- "name" must be one of the tools below; "arguments" must be a JSON object.', + "- When a tool is needed, emit the block instead of only describing the plan.", + "- Emit one block per call; you may put several blocks back to back.", + "- If no tool is needed, just answer normally without any block.", + "", + "Available tools:", + ...lines, + ].join("\n"); +} + +// ── Tool-aware conversation prompt ─────────────────────────────────────────── + +interface ChatMessage { + role: string; + content?: unknown; + tool_calls?: Array<{ id?: string; function?: { name?: string; arguments?: unknown } }>; + tool_call_id?: string; + name?: string; +} + +function extractText(content: unknown): string { + if (Array.isArray(content)) { + return (content as Array<{ type?: string; text?: string }>) + .filter((item) => item?.type === "text") + .map((item) => item?.text ?? "") + .join("\n"); + } + return content == null ? "" : String(content); +} + +/** + * Build the single `prompt` string for an agentic (tool-using) DeepSeek-web turn. + * + * The web endpoint takes a flat prompt with no `messages[]`, so the legacy `messagesToPrompt` + * only forwarded the last user message — which makes an agent loop amnesiac: on every turn the + * follow-up messages carry no new *user* text, so DeepSeek only ever saw the original task and + * kept restarting (re-creating todos, re-listing files…). This builder instead replays the WHOLE + * trajectory — including the assistant's prior `` calls and each `role:"tool"` result — so + * the model continues from where it left off instead of starting over. + */ +export function buildToolConversationPrompt( + messages: ChatMessage[], + toolSystemPrompt: string +): string { + const systemParts: string[] = []; + if (toolSystemPrompt) systemParts.push(toolSystemPrompt); + + const lines: string[] = []; + const callNameById = new Map(); + let sawToolActivity = false; + + for (const m of messages) { + if (m.role === "system") { + const t = extractText(m.content).trim(); + if (t) systemParts.push(t); + } else if (m.role === "user") { + const t = extractText(m.content).trim(); + if (t) lines.push(`User: ${t}`); + } else if (m.role === "assistant") { + const t = extractText(m.content).trim(); + const calls = Array.isArray(m.tool_calls) ? m.tool_calls : []; + const parts: string[] = []; + if (t) parts.push(t); + for (const c of calls) { + const name = typeof c?.function?.name === "string" ? c.function.name : ""; + const rawArgs = c?.function?.arguments; + const args = + typeof rawArgs === "string" && rawArgs ? rawArgs : JSON.stringify(rawArgs ?? {}); + if (c?.id) callNameById.set(c.id, name); + parts.push(`{"name": ${JSON.stringify(name)}, "arguments": ${args}}`); + sawToolActivity = true; + } + if (parts.length) lines.push(`Assistant: ${parts.join("\n")}`); + } else if (m.role === "tool") { + const t = extractText(m.content).trim(); + const name = (m.tool_call_id && callNameById.get(m.tool_call_id)) || m.name || "tool"; + lines.push(`Tool result (${name}): ${t || "(no output)"}`); + sawToolActivity = true; + } + } + + const parts: string[] = []; + if (systemParts.length) parts.push(systemParts.join("\n\n")); + if (lines.length) parts.push(lines.join("\n\n")); + if (sawToolActivity) { + // Anchor the model to the work already done so it advances instead of repeating it. + parts.push( + "Continue the task using the tool results above. Do NOT repeat tool calls that already " + + "succeeded; perform the next step or give the final answer." + ); + } + + return parts.join("\n\n").replace(/!\[.*?\]\(.*?\)/g, ""); +} + +// ── Tag tokenizer ──────────────────────────────────────────────────────────── + +interface TagToken { + start: number; + end: number; + closing: boolean; + suffix: string; // tool name after ':' in the tag (e.g. `` → "bash") + attrs: string; // raw attribute text inside the tag +} + +// Matches an opening/closing or tag, optionally with a `:name` +// suffix and an attribute list. `tool_call` is listed first so it wins the alternation. +const TAG_TOKEN_RE = /<(\/?)(?:tool_call|tool)(:[A-Za-z0-9_.+-]+)?((?:\s[^>]*)?)\/?>/g; + +function tokenizeToolTags(text: string): TagToken[] { + const tokens: TagToken[] = []; + let m: RegExpExecArray | null; + TAG_TOKEN_RE.lastIndex = 0; + while ((m = TAG_TOKEN_RE.exec(text)) !== null) { + tokens.push({ + start: m.index, + end: TAG_TOKEN_RE.lastIndex, + closing: m[1] === "/", + suffix: m[2] ? m[2].slice(1) : "", + attrs: m[3] || "", + }); + } + return tokens; +} + +interface ToolBlock { + open: TagToken; + close: TagToken; + innerStart: number; + innerEnd: number; +} + +// Pair tags with a stack: every closing tag pairs with the nearest unmatched open. An open +// left unmatched at the end (e.g. the stray outer `` of a doubled wrapper, or a +// never-closed `` followed by ``) gets a synthetic close at the end +// of the text so its body is still parsed; the doubled-wrapper outer is then dropped by the +// leaf filter. +function pairToolBlocks(tokens: TagToken[], textLen: number): ToolBlock[] { + const blocks: ToolBlock[] = []; + const stack: TagToken[] = []; + for (const tok of tokens) { + if (!tok.closing) { + stack.push(tok); + continue; + } + const open = stack.pop(); + if (!open) continue; + blocks.push({ open, close: tok, innerStart: open.end, innerEnd: tok.start }); + } + for (const open of stack) { + const synthetic: TagToken = { + start: textLen, + end: textLen, + closing: true, + suffix: "", + attrs: "", + }; + blocks.push({ open, close: synthetic, innerStart: open.end, innerEnd: textLen }); + } + return blocks; +} + +// ── Attribute / XML-child helpers ──────────────────────────────────────────── + +/** Read an attribute value, tolerating backslash-escaped quotes inside the value. */ +function getAttr(attrs: string, name: string): string | null { + const re = new RegExp(`\\b${name}\\s*=\\s*("|')`); + const m = re.exec(attrs); + if (!m) return null; + const quote = m[1]; + let j = m.index + m[0].length; + let out = ""; + while (j < attrs.length) { + const ch = attrs[j]; + if (ch === "\\") { + out += attrs[j + 1] ?? ""; + j += 2; + continue; + } + if (ch === quote) break; + out += ch; + j += 1; + } + return out; +} + +function getXmlChild(inner: string, tag: string): string | null { + const m = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, "i").exec(inner); + return m ? m[1].trim() : null; +} + +// The body group is a tempered greedy token: `(?:(?!` (no closing tag) cannot let the body matcher swallow a +// following `...` and drop that parameter. +const PARAM_TAG_RE = /]*?)\/?>(?:((?:(?!)?/gi; + +/** Collect `` / `y` into an object. */ +function buildArgsFromParameters(inner: string): Record | null { + const out: Record = {}; + let found = false; + let m: RegExpExecArray | null; + PARAM_TAG_RE.lastIndex = 0; + while ((m = PARAM_TAG_RE.exec(inner)) !== null) { + const attrs = m[1] || ""; + const body = m[2]; + const name = getAttr(attrs, "name"); + if (!name) continue; + const value = getAttr(attrs, "content") ?? (typeof body === "string" ? body.trim() : ""); + out[name] = value; + found = true; + } + return found ? out : null; +} + +// ── Single-block extraction ────────────────────────────────────────────────── + +interface ExtractedCall { + name: string; + arguments: string; +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +/** + * Turn one tool block (tag name + inner text) into a name + JSON-string arguments. + * Returns null when no plausible tool name can be recovered. + */ +function extractCall( + tagName: string, + innerRaw: string, + requested: RequestedToolName[] +): ExtractedCall | null { + const inner = innerRaw.trim(); + + const nameChild = getXmlChild(inner, "name"); + const argsChild = getXmlChild(inner, "arguments") ?? getXmlChild(inner, "parameters"); + const paramObj = argsChild ? null : buildArgsFromParameters(inner); + const hasXmlChildren = !!nameChild || !!argsChild || !!paramObj; + + const json = hasXmlChildren ? null : parseLooseJsonObject(inner); + const jsonName = json ? (asString(json.name) ?? asString(json.type)) : null; + + const childResolved = nameChild ? resolveRequestedToolName(nameChild, requested) : null; + const jsonResolved = jsonName ? resolveRequestedToolName(jsonName, requested) : null; + const tagResolved = tagName ? resolveRequestedToolName(tagName, requested) : null; + + // Prefer a name that maps to a requested tool. The JSON body wins over the tag attribute + // because DeepSeek sometimes emits a bogus tag name (e.g. name="skill", #3260). + let name: string | null = null; + let nameFromTag = false; + const pick = (val: string | null, fromTag: boolean) => { + if (!name && val) { + name = val; + nameFromTag = fromTag; + } + }; + pick(childResolved, false); + pick(jsonResolved, false); + pick(tagResolved, true); + pick(nameChild, false); + pick(jsonName, false); + pick(tagName, true); + + // Shell-style `{ "command": "..." }` with no tag name: treat command as the tool name only + // if it actually resolves to a requested tool (the value is otherwise the command itself). + if (!name && !tagName && json) { + const command = asString(json.command); + const resolved = command ? resolveRequestedToolName(command, requested) : null; + if (resolved) { + name = resolved; + nameFromTag = false; + } + } + if (!name) return null; + + let argsValue: unknown; + if (argsChild) { + argsValue = parseLooseJsonObject(argsChild) ?? argsChild; + } else if (paramObj) { + argsValue = paramObj; + } else if (json) { + if (json.arguments !== undefined) argsValue = json.arguments; + else if (json.params !== undefined) argsValue = json.params; + else if (nameFromTag) { + // `{"command": ...}` — the whole JSON object is the arguments payload. + argsValue = json; + } else { + // Name came from the JSON body — the remaining keys are the arguments. + const { name: _n, type: _t, id: _i, command: _c, arguments: _a, params: _p, ...rest } = json; + argsValue = rest; + } + } else { + argsValue = {}; + } + + return { name, arguments: toArgumentsString(argsValue) }; +} + +// ── Public parser ───────────────────────────────────────────────────────────── + +/** + * Parse a DeepSeek-web text reply into OpenAI `tool_calls`. Returns the surrounding text with the recognized blocks stripped (so it can + * still be streamed to the client) plus the parsed calls, or `null` when none are present. + * + * Falls back to the canonical `webTools.parseToolCallsFromText` for tag-free replies so that + * bare-JSON and plain `` behavior stays identical to the shared implementation. + */ +export function parseDeepSeekToolCalls( + text: string, + idSeed = "call", + requestedTools?: unknown +): { content: string; toolCalls: OpenAIToolCall[] | null } { + if (typeof text !== "string" || text.length === 0) { + return { content: text ?? "", toolCalls: null }; + } + + const tokens = tokenizeToolTags(text); + if (tokens.length === 0) { + // No DeepSeek-specific tags — defer to the proven canonical parser (bare JSON, etc.). + return parseToolCallsFromText(text, idSeed, requestedTools); + } + + const requested = getRequestedToolNames(requestedTools); + const blocks = pairToolBlocks(tokens, text.length); + + // Only extract from leaf blocks (no other block nested inside), so a doubled + // `...` wrapper yields a single call from the inner block. + const isLeaf = (b: ToolBlock) => + !blocks.some((o) => o !== b && o.open.start >= b.innerStart && o.close.end <= b.innerEnd); + + const toolCalls: OpenAIToolCall[] = []; + const acceptedRanges: Array<{ start: number; end: number }> = []; + + for (const block of blocks.filter(isLeaf).sort((a, b) => a.open.start - b.open.start)) { + const tagName = + block.open.suffix || + getAttr(block.open.attrs, "name") || + getAttr(block.open.attrs, "id") || + ""; + const inner = text.slice(block.innerStart, block.innerEnd); + const call = extractCall(tagName, inner, requested); + if (!call) continue; + toolCalls.push({ + id: `${idSeed}_${toolCalls.length}`, + type: "function", + function: { name: call.name, arguments: call.arguments }, + }); + acceptedRanges.push({ start: block.open.start, end: block.close.end }); + } + + if (toolCalls.length === 0) { + // Tags were present but none parsed (e.g. malformed) — try the canonical bare-JSON path. + return parseToolCallsFromText(text, idSeed, requestedTools); + } + + // Strip the accepted blocks plus any stray tool tags left outside them (the unmatched outer + // `` of a doubled wrapper, leftover `` of a non-leaf wrapper, etc.). + const within = (tok: TagToken) => + acceptedRanges.some((r) => tok.start >= r.start && tok.end <= r.end); + const ranges = [ + ...acceptedRanges, + ...tokens.filter((t) => !within(t)).map((t) => ({ start: t.start, end: t.end })), + ]; + + return { content: stripRanges(text, ranges), toolCalls }; +} diff --git a/open-sse/translator/request/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index 92b825f46e..fadc428731 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -55,7 +55,10 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { result.generationConfig.topK = body.top_k; } if (body.max_tokens !== undefined) { - result.generationConfig.maxOutputTokens = capMaxOutputTokens(model, body.max_tokens); + const maxOutputTokens = capMaxOutputTokens(model, body.max_tokens); + if (maxOutputTokens !== null) { + result.generationConfig.maxOutputTokens = maxOutputTokens; + } } // ── System instruction ───────────────────────────────────────── diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index 4ec6236f75..55a1f28433 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -41,14 +41,13 @@ function applyCopilotSummarizedThinkingDisplay( // - max_tokens must be <= model output cap (e.g. 128000 for Opus 4.7) const MIN_CLAUDE_THINKING_BUDGET = 1024; const MIN_RESPONSE_ROOM = 1024; -const FALLBACK_OUTPUT_CAP = 128000; -function safeCapMaxOutputTokens(model: string): number { +function safeCapMaxOutputTokens(model: string): number | null { try { const cap = capMaxOutputTokens(model); - return typeof cap === "number" && cap > 0 ? cap : FALLBACK_OUTPUT_CAP; + return typeof cap === "number" && cap > 0 ? cap : null; } catch { - return FALLBACK_OUTPUT_CAP; + return null; } } @@ -86,26 +85,35 @@ export function fitThinkingToMaxTokens( // No budgeted thinking — just cap max_tokens to the model output ceiling. if (!thinking || requestedBudget <= 0) { return { - maxTokens: Math.min(Math.max(callerMaxTokens, 1), modelCap), + maxTokens: + modelCap === null + ? Math.max(callerMaxTokens, 1) + : Math.min(Math.max(callerMaxTokens, 1), modelCap), thinking, }; } let responseRoom = Math.max(callerMaxTokens, MIN_RESPONSE_ROOM); - let target = Math.min(responseRoom + requestedBudget, modelCap); + let target = + modelCap === null + ? responseRoom + requestedBudget + : Math.min(responseRoom + requestedBudget, modelCap); let fittedBudget = target - responseRoom; // If the cap squeezed thinking below Anthropic's floor, try shrinking // response room to MIN_RESPONSE_ROOM to recover budget. if (fittedBudget < MIN_CLAUDE_THINKING_BUDGET && responseRoom > MIN_RESPONSE_ROOM) { responseRoom = MIN_RESPONSE_ROOM; - target = Math.min(responseRoom + requestedBudget, modelCap); + target = + modelCap === null + ? responseRoom + requestedBudget + : Math.min(responseRoom + requestedBudget, modelCap); fittedBudget = target - responseRoom; } // Cap too tight for any thinking — disable rather than send an invalid request. if (fittedBudget < MIN_CLAUDE_THINKING_BUDGET) { - return { maxTokens: modelCap, thinking: undefined }; + return { maxTokens: modelCap ?? Math.max(callerMaxTokens, 1), thinking: undefined }; } const adjustedThinking: Record = { ...thinking }; diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 39e2016da6..bc9e6651e6 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -32,9 +32,8 @@ import { import { buildGeminiTools, sanitizeGeminiToolName } from "../helpers/geminiToolsSanitizer.ts"; // Observed Antigravity wrapper output cap, not an underlying model capability. -// Keep this bridge-local: capMaxOutputTokens() falls back to OmniRoute's generic -// 8192 default for unknown Claude-family IDs, while Antigravity currently caps -// visible output around 16K. See: https://github.com/keisksw/antigravity-output-analysis +// Keep this bridge-local: Antigravity currently caps visible output around 16K. +// See: https://github.com/keisksw/antigravity-output-analysis const ANTIGRAVITY_CLAUDE_MAX_OUTPUT_TOKENS = 16_384; type GeminiPart = Record; @@ -274,13 +273,12 @@ function openaiToGeminiBase( if (body.stop !== undefined) { result.generationConfig.stopSequences = Array.isArray(body.stop) ? body.stop : [body.stop]; } - const requestedMaxOutputTokens = (body.max_tokens ?? body.max_completion_tokens) as - | number - | undefined; - if (requestedMaxOutputTokens !== undefined) { - result.generationConfig.maxOutputTokens = capMaxOutputTokens(model, requestedMaxOutputTokens); - } else { - result.generationConfig.maxOutputTokens = capMaxOutputTokens(model); + const maxOutputTokens = capMaxOutputTokens( + model, + (body.max_tokens ?? body.max_completion_tokens) as number | undefined + ); + if (maxOutputTokens !== null) { + result.generationConfig.maxOutputTokens = maxOutputTokens; } // Thinking / Reasoning support (Google Gemini 2.0+ Thinking models) diff --git a/open-sse/translator/webTools.ts b/open-sse/translator/webTools.ts index 28a94219d9..a3fc8f1766 100644 --- a/open-sse/translator/webTools.ts +++ b/open-sse/translator/webTools.ts @@ -34,7 +34,7 @@ interface ToolParseCandidate { requireRequestedTool: boolean; } -interface RequestedToolName { +export interface RequestedToolName { original: string; normalized: string; } @@ -45,7 +45,7 @@ function toRecord(value: unknown): Record | null { : null; } -function getRequestedToolNames(tools: unknown): RequestedToolName[] { +export function getRequestedToolNames(tools: unknown): RequestedToolName[] { if (!Array.isArray(tools)) return []; const names: RequestedToolName[] = []; const seen = new Set(); @@ -102,7 +102,10 @@ function scoreToolName(emitted: string, requested: RequestedToolName): number { return similarity >= 0.72 ? similarity : 0; } -function resolveRequestedToolName(emitted: string, requestedTools: RequestedToolName[]): string | null { +export function resolveRequestedToolName( + emitted: string, + requestedTools: RequestedToolName[] +): string | null { if (requestedTools.length === 0) return emitted; let best: { name: string; score: number } | null = null; @@ -227,7 +230,7 @@ function normalizeLooseJson(value: string): string { .replace(/,\s*([}\]])/g, "$1"); } -function parseLooseJsonObject(raw: string): Record | null { +export function parseLooseJsonObject(raw: string): Record | null { const trimmed = stripCodeFence(raw); for (const candidate of [trimmed, normalizeLooseJson(trimmed)]) { try { @@ -282,7 +285,10 @@ function findBareJsonCandidates(text: string): ToolParseCandidate[] { depth -= 1; if (depth === 0 && start >= 0) { const raw = text.slice(start, i + 1); - if (/[{,]\s*["']?(name|command)["']?\s*:/i.test(raw) && /[{,]\s*["']?arguments["']?\s*:/i.test(raw)) { + if ( + /[{,]\s*["']?(name|command)["']?\s*:/i.test(raw) && + /[{,]\s*["']?arguments["']?\s*:/i.test(raw) + ) { candidates.push({ raw, start, end: i + 1, requireRequestedTool: true }); } start = -1; @@ -293,11 +299,14 @@ function findBareJsonCandidates(text: string): ToolParseCandidate[] { return candidates; } -function rangesOverlap(a: { start: number; end: number }, b: { start: number; end: number }): boolean { +function rangesOverlap( + a: { start: number; end: number }, + b: { start: number; end: number } +): boolean { return a.start < b.end && b.start < a.end; } -function stripRanges(text: string, ranges: Array<{ start: number; end: number }>): string { +export function stripRanges(text: string, ranges: Array<{ start: number; end: number }>): string { let content = text; const sorted = [...ranges].sort((a, b) => b.start - a.start); for (const range of sorted) { @@ -308,13 +317,18 @@ function stripRanges(text: string, ranges: Array<{ start: number; end: number }> const afterOnLine = content.slice(range.end, lineEnd); const removeWholeLine = beforeOnLine.trim() === "" && afterOnLine.trim() === ""; const start = removeWholeLine ? lineStart : range.start; - const end = removeWholeLine && nextLineBreak !== -1 ? nextLineBreak + 1 : removeWholeLine ? lineEnd : range.end; + const end = + removeWholeLine && nextLineBreak !== -1 + ? nextLineBreak + 1 + : removeWholeLine + ? lineEnd + : range.end; content = `${content.slice(0, start)}${content.slice(end)}`; } return content.replace(/\n{3,}/g, "\n\n").trim(); } -function toArgumentsString(value: unknown): string { +export function toArgumentsString(value: unknown): string { if (value === undefined) return "{}"; if (typeof value === "string") { const parsed = parseLooseJsonObject(value); @@ -346,7 +360,9 @@ export function serializeToolsToPrompt(tools: unknown): string { } catch { params = ""; } - lines.push(`- ${fn.name}${desc ? `: ${desc}` : ""}${params ? `\n parameters: ${params}` : ""}`); + lines.push( + `- ${fn.name}${desc ? `: ${desc}` : ""}${params ? `\n parameters: ${params}` : ""}` + ); } if (lines.length === 0) return ""; @@ -471,7 +487,7 @@ interface ToolPrepResult { */ export function prepareToolMessages( bodyObj: Record, - messages: Array<{ role: string; content: unknown }>, + messages: Array<{ role: string; content: unknown }> ): ToolPrepResult { const requestedTools = bodyObj.tools; const hasTools = Array.isArray(requestedTools) && requestedTools.length > 0; @@ -500,9 +516,13 @@ interface ToolCompletionResult { export function buildToolAwareResult( rawContent: string, requestedTools: unknown, - idSeed = "call", + idSeed = "call" ): ToolCompletionResult { - const { content, toolCalls } = parseToolCallsFromText(rawContent, `${idSeed}-${Date.now()}`, requestedTools); + const { content, toolCalls } = parseToolCallsFromText( + rawContent, + `${idSeed}-${Date.now()}`, + requestedTools + ); return { content, toolCalls, diff --git a/open-sse/utils/noThinkingAlias.ts b/open-sse/utils/noThinkingAlias.ts index 9acbb2b6c8..c0dfcfbb6b 100644 --- a/open-sse/utils/noThinkingAlias.ts +++ b/open-sse/utils/noThinkingAlias.ts @@ -6,7 +6,7 @@ * thinking-capable model into a no-thinking mode purely by *model selection*, the * gateway exposes a synthetic catalog id: * - * claude-3-omniroute-no-thinking// + * no-think// * * When such an id arrives on a request we strip the prefix back to the real * `/` and suppress reasoning (`thinking:{type:"disabled"}` for the @@ -21,7 +21,7 @@ */ import { getModelSpec } from "@/shared/constants/modelSpecs"; -export const NO_THINKING_PREFIX = "claude-3-omniroute-no-thinking/"; +export const NO_THINKING_PREFIX = "no-think/"; /** True when `modelId` carries the no-thinking gateway prefix. */ export function isNoThinkingAlias(modelId: unknown): modelId is string { diff --git a/open-sse/utils/proxyDispatcher.ts b/open-sse/utils/proxyDispatcher.ts index aab0050a66..df120d11a6 100644 --- a/open-sse/utils/proxyDispatcher.ts +++ b/open-sse/utils/proxyDispatcher.ts @@ -108,10 +108,42 @@ function getProxyDispatcherOptions(env: Record = pro }; } +export function getDefaultDispatcherConnectionLimit( + env: Record = process.env +): number { + const raw = env.OMNIROUTE_DIRECT_DISPATCHER_CONNECTIONS; + if (raw == null || raw.trim() === "") return DEFAULT_PROXY_DISPATCHER_CONNECTIONS; + + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < 1) { + console.warn( + `[ProxyDispatcher] Invalid OMNIROUTE_DIRECT_DISPATCHER_CONNECTIONS="${raw}". Using default ${DEFAULT_PROXY_DISPATCHER_CONNECTIONS}.` + ); + return DEFAULT_PROXY_DISPATCHER_CONNECTIONS; + } + + return Math.min(Math.floor(parsed), MAX_PROXY_DISPATCHER_CONNECTIONS); +} + +function getDefaultDispatcherOptions(env: Record = process.env) { + const options = getDispatcherOptions(); + // #4580 — On the direct egress path, undici's default pipelining (1) lets a long + // SSE stream monopolize the single pooled socket per origin, serializing every + // other concurrent request to that same provider. Mirror the proxy fix (#4288): + // disable pipelining and keep several connections available. Unlike the proxy + // path we KEEP keep-alive — the 1ms TTL there is a cheap-proxy-socket workaround, + // not needed (and harmful to perf) for direct connections. + return { + ...options, + connections: getDefaultDispatcherConnectionLimit(env), + pipelining: 0, + }; +} + export function getDefaultDispatcher(): Dispatcher { const globalWithCache = globalThis as GlobalWithDispatcherCache; if (!globalWithCache[DEFAULT_DISPATCHER_KEY]) { - globalWithCache[DEFAULT_DISPATCHER_KEY] = new Agent(getDispatcherOptions()); + globalWithCache[DEFAULT_DISPATCHER_KEY] = new Agent(getDefaultDispatcherOptions()); } return globalWithCache[DEFAULT_DISPATCHER_KEY]; } @@ -362,6 +394,12 @@ export function __getProxyDispatcherOptionsForTest( return getProxyDispatcherOptions(env); } +export function __getDefaultDispatcherOptionsForTest( + env: Record = process.env +) { + return getDefaultDispatcherOptions(env); +} + export function createProxyDispatcher(proxyUrl: string): Dispatcher { const normalizedUrl = normalizeProxyUrl(proxyUrl, "proxy dispatcher"); const dispatcherCache = getDispatcherCache(); diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 3e51ea4e91..6d54886840 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -1545,6 +1545,26 @@ export function createSSEStream(options: StreamOptions = {}) { if (Array.isArray(parsed.choices) && parsed.choices.length === 0) { const emptyChoicesUsage = extractUsage(parsed) ?? parsed.usage; if (hasValidUsage(emptyChoicesUsage)) { + // Some upstreams (e.g. Ollama Cloud) emit prompt_tokens: 0 + // even when input was sent — they simply don't count input + // tokens. When we have a non-zero output but zero input, + // estimate the real input token count from the request body. + if ( + emptyChoicesUsage && + typeof emptyChoicesUsage === "object" && + !Array.isArray(emptyChoicesUsage) && + emptyChoicesUsage.completion_tokens > 0 + ) { + const pt = emptyChoicesUsage.prompt_tokens ?? 0; + if (pt === 0) { + const estimated = estimateUsage(body, totalContentLength, FORMATS.OPENAI); + if (estimated?.prompt_tokens > 0) { + emptyChoicesUsage.prompt_tokens = estimated.prompt_tokens; + emptyChoicesUsage.total_tokens = + (emptyChoicesUsage.total_tokens ?? 0) + estimated.prompt_tokens; + } + } + } usage = emptyChoicesUsage; output = `data: ${JSON.stringify(parsed)}\n\n`; injectedUsage = true; diff --git a/package-lock.json b/package-lock.json index c390e17096..8a1767faf8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "omniroute", - "version": "3.8.33", + "version": "3.8.34", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.8.33", + "version": "3.8.34", "hasInstallScript": true, "license": "MIT", "workspaces": [ "open-sse" ], "dependencies": { - "@aws-sdk/client-bedrock-runtime": "^3.1045.0", + "@aws-sdk/client-bedrock-runtime": "^3.1073.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -24,7 +24,7 @@ "@ngrok/ngrok": "^1.7.0", "@swc/helpers": "0.5.23", "@types/mdx": "^2.0.13", - "@xyflow/react": "^12.10.2", + "@xyflow/react": "^12.11.1", "axios": "^1.16.1", "bcryptjs": "^3.0.3", "bottleneck": "^2.19.5", @@ -34,9 +34,9 @@ "express": "^5.2.1", "fetch-socks": "^1.3.3", "fflate": "^0.8.3", - "fumadocs-core": "^16.9.0", + "fumadocs-core": "^16.10.5", "fumadocs-mdx": "^15.0.7", - "fumadocs-ui": "^16.9.0", + "fumadocs-ui": "^16.10.5", "http-proxy-middleware": "^4.0.0", "https-proxy-agent": "^9.0.0", "ink": "^7.0.3", @@ -44,13 +44,13 @@ "ink-text-input": "^6.0.0", "ioredis": "^5.10.1", "jose": "^6.2.3", - "js-yaml": "^4.1.1", + "js-yaml": "^5.0.0", "jsonc-parser": "^3.3.1", "lowdb": "^7.0.1", - "lucide-react": "^1.16.0", + "lucide-react": "^1.21.0", "marked": "^18.0.4", "marked-terminal": "^7.3.0", - "material-symbols": "^0.45.1", + "material-symbols": "^0.45.2", "mermaid": "^11.15.0", "monaco-editor": "^0.55.1", "next": "^16.2.6", @@ -58,7 +58,7 @@ "next-themes": "^0.4.6", "node-machine-id": "^1.1.12", "open": "^11.0.0", - "ora": "^9.4.0", + "ora": "^9.4.1", "parse5": "^8.0.1", "pino": "^10.3.1", "pino-abstract-transport": "^3.0.0", @@ -103,7 +103,7 @@ "@types/better-sqlite3": "^7.6.13", "@types/bun": "latest", "@types/keytar": "^4.4.2", - "@types/node": "^25.9.1", + "@types/node": "^26.0.0", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "@types/ws": "^8.18.0", @@ -114,15 +114,15 @@ "dpdm": "^4.2.0", "eslint": "^9.39.4", "eslint-config-next": "16.2.9", - "eslint-plugin-sonarjs": "^4.0.3", + "eslint-plugin-sonarjs": "^4.1.0", "fast-check": "^4.8.0", "glob": "^13.0.6", "husky": "^9.1.7", "jscpd": "^4.2.5", "jsdom": "^29.1.1", - "knip": "^6.16.1", + "knip": "^6.18.0", "license-checker-rseidelsohn": "^5.0.1", - "lint-staged": "^17.0.5", + "lint-staged": "^17.0.8", "lockfile-lint": "^5.0.0", "node-loader": "^2.1.0", "prettier": "^3.8.3", @@ -355,9 +355,9 @@ } }, "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1071.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1071.0.tgz", - "integrity": "sha512-1Sa7UTC98Wy+M+zYbbXEqhcL5WUE3VnAmO1Y2RikmYjWx/8nCHudjSj1bVovk6Obfrrsir9Q1AbrmKbgxwI90g==", + "version": "3.1073.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1073.0.tgz", + "integrity": "sha512-Vecj8r9/KIh/Nu9T7CRoCw5EBqnmAa9Q+Iwi5J5Mr0IEBMH6KUoOgAjayfyEZjvvZTllLJ2dOAx5cYeIz8QD6A==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", @@ -367,7 +367,7 @@ "@aws-sdk/eventstream-handler-node": "^3.972.22", "@aws-sdk/middleware-eventstream": "^3.972.18", "@aws-sdk/middleware-websocket": "^3.972.30", - "@aws-sdk/token-providers": "3.1071.0", + "@aws-sdk/token-providers": "3.1073.0", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", @@ -379,6 +379,23 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/token-providers": { + "version": "3.1073.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1073.0.tgz", + "integrity": "sha512-Tolawuc3I9Q6pElcqoBQMLCiCOfKn3eqG4oNIRci4BurhsrJmzXkhF3N+6LRXJrWYFtJKfTkBuLbYCLr8+pwig==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/nested-clients": "^3.997.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/core": { "version": "3.974.22", "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.22.tgz", @@ -2291,6 +2308,29 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -4901,9 +4941,9 @@ } }, "node_modules/@oxc-parser/binding-android-arm-eabi": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.135.0.tgz", - "integrity": "sha512-sHeZItACNcA5WRAWqF6ixriR4GkZDyY10gVgnZU7pXku1DjHFATSqnwZM809jl0gXPHxb6fKzYQCK7bNK5cACQ==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.137.0.tgz", + "integrity": "sha512-KDs+0VPdEmasOkpuJHW9V5WCF+cvYdMQv2Jd+aJXt+cxIx12NToRQRbXaRwUEDsZw+/jMk81Ve8ZFbjUkJTOwA==", "cpu": [ "arm" ], @@ -4918,9 +4958,9 @@ } }, "node_modules/@oxc-parser/binding-android-arm64": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.135.0.tgz", - "integrity": "sha512-wPte+SzgzWWFgMSF8YZDNM+tBXtJg0AXBi7+tU3yS2z1f2Af9kRLZLKuJojADmuD/cZexmnMHHC3SDItTW77Iw==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.137.0.tgz", + "integrity": "sha512-WhALNzfy3x/RfC6bsqX+csavuUY0yHHE7XfgPE5M542uhoBZUUoGTPG+nkMbGoG4+gcfss5s7urMyn5QBHu0sw==", "cpu": [ "arm64" ], @@ -4935,9 +4975,9 @@ } }, "node_modules/@oxc-parser/binding-darwin-arm64": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.135.0.tgz", - "integrity": "sha512-BmKz3lHIsqVos+9aPcdYCT9MG3APoUyM43KlEFhJMWNVDOGG8FKyiFz81Bc+mGz2o0hpuQ3PfXLfVWJrKXjo2g==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.137.0.tgz", + "integrity": "sha512-bFPr5hgmNMOMoyPTGtdsK4Ug21RovIPojRMgDDhSp1LtCnc/DkLwGONKjgRjszg677RlGnkYSviQ8hHaUPOVYA==", "cpu": [ "arm64" ], @@ -4952,9 +4992,9 @@ } }, "node_modules/@oxc-parser/binding-darwin-x64": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.135.0.tgz", - "integrity": "sha512-dM8BS+8+Br1fNvmh2QZbGiHaYttwLebRa6J4Uz9vuFzMNmvsdRYwf7993ptOaV0JTrR63AaoVLjX7nhWbijxjQ==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.137.0.tgz", + "integrity": "sha512-CL5dMm1asqXIDZHg14FLxj3Mc36w8PI7xCWh1uA4is6z8g2XrIILoTcQYOxDbwzuk34RDPX5IAGUxZr6LA9KAg==", "cpu": [ "x64" ], @@ -4969,9 +5009,9 @@ } }, "node_modules/@oxc-parser/binding-freebsd-x64": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.135.0.tgz", - "integrity": "sha512-xlZnvvJdR9bGu2pOhvR5hMuKPHCE6Sa9owK5A484mzjHdm75VRV5nCs5w/jkmGODMMTFc+KN7EnZqEieM813kw==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.137.0.tgz", + "integrity": "sha512-79h8rYGnSlKPGWo7mHr2ixO6ea7aW8B0CT965SZ8SLbNnCOH5aOYBTeVXUY6eMvEaiLyWr8Skuiugr5pDYgLGw==", "cpu": [ "x64" ], @@ -4986,9 +5026,9 @@ } }, "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.135.0.tgz", - "integrity": "sha512-PSR8LmBK/H/PQRiN8g7RebQgZX/ntVCrdT/JBfNxE5ezdHG1s2i4rbazsRJYD83TTI1MmgTpC0MGL42PLtskQQ==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.137.0.tgz", + "integrity": "sha512-ASgmlSimhGyr0lksgVIo6hibz1obnDq4qJbiMX/AzltfgPnanRrzG1Q+23g8ljOHOjv6dsznkUuCYL3gg0sY1Q==", "cpu": [ "arm" ], @@ -5003,9 +5043,9 @@ } }, "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.135.0.tgz", - "integrity": "sha512-I85GJXzfUsigkkk7Ngdz95C217M4FdUi1Z2HrX5UyPmURobwQZ7m2bbUvwFkz4VGZd+lymFGKHvDZ3RQC9qOzA==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.137.0.tgz", + "integrity": "sha512-AU2J9aa22Sx32wRGnDjybOU9TQXXQUud5sdUi+ZB0XxwM8aToWLweV+yA0wlQm0yIUVqljquqoHCYEq9II8gJQ==", "cpu": [ "arm" ], @@ -5020,9 +5060,9 @@ } }, "node_modules/@oxc-parser/binding-linux-arm64-gnu": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.135.0.tgz", - "integrity": "sha512-zqEY0npz0g0aGZj/8a5BclunjVDytsBQHYtIC10Gd26HcrLwbVF6YDbqRQjunMGYdSo97u6xOBl05aTDI2diDQ==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.137.0.tgz", + "integrity": "sha512-GdEtiG89yMr7XkUGxifgodXEEm2f+xW2f9CpDjlgAnBOwhTmrpQMvhOGobLVKUyzf/qHBXW16smk5zbF3nZU6w==", "cpu": [ "arm64" ], @@ -5040,9 +5080,9 @@ } }, "node_modules/@oxc-parser/binding-linux-arm64-musl": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.135.0.tgz", - "integrity": "sha512-mWAfprP819gQ2qYst1RxgTI8b/z0b29OpoKfRflIXLHde2dZLihQD4g47Onuvtpo5GPIkMYPRlX9QoeZfs/GnQ==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.137.0.tgz", + "integrity": "sha512-EGJ+Bs8iXx8KBH8DQ5BLoEm5lnHaYjlh4/8j8vFhrr/6z4tqONy5BZDzLpKmmNWlN6Hlc5r8YOuBVHqZ9vRFEQ==", "cpu": [ "arm64" ], @@ -5060,9 +5100,9 @@ } }, "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.135.0.tgz", - "integrity": "sha512-gri8c2AOmJKJwOux2KTHFBfUaXoJURuVMKhmKEi/2hTF55cQteTDV2XNfTiE5oCC+Tnem1Y4/MWzcyDadtsSag==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.137.0.tgz", + "integrity": "sha512-vzFUQENy/fnbSe5DZWovq6tIBc1uhuMztanSW6rz1e9WdQE4gHwYuD7ZII6JnrJifd1R3RSoqiZbgRFlVL2tYQ==", "cpu": [ "ppc64" ], @@ -5080,9 +5120,9 @@ } }, "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.135.0.tgz", - "integrity": "sha512-Y2tkupCG5wo0SxH2rMLG4d4Kmv6DaM3sBp+GuM5lox0S8Za6VxKgQrY2Mut088QQxKkEE89n/4CCCgmw2o0e3Q==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.137.0.tgz", + "integrity": "sha512-SfVI14HBQs9gtLcUD5hTt5hsNbdrqSUNg9S8muN+LhVQ5nf1WwH3hAoK6B9NKgdYgWAQSXFXGiiBedQ4r/BKuw==", "cpu": [ "riscv64" ], @@ -5100,9 +5140,9 @@ } }, "node_modules/@oxc-parser/binding-linux-riscv64-musl": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.135.0.tgz", - "integrity": "sha512-xDRJq6i6WTynjeP+ISbDpyH4p9BaJ0wuQcL0lCSDkt9qOXC9dmwpOu1VG/TlwmPI3KpYntmO9nJCuc3TMTsNBA==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.137.0.tgz", + "integrity": "sha512-e7Ppy4FCIFNQxT/ikSeIWFoQ0l+N9vgtRBtLcyZXeolTzApyVoPqEXsYPrcdM/9i0Bwk8knvYd37vaEMxHyi6g==", "cpu": [ "riscv64" ], @@ -5120,9 +5160,9 @@ } }, "node_modules/@oxc-parser/binding-linux-s390x-gnu": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.135.0.tgz", - "integrity": "sha512-V4MoUuiCRNvihxhIufRxvK+ka013V4joTSK0FAGA1KEjLuNprfH6N/Qw2uxQEVIFuNYMhD/hV6xJ/ptbzlKdHg==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.137.0.tgz", + "integrity": "sha512-Bho5qFwdhqsIFR7gipYEUlqvi3SRrY8sugxXig380MIaakBB1PyU9+7dBiBVScfImTNWhijUxdBwqrprGdq5WA==", "cpu": [ "s390x" ], @@ -5140,9 +5180,9 @@ } }, "node_modules/@oxc-parser/binding-linux-x64-gnu": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.135.0.tgz", - "integrity": "sha512-JCFZ7zM7KXOKoPAbK/ZB4wY0M1jxRECiem2UQuiXLjzGqS9+hno7mtX+qyK2F7HWK2xPhyJb+frpcOtk5DKOtg==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.137.0.tgz", + "integrity": "sha512-36mGWtg7PyFzjJwGDkH6/F4o2nIDEoKXLPr/X/lwqklkomQwJJt1I5GJVmGhovUEmgPK5WAeAZMqlFCehwiy9Q==", "cpu": [ "x64" ], @@ -5160,9 +5200,9 @@ } }, "node_modules/@oxc-parser/binding-linux-x64-musl": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.135.0.tgz", - "integrity": "sha512-9jSVS1b3hOV7sdKH4aA2DFfnTz0RgQd0v2BefR+LYbH8yIlmSM22JJZbAAjVeVXmFgUAk3zJQ1tpE/Nd+Vi2YQ==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.137.0.tgz", + "integrity": "sha512-/Jqx6+N7A44n2BdvUr7pXhVr2vFjs6WGH3unZRczwrfiH0H1zY0QwKQMG/dtRiTlKGDKGukznPT8lx84/oEsZg==", "cpu": [ "x64" ], @@ -5180,9 +5220,9 @@ } }, "node_modules/@oxc-parser/binding-openharmony-arm64": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.135.0.tgz", - "integrity": "sha512-M857ZLBSdn1Uy/SJJz5zh0qGu67B4P9omCgXGBU2LLqTzraX6ZjVNaKq5yW1PDw/LgJXDXR/dbZfgmB310f11Q==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.137.0.tgz", + "integrity": "sha512-9Uj0qHNNl+OgT1UTGwF7ixIXU6T1u2SbMidmgPy/h1h/fl2gRS6YpAxxY1gwHofcWjoTwkoMFd8xs5Vuj6GOFA==", "cpu": [ "arm64" ], @@ -5197,9 +5237,9 @@ } }, "node_modules/@oxc-parser/binding-wasm32-wasi": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.135.0.tgz", - "integrity": "sha512-2w6DVcntQZX9U5RhXtgiWb3FLWFB5EcwI1U8yr3htOCJUJjagN4BFUHz/Y/d9ZsumndZ6ByxxWEtbUZNE1bfFw==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.137.0.tgz", + "integrity": "sha512-gW2vfkytNGgMVADiuzdvOfw0mWG9za20F/1fCJsif5aBMAvWJTSbpIXbIe0XkOe0VENk+PadpQ7cZgUy2sUJcA==", "cpu": [ "wasm32" ], @@ -5207,18 +5247,71 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.5" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@oxc-parser/binding-win32-arm64-msvc": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.135.0.tgz", - "integrity": "sha512-rX1U8+IH2Z37EJjDXKa1iifvUQAdba+vZ4Ewj1iaG5eA/QaSybzclCOwtWa0/5BuUQnnK/T2JHUEFrwhL6Ck2Q==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.137.0.tgz", + "integrity": "sha512-x+pFANF0yL5uK/6T7lu6SlR5qid6sp//eZXKLq5iNsIE+EQg6EaS8/wsW7E91nXXjpnPhSoMOHXShSVhGRdn8w==", "cpu": [ "arm64" ], @@ -5233,9 +5326,9 @@ } }, "node_modules/@oxc-parser/binding-win32-ia32-msvc": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.135.0.tgz", - "integrity": "sha512-9FAisBbH1QICGAjlJobiuKGd/jOuVmyqniWdQMwTa5SkCl6hhuotBCJf1n46B0flYbSOR5TzfV9HZCWSyb3c/Q==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.137.0.tgz", + "integrity": "sha512-sQUqym80PFi6McRsIqfJrSu2JrSClEZIXXD+/FjAFoULEKzOPsldIdFBG96xdX8aVMzCNQ9792FPx3MfkEIrFA==", "cpu": [ "ia32" ], @@ -5250,9 +5343,9 @@ } }, "node_modules/@oxc-parser/binding-win32-x64-msvc": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.135.0.tgz", - "integrity": "sha512-wYF+A2AzJ2n7ul6q+Z2G/ia0S2+8cUp0AgWZzoFvF4WmUcl1P7p+o6se1Gdr5wGnWuF0iAMIkGddrjCarNr2yA==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.137.0.tgz", + "integrity": "sha512-2AsevxlvNN4WKxpEn3RtqD5zbqMaXF+T7JXblsP4gVuY+vC9dXS4ED/PwfRCliFqoeisYS3Iro4DHzxr0TEvVA==", "cpu": [ "x64" ], @@ -5277,9 +5370,9 @@ } }, "node_modules/@oxc-resolver/binding-android-arm-eabi": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.20.0.tgz", - "integrity": "sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.21.3.tgz", + "integrity": "sha512-eNU11A2WNizh04v3uyaJCootrHIaS0B9aHYXvAvVnPNk4xYSjMUjHnhQ6dewPN2MRYDskV85d1N0Aw0WNWhcyg==", "cpu": [ "arm" ], @@ -5291,9 +5384,9 @@ ] }, "node_modules/@oxc-resolver/binding-android-arm64": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.20.0.tgz", - "integrity": "sha512-QqslZAuFQG8Q9xm7JuIn8JUbvywhSBMVhuQHtYW+auirZJloS41oxUUaBXk7uUhZJgp44c5zQLeVvmFaDQB+2Q==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.21.3.tgz", + "integrity": "sha512-8Q+ZjTLvn2dIcWsrmhdrEihm7q+ag/k+mkry7Z+t0QbbHaVxXQfvH9AewyVMh/WrpEKhQ3DDgx9fYbqeCpeOEw==", "cpu": [ "arm64" ], @@ -5305,9 +5398,9 @@ ] }, "node_modules/@oxc-resolver/binding-darwin-arm64": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.20.0.tgz", - "integrity": "sha512-MUcavykj2ewlR+kc5arpg4tC2RvzJkUxWtNv74pf7lcNk00GpIpN43vXMj+j6r4eMmfZhlb8hueKoIb8e9kAGQ==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.21.3.tgz", + "integrity": "sha512-wkh0qKZGHXVUDxFw3oA1TXnU2BDYY/r775oJflGeIr8uDPPoN2pk8gijQIzYRT6hoql/lg3+Tx/SaTn9e2/aGg==", "cpu": [ "arm64" ], @@ -5319,9 +5412,9 @@ ] }, "node_modules/@oxc-resolver/binding-darwin-x64": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.20.0.tgz", - "integrity": "sha512-BGB16nRUK5Etiv//ihPyzj8Lj1px0mhh4YIfe0FDf045ywknfSm0GEbiRESpr6Q4K82AvnyaRIhhluHByvS4bg==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.21.3.tgz", + "integrity": "sha512-HbNc23FAQYbuyDV2vBWMez4u4mrsm5RAkniGZAWqr6lYZ3N4beeqIb776jzwRl8qL2zRhHVXpUj97X0QgogVzg==", "cpu": [ "x64" ], @@ -5333,9 +5426,9 @@ ] }, "node_modules/@oxc-resolver/binding-freebsd-x64": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.20.0.tgz", - "integrity": "sha512-JZgtePaqj3qmD5XFHJaSLWzHRxQu0LaPkdoM1KJXYADvAaa83ijXHclV3ej3CueeW0wxfIAbGCZVP45J0CA7uQ==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.21.3.tgz", + "integrity": "sha512-K6xNsTUPEUdfrn0+kbMq5nOUB5w1C5pavPQngt4TM2FpN91lP0PBe2srSpamb4d69O7h86oAi/qWX/kZNRSjkw==", "cpu": [ "x64" ], @@ -5347,9 +5440,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.20.0.tgz", - "integrity": "sha512-hOQ/p3ry3v3SchUBXicrrnszaI/UmYzM4wtS4RGfwgVUX7a+HbyQSzJ5aOzu+o6XZkFkS3ZXN4PZAzhOb77OSg==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.21.3.tgz", + "integrity": "sha512-VcFmOpcpWX1zoEy8M58tR2M9YxM+Z9RuQhqAx5q0CTmrruaP7Gveejg75hzd/5sg5nk9G3aLALEa3hE2FsmmTQ==", "cpu": [ "arm" ], @@ -5361,9 +5454,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.20.0.tgz", - "integrity": "sha512-2ArPksaw0AqeuGBfoS715VF+JvJQAhD2niWgjE5hVO+L+nAfikVQopvngCMX9x4BD8itWoQ3dnikrQyl5Ho5Jg==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.21.3.tgz", + "integrity": "sha512-quVoxFLBy43hWaQbbDtQNRwAX5vX76mv7n64icAtQcJ3eNgVeblqmkupF/hAneNthdqSlnd1sTjb3aQSaDPaCQ==", "cpu": [ "arm" ], @@ -5375,13 +5468,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.20.0.tgz", - "integrity": "sha512-0bJnmYFp62JdZ4nVMDUZ/C58BCZOCcqgKtnUlp7L9Ojf/czIN+3j72YlLPeWLkzlr6SlYvIQA4SGV/HyO0d+qg==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.21.3.tgz", + "integrity": "sha512-X0AqNZgcD07Q4V3RDK18/vYOj/HQT/FnmEFGYS2jTWqY7JO13ryE3TEs3eAIgUJhBnNkpEaiXqz3VK8M7qQhWQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5389,13 +5485,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm64-musl": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.20.0.tgz", - "integrity": "sha512-wKHHzPKZo7Ufhv/Bt6yxT7FOgnIgW4gwXcJUipkShGp68W3wGVqvr1Sr0fY65lN0Oy6y41+g2kIDvkgZaMMUkw==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.21.3.tgz", + "integrity": "sha512-YkaQnaKYdbuaXvRt5Qd0GpbihzVnyfR6z1SpYfIUC6RTu4NF7lDKPjVkYb+jRI2gedVO2rVpN35Y6akG6ud4Lw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5403,13 +5502,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.20.0.tgz", - "integrity": "sha512-RN8goF7Ie0B79L4i4G6OeBocTgSC56vJbQ65VJje+oXnldVpLnOU7j/AQ/dP94TcCS+Yh6WG8u3Qt4ETteXFNQ==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.21.3.tgz", + "integrity": "sha512-gB9HwhrPiFqUzDeEq+y/CgAijz1YdI6BnXz5GaH2Pa9cWdutchlkGFAiAuGb/PjVQpiK6NFKzFuztxrweoit7A==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5417,13 +5519,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.20.0.tgz", - "integrity": "sha512-5l1yU6/xQEqLZRzxqmMxJfWPslpwCmBsdDGaBvABPehxquCXDC7dd7oraNdKSJUMDXSM7VvVj8H2D2FTjU7oWw==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.21.3.tgz", + "integrity": "sha512-zjDWBlYk8QGv0H8dsPUWqkfjYIIjG2TvspGkzXL0eImbgxtZorA/klKeHyolevoT3Kvbi+1iMr9Lhrh7jf54Og==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5431,13 +5536,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.20.0.tgz", - "integrity": "sha512-xHEvkbgz6UC+A3JOyDQy76LkUaxsNSfIr3/GV8slwZsnuooJiIB34gzJfsyvR4JdCYNUUPsRJc/w/oWkODu+hg==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.21.3.tgz", + "integrity": "sha512-4UfsQvacV388y1zpXL7C1x1FNYaV52JtuNRiuzrfQA2z1z6ElVrsidkGsrvQ5EgeSq1Pj7kaKqrgGkvFuxJ/tw==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5445,13 +5553,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.20.0.tgz", - "integrity": "sha512-aWPDUUmSeyHvlW+SoEUd+JIJsQhVhu6a5tBpDRMu058naPAchTgAVGCFy35zjbnFlt0i8hLWziff6HX0D3LU4g==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.21.3.tgz", + "integrity": "sha512-b5uH+HKH0MP5mNBYaK75SKsJbw52URqrx2LavYdq6wb0l3ExAG5niYRP9DWUNHdKilpaBVM2bXk9HNWrH3ew7Q==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5459,13 +5570,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-x64-gnu": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.20.0.tgz", - "integrity": "sha512-x2YeSimvhJjKLVD8KSu8f/rqU1potcdEMkApIPJqjZWN7c2Fpt4g2X32WDg1p+XDAmyT7nuQGe0vnhvXeLbH+g==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.21.3.tgz", + "integrity": "sha512-PjYlmilBpNRh2ntXNYAK3Am5w/nPfEpnU/96iNx7CI8EzAn12J4JRiec63wHJTH31nLoCNxBg/829pN+3CfG3Q==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5473,13 +5587,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-x64-musl": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.20.0.tgz", - "integrity": "sha512-kcRLEIxpZefeYfLChjpgFf3ilBzRDZ+yobMrpRsQlSrxuFGtm3U6PMU7AaEpMqo3NfDGVyJJseAjnRLzMFHjwQ==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.21.3.tgz", + "integrity": "sha512-QTBAb7JuHlZ7JUEyM8UiQi2f7m/L4swBhP2TNpYIDc9Wp/wRw1G/8sl6i13aIzQAXH7LKIm294LeOHd0lQR8zA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5487,9 +5604,9 @@ ] }, "node_modules/@oxc-resolver/binding-openharmony-arm64": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.20.0.tgz", - "integrity": "sha512-HHcfnApSZGtKhTiHqe8OZruOZe5XuFQH5/E0Yhj3u8fnFvzkM4/k6WjacUf4SvA0SPEAbfbgYmVPuo0VX/fIBQ==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.21.3.tgz", + "integrity": "sha512-4j1DFwjwv36ec9kds0jU/ucQ5Ha4ERO/H95BxR5JFf0kqUUAJ1kwII7XhTc1vZrkdJkvLGC9Q2MbpObpum8RBg==", "cpu": [ "arm64" ], @@ -5501,9 +5618,9 @@ ] }, "node_modules/@oxc-resolver/binding-wasm32-wasi": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.20.0.tgz", - "integrity": "sha512-Tn0y1XOFYHNfK1wp1Z5QK8Rcld/bsOwRISQXfqAZ5IBpv8Gz1IvV39fUWNprqNdRizgcvFhOzWwFun2zkJsyBg==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.21.3.tgz", + "integrity": "sha512-i8oluoel5kru/j1WNrjmQSiA3GQ7wvIYVR1IwIoZtKogAhya2iub+ZKIeSIkcJOrnzQ18Tzl/F+kL3fYOxZLvA==", "cpu": [ "wasm32" ], @@ -5511,18 +5628,71 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.0", + "@emnapi/runtime": "1.11.0", + "@napi-rs/wasm-runtime": "^1.1.5" }, "engines": { "node": ">=14.0.0" } }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz", + "integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz", + "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.20.0.tgz", - "integrity": "sha512-qPi25YNPe4YenS8MgsQU2+bIFHxxpLx1LVna2444cEHqNPhNjvWf9zqj4aWE43H9LpAsTmkkAlA3eL5ElBU3mA==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.21.3.tgz", + "integrity": "sha512-M/8dw8dD6aOs+NlPJax401CZB9I7Aut84isQLgALGGwke4Afvw+/7yYhZb94yXf6t2sPLhQLmSmtSV+2FhsOWg==", "cpu": [ "arm64" ], @@ -5534,9 +5704,9 @@ ] }, "node_modules/@oxc-resolver/binding-win32-x64-msvc": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.20.0.tgz", - "integrity": "sha512-Wb14jWEW8huH6It9F6sXd9vrYmIS7pMrgkU6sxpLxkP+9z+wRgs71hUEhRpcn8FOXAFa27FVWfY2tRpbfTzfLw==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.21.3.tgz", + "integrity": "sha512-H7BCt/VnS9hnmMp42eGhZ99izSCRvlnWwy/N71K1/J8QoExwY4262Z8QiEkMDtduRJrztayDxETTckmUuAVL9Q==", "cpu": [ "x64" ], @@ -6140,19 +6310,19 @@ "license": "MIT" }, "node_modules/@radix-ui/react-accordion": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.13.tgz", - "integrity": "sha512-xITxBB2p5m5tAe7M0F95kb4uAh7jSIKGlExMEm93HlW+XxZHV2eXFbPWLktd4JhRiwcnXNbO7iekcrbZy6ZCvA==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.14.tgz", + "integrity": "sha512-iE8YB9nmTBH8zd73ofBISZ8JCzgMoMkATJr7qDwa6u5F1+7mTM81V6fa71jgZ65rpjVpecDf1vSnwIFP9Ly1zw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collapsible": "1.1.13", - "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-collapsible": "1.1.14", + "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { @@ -6171,12 +6341,12 @@ } }, "node_modules/@radix-ui/react-arrow": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.9.tgz", - "integrity": "sha512-yqHW5WQ/cTpU/un7dqqIKNy2iRU8BC0JB78PEzTfCCYvZu1U6W9KwObAniMk9nhSfyotKPQTYaUD/HB0f5muig==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.10.tgz", + "integrity": "sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.5" + "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", @@ -6194,9 +6364,9 @@ } }, "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.13.tgz", - "integrity": "sha512-F0s8+p2XNpfc3k02zBfB0jPWbkHVG162+p7BdUMyJ2308QMqZ+oaclX+FAzKFovgL5OqRU+Rvy6f/vbdlJVaqA==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.14.tgz", + "integrity": "sha512-9bT+FvifX1FK2Mj6UEsTdyu0cN3JaA3KdfhaBao+ONrYFy/pyOy3TU1TNw7iOk1o+0hOEq67RojlUUmoFGwxyA==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", @@ -6204,7 +6374,7 @@ "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, @@ -6224,15 +6394,15 @@ } }, "node_modules/@radix-ui/react-collection": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.9.tgz", - "integrity": "sha512-zuSVi7ziP7uQRqc+yGxsKJfNkdyHv3ZKDaHe0gzg4dRgws96TPKWIiz84tVHP4GEcEl8bC0mdt17NkcxaJHmaQ==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.10.tgz", + "integrity": "sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-slot": "1.2.5" + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", @@ -6280,22 +6450,22 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.16.tgz", - "integrity": "sha512-l9ok83YBclEZhbjgzt76Hw733e6cvRKPNgO6GJ/IETlufXG9p+fRu2wlvpImQvR6xdJ8h7J8J2DBvsPEiEsKMw==", + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.17.tgz", + "integrity": "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.9", + "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-portal": "1.1.11", + "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-slot": "1.2.5", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" @@ -6331,14 +6501,14 @@ } }, "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.12.tgz", - "integrity": "sha512-MhoruH6xEzsbvOmo4TNgMfmtvRGyDZw4MDSdf4ybMHfezjqwzv6hyd4lsMzBp8K9Sn6sGzCF62x1I7BYUECXOg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.13.tgz", + "integrity": "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-escape-keydown": "1.1.2" }, @@ -6373,13 +6543,13 @@ } }, "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.9.tgz", - "integrity": "sha512-9Se8t+Zry+1rEOL7Y6l/4ANYU/TOtAtf8O2fKdwLltcaMcm6kOqYGbzO4tMFQ0bvzO920pRAoHpFZ4W85S3keQ==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.10.tgz", + "integrity": "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { @@ -6416,25 +6586,25 @@ } }, "node_modules/@radix-ui/react-navigation-menu": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.15.tgz", - "integrity": "sha512-/fS8hKCcRt4DwCGa5QIB3juRXmfYSOk4a2AEe/BDIyy7Hm+eje2Y13oUx5zejl+wFt1owrM7E8NWlbaEl5EGpg==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.16.tgz", + "integrity": "sha512-nJ0SkrSQgudyYhMiYeHA1ayLVuduEJCFLan1RZZN7c9kqzzCFLaU9kuy81uNtqzweM9YaQPgWzxi9MwQ9jZ04g==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.5" + "@radix-ui/react-visually-hidden": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -6452,23 +6622,23 @@ } }, "node_modules/@radix-ui/react-popover": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.16.tgz", - "integrity": "sha512-8brVpAU5Uq7Bh0c8EFc4ZTf2JJTYn0o+1L+CUJB3UYIOkTjKGMgoHvduylrahdmNlr3DfH0rFq2DrbNZXgaspw==", + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.17.tgz", + "integrity": "sha512-/YSAOdJ7YJvdn7bn5sdSx2egW+SKY+u7O5RyAVs94Ymrg2fg5QTSFPMRkzvhGyFuE4/qsmPBdrwYoZMZh/4f+g==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.9", + "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.0", - "@radix-ui/react-portal": "1.1.11", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-slot": "1.2.5", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" @@ -6489,16 +6659,16 @@ } }, "node_modules/@radix-ui/react-popper": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.0.tgz", - "integrity": "sha512-9PB589e1aWZbrlFUHdz6WiPCL+xLZHQFX7oibqG/6Q0SwOkxDyQX9W/cyPa+sAPPKuC8cpLCpRczE5a/1DiwVQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.1.tgz", + "integrity": "sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw==", "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.9", + "@radix-ui/react-arrow": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-rect": "1.1.2", @@ -6521,12 +6691,12 @@ } }, "node_modules/@radix-ui/react-portal": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.11.tgz", - "integrity": "sha512-UEytdjgEh2tJGgD/gZK4FUx6t1rNIlM3U0DENhSrG7I75FGm1DnaDuVUWF1pWAWUwGmn1sCJ1VGHn8LhN1aTOw==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.12.tgz", + "integrity": "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { @@ -6568,12 +6738,12 @@ } }, "node_modules/@radix-ui/react-primitive": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.5.tgz", - "integrity": "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.5" + "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", @@ -6591,18 +6761,18 @@ } }, "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.12.tgz", - "integrity": "sha512-FvgPt1bRmg8Xt2QpF7NUZW3dE0ZQHGm41dAdgT2J2GJPoIXz+9Em3NobAxf4fupcxhgHu03E5CRiU2MWvObXyg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.13.tgz", + "integrity": "sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3" }, @@ -6622,9 +6792,9 @@ } }, "node_modules/@radix-ui/react-scroll-area": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.11.tgz", - "integrity": "sha512-DS39ziOgea75U/TrXKU2/oKp0be2jrDHnzFLvahg/0iNAT1Zq16e4Uw0WXwyXvsK+mG3BRyMb7A3NRZMDuEXtQ==", + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.12.tgz", + "integrity": "sha512-xuafVzQiTCLsyEjakowTdG3OgTXsmO7IdCiO77otIa+z44xoLNs9Do5eg7POFumIOCjtG6djfm6RKUKpUa/csA==", "license": "MIT", "dependencies": { "@radix-ui/number": "1.1.2", @@ -6633,7 +6803,7 @@ "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2" }, @@ -6653,9 +6823,9 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.5.tgz", - "integrity": "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", + "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" @@ -6671,9 +6841,9 @@ } }, "node_modules/@radix-ui/react-tabs": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.14.tgz", - "integrity": "sha512-D5jwp9JNuwDeCw3CYD2Fz+sSHo0droQjC8u75dJHe4aWr5q6yBiXZU+hurXnKudRgEpUkD5TsI6bjHPo5ThUxA==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.15.tgz", + "integrity": "sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", @@ -6681,8 +6851,8 @@ "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-roving-focus": "1.1.12", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { @@ -6837,12 +7007,12 @@ } }, "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.5.tgz", - "integrity": "sha512-tPcHNI3FajdDBFpl/Ez1m2WL0ufJqBKyHxMDBvKitopamK36WwBGOMicuMEZKkM5Wce41QxUyv6BsiqfrWBiGg==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.6.tgz", + "integrity": "sha512-jCE0WljWifTI4niIMCll06kGpsJTAPiZVU9H4WR1N6qW7At9ystHbN7dDB+we2xH535roFHj7qKS+RGj0FMDWQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.5" + "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", @@ -9240,12 +9410,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.9.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", - "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz", + "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", "license": "MIT", "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" + "undici-types": "~8.3.0" } }, "node_modules/@types/node-fetch": { @@ -10069,12 +10239,12 @@ "optional": true }, "node_modules/@xyflow/react": { - "version": "12.11.0", - "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.0.tgz", - "integrity": "sha512-na4IO33FSs2OS72hASgZDmTYwFAkef7Z74uBUVrong3ARmQQHfnRUVaCFn1kTt5LbS6pK03TbYjCPGLjLFfziA==", + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.1.tgz", + "integrity": "sha512-L+zBoLGSXham0MnlY8QqjfR7/C5JNw0zxkaey5aZ5XmCgJBAdH4+WRIu8CR40d3l/BdU635V6YbhBK1jMo8/6Q==", "license": "MIT", "dependencies": { - "@xyflow/system": "0.0.77", + "@xyflow/system": "0.0.78", "classcat": "^5.0.3", "zustand": "^4.4.0" }, @@ -10122,9 +10292,9 @@ } }, "node_modules/@xyflow/system": { - "version": "0.0.77", - "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.77.tgz", - "integrity": "sha512-qCDCMCQAAgUu8yHnhloHG9F5mwPX5E+Wl8McpYIOPSSXfzFJJoZcwOcsDiAjitVKIg2de1WmJbCHfpcvxprsgg==", + "version": "0.0.78", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.78.tgz", + "integrity": "sha512-lY0z2qP33fUhTva9Vaxrk0lqZta2pkbxB1trHAx1omnJqRtPvDlAQYV2r5fhS6AdpkulYmbNW0svy+A4/t4B/g==", "license": "MIT", "dependencies": { "@types/d3-drag": "^3.0.7", @@ -14271,9 +14441,9 @@ } }, "node_modules/eslint-plugin-sonarjs": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-4.0.3.tgz", - "integrity": "sha512-5drkJKLC9qQddIiaATV0e8+ygbUc7b0Ti6VB7M2d3jmKNh3X0RaiIJYTs3dr9xnlhlrxo+/s1FoO3Jgv6O/c7g==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-4.1.0.tgz", + "integrity": "sha512-rh+FlVz0yfd2RNIb6WqSkuGh0addX/Qi5scwQ5FphXDFrM6fZKcxP1+attJ78yUKcyYfiu6MTaISPpAFPzqRJw==", "dev": true, "license": "LGPL-3.0-only", "dependencies": { @@ -14281,14 +14451,15 @@ "builtin-modules": "^3.3.0", "bytes": "^3.1.2", "functional-red-black-tree": "^1.0.1", - "globals": "^17.4.0", + "globals": "^17.6.0", "jsx-ast-utils-x": "^0.1.0", "lodash.merge": "^4.6.2", "minimatch": "^10.2.5", "scslre": "^0.3.0", - "semver": "^7.7.4", + "semver": "^7.8.4", "ts-api-utils": "^2.5.0", - "typescript": ">=5" + "typescript": ">=5", + "yaml": "^2.9.0" }, "peerDependencies": { "eslint": "^8.0.0 || ^9.0.0 || ^10.0.0" @@ -15345,9 +15516,9 @@ } }, "node_modules/fumadocs-core": { - "version": "16.10.3", - "resolved": "https://registry.npmjs.org/fumadocs-core/-/fumadocs-core-16.10.3.tgz", - "integrity": "sha512-xXhqz/fqbN7pLlshJb/B5L+vzMJOmWxoPj7+KMRTa/4A669hKeeCBPpRAiooMqjblWqIRSxLiO02/ds8ltvUPQ==", + "version": "16.10.5", + "resolved": "https://registry.npmjs.org/fumadocs-core/-/fumadocs-core-16.10.5.tgz", + "integrity": "sha512-e/xrZnKvQo8bF/WYMwPuym8PR3OtjZzHy0S/EIOvGwjKRgVq9z6J58zaBpi4LvYtPVZxNGsxdZVlmZXCVWq4FQ==", "license": "MIT", "dependencies": { "@orama/orama": "^3.1.18", @@ -15384,7 +15555,7 @@ "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-router": "7.x.x", + "react-router": "7.x.x || 8.x.x", "waku": "*", "zod": "4.x.x" }, @@ -15445,6 +15616,28 @@ } } }, + "node_modules/fumadocs-core/node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/fumadocs-mdx": { "version": "15.0.12", "resolved": "https://registry.npmjs.org/fumadocs-mdx/-/fumadocs-mdx-15.0.12.tgz", @@ -15509,26 +15702,48 @@ } } }, + "node_modules/fumadocs-mdx/node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/fumadocs-ui": { - "version": "16.10.3", - "resolved": "https://registry.npmjs.org/fumadocs-ui/-/fumadocs-ui-16.10.3.tgz", - "integrity": "sha512-0aSLdQ73EWoCmYcQYr2uNHlSB/s2fD+NMugtdZF3vC4lqs0MfyOtwnZPyYAskUnXNs6HECly/Hu6oY5JqmlkHg==", + "version": "16.10.5", + "resolved": "https://registry.npmjs.org/fumadocs-ui/-/fumadocs-ui-16.10.5.tgz", + "integrity": "sha512-vd69ckYx/4a1aoJTCUJ5LBkqNeOFxm3r+8SK9bVYaeHJrY/n8+4W6b0soqxVqgj1UwNmgovoAg0vlsYmSxZBgQ==", "license": "MIT", "dependencies": { "@fuma-translate/react": "^1.0.2", "@fumadocs/tailwind": "0.0.5", - "@radix-ui/react-accordion": "^1.2.13", - "@radix-ui/react-collapsible": "^1.1.13", - "@radix-ui/react-dialog": "^1.1.16", + "@radix-ui/react-accordion": "^1.2.14", + "@radix-ui/react-collapsible": "^1.1.14", + "@radix-ui/react-dialog": "^1.1.17", "@radix-ui/react-direction": "^1.1.2", - "@radix-ui/react-navigation-menu": "^1.2.15", - "@radix-ui/react-popover": "^1.1.16", + "@radix-ui/react-navigation-menu": "^1.2.16", + "@radix-ui/react-popover": "^1.1.17", "@radix-ui/react-presence": "^1.1.6", - "@radix-ui/react-scroll-area": "^1.2.11", - "@radix-ui/react-slot": "^1.2.5", - "@radix-ui/react-tabs": "^1.1.14", + "@radix-ui/react-scroll-area": "^1.2.12", + "@radix-ui/react-slot": "^1.3.0", + "@radix-ui/react-tabs": "^1.1.15", "class-variance-authority": "^0.7.1", - "lucide-react": "^1.17.0", + "lucide-react": "^1.20.0", "motion": "^12.40.0", "next-themes": "^0.4.6", "react-remove-scroll": "^2.7.2", @@ -15542,7 +15757,7 @@ "@takumi-rs/image-response": "*", "@types/mdx": "*", "@types/react": "*", - "fumadocs-core": "16.10.3", + "fumadocs-core": "16.10.5", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0" @@ -17870,9 +18085,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.0.0.tgz", + "integrity": "sha512-GSvaPUbk1U+FMZ7rJzF+F8e5YVtu7KnD40et/5rBXXRBv2jCO9L3qCewvIDDdudC0QycTFlf6EAA+h3kxBsuUw==", "funding": [ { "type": "github", @@ -17888,7 +18103,7 @@ "argparse": "^2.0.1" }, "bin": { - "js-yaml": "bin/js-yaml.js" + "js-yaml": "bin/js-yaml.mjs" } }, "node_modules/jscpd": { @@ -18205,9 +18420,9 @@ "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" }, "node_modules/knip": { - "version": "6.17.1", - "resolved": "https://registry.npmjs.org/knip/-/knip-6.17.1.tgz", - "integrity": "sha512-HcQsZSQ4Ymhuay4BVzJtM5pFZNDSomYYqcNCZOSITPQh9g18a09DqziWAxSt2G+BH9wGlG+0ZjWpEnaFlnKseQ==", + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/knip/-/knip-6.18.0.tgz", + "integrity": "sha512-RlT6fK3epETsEUCVdlz96CiJN3DQJwuxOuQhEeAVWjNRuAUJHWwe+XolTL8vIiwLPZ38tTdPWJUgZyTdskOEog==", "dev": true, "funding": [ { @@ -18225,8 +18440,8 @@ "formatly": "^0.3.0", "get-tsconfig": "4.14.0", "jiti": "^2.7.0", - "oxc-parser": "^0.135.0", - "oxc-resolver": "^11.20.0", + "oxc-parser": "^0.137.0", + "oxc-resolver": "11.21.3", "picomatch": "^4.0.4", "smol-toml": "^1.6.1", "strip-json-comments": "5.0.3", @@ -19034,9 +19249,9 @@ "license": "MIT" }, "node_modules/lint-staged": { - "version": "17.0.7", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.7.tgz", - "integrity": "sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA==", + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.8.tgz", + "integrity": "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA==", "dev": true, "license": "MIT", "dependencies": { @@ -19198,6 +19413,29 @@ "node": ">= 6" } }, + "node_modules/lockfile-lint/node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -19390,9 +19628,9 @@ } }, "node_modules/lucide-react": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.20.0.tgz", - "integrity": "sha512-jhXLeC/7m0/tjL1nzMdKk6x256zWA6AtbhTVreHOiKPoeX2d6MK4FbyIQPpVq0E6iPWBisyy1TW+pEge/uMEuQ==", + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz", + "integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -19553,9 +19791,9 @@ } }, "node_modules/material-symbols": { - "version": "0.45.1", - "resolved": "https://registry.npmjs.org/material-symbols/-/material-symbols-0.45.1.tgz", - "integrity": "sha512-iQibeoKymxHthNqTjYg/jCN2pRySFQ+DAD0sYK9FS7rUpf38i1qR3N4I1jfi/bLS5qdEKvkkJAvf2mE89amSZw==", + "version": "0.45.2", + "resolved": "https://registry.npmjs.org/material-symbols/-/material-symbols-0.45.2.tgz", + "integrity": "sha512-TxcmySpXFc+03tgyl0Z6J1Yvn5/eY9b9vnizJu5Xg3RrrL5jFLp3KlZKq5k2ttKj3SqH/omofH7BwZMO/zk1mw==", "license": "Apache-2.0" }, "node_modules/math-intrinsics": { @@ -22068,9 +22306,9 @@ } }, "node_modules/ora": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-9.4.0.tgz", - "integrity": "sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==", + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.4.1.tgz", + "integrity": "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw==", "license": "MIT", "dependencies": { "chalk": "^5.6.2", @@ -22120,13 +22358,13 @@ } }, "node_modules/oxc-parser": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.135.0.tgz", - "integrity": "sha512-/DaPStu0s2zzNSRRniKyTPM6Z/o+DapOp2JYNKDL8AsgaBGPK2IdZyB87SQjVH+xeQPz+Qr9mrjglfkYgtbVRA==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.137.0.tgz", + "integrity": "sha512-yFImD+WLElJpLKy8llG1qe4DCmMsL18peRp8XP1JKfig/gISbJkglnpDtX2aTmAn10kZF7164HbN2H8QPsXxGg==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "^0.135.0" + "@oxc-project/types": "^0.137.0" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -22135,32 +22373,32 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxc-parser/binding-android-arm-eabi": "0.135.0", - "@oxc-parser/binding-android-arm64": "0.135.0", - "@oxc-parser/binding-darwin-arm64": "0.135.0", - "@oxc-parser/binding-darwin-x64": "0.135.0", - "@oxc-parser/binding-freebsd-x64": "0.135.0", - "@oxc-parser/binding-linux-arm-gnueabihf": "0.135.0", - "@oxc-parser/binding-linux-arm-musleabihf": "0.135.0", - "@oxc-parser/binding-linux-arm64-gnu": "0.135.0", - "@oxc-parser/binding-linux-arm64-musl": "0.135.0", - "@oxc-parser/binding-linux-ppc64-gnu": "0.135.0", - "@oxc-parser/binding-linux-riscv64-gnu": "0.135.0", - "@oxc-parser/binding-linux-riscv64-musl": "0.135.0", - "@oxc-parser/binding-linux-s390x-gnu": "0.135.0", - "@oxc-parser/binding-linux-x64-gnu": "0.135.0", - "@oxc-parser/binding-linux-x64-musl": "0.135.0", - "@oxc-parser/binding-openharmony-arm64": "0.135.0", - "@oxc-parser/binding-wasm32-wasi": "0.135.0", - "@oxc-parser/binding-win32-arm64-msvc": "0.135.0", - "@oxc-parser/binding-win32-ia32-msvc": "0.135.0", - "@oxc-parser/binding-win32-x64-msvc": "0.135.0" + "@oxc-parser/binding-android-arm-eabi": "0.137.0", + "@oxc-parser/binding-android-arm64": "0.137.0", + "@oxc-parser/binding-darwin-arm64": "0.137.0", + "@oxc-parser/binding-darwin-x64": "0.137.0", + "@oxc-parser/binding-freebsd-x64": "0.137.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.137.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.137.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.137.0", + "@oxc-parser/binding-linux-arm64-musl": "0.137.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.137.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.137.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.137.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.137.0", + "@oxc-parser/binding-linux-x64-gnu": "0.137.0", + "@oxc-parser/binding-linux-x64-musl": "0.137.0", + "@oxc-parser/binding-openharmony-arm64": "0.137.0", + "@oxc-parser/binding-wasm32-wasi": "0.137.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.137.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.137.0", + "@oxc-parser/binding-win32-x64-msvc": "0.137.0" } }, "node_modules/oxc-parser/node_modules/@oxc-project/types": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.135.0.tgz", - "integrity": "sha512-wR+xRdFkUBMvcAjBJ2q2kcZM6d+DKu2NgoOyxZgYwZdLhmiv6+rnO8PZ/P68kMiZtIKm+pW7zyEJ4kSOs0vo+Q==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", "dev": true, "license": "MIT", "funding": { @@ -22168,34 +22406,34 @@ } }, "node_modules/oxc-resolver": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.20.0.tgz", - "integrity": "sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.21.3.tgz", + "integrity": "sha512-2Mx3fKQz7+xgrBONjsxOgCGtMHOn38/HxMzW1I5efwXB5a4lRN0Vp40gYUJFBWJslcrvwoofTrqoTnLbwTd3pA==", "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxc-resolver/binding-android-arm-eabi": "11.20.0", - "@oxc-resolver/binding-android-arm64": "11.20.0", - "@oxc-resolver/binding-darwin-arm64": "11.20.0", - "@oxc-resolver/binding-darwin-x64": "11.20.0", - "@oxc-resolver/binding-freebsd-x64": "11.20.0", - "@oxc-resolver/binding-linux-arm-gnueabihf": "11.20.0", - "@oxc-resolver/binding-linux-arm-musleabihf": "11.20.0", - "@oxc-resolver/binding-linux-arm64-gnu": "11.20.0", - "@oxc-resolver/binding-linux-arm64-musl": "11.20.0", - "@oxc-resolver/binding-linux-ppc64-gnu": "11.20.0", - "@oxc-resolver/binding-linux-riscv64-gnu": "11.20.0", - "@oxc-resolver/binding-linux-riscv64-musl": "11.20.0", - "@oxc-resolver/binding-linux-s390x-gnu": "11.20.0", - "@oxc-resolver/binding-linux-x64-gnu": "11.20.0", - "@oxc-resolver/binding-linux-x64-musl": "11.20.0", - "@oxc-resolver/binding-openharmony-arm64": "11.20.0", - "@oxc-resolver/binding-wasm32-wasi": "11.20.0", - "@oxc-resolver/binding-win32-arm64-msvc": "11.20.0", - "@oxc-resolver/binding-win32-x64-msvc": "11.20.0" + "@oxc-resolver/binding-android-arm-eabi": "11.21.3", + "@oxc-resolver/binding-android-arm64": "11.21.3", + "@oxc-resolver/binding-darwin-arm64": "11.21.3", + "@oxc-resolver/binding-darwin-x64": "11.21.3", + "@oxc-resolver/binding-freebsd-x64": "11.21.3", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.21.3", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.21.3", + "@oxc-resolver/binding-linux-arm64-gnu": "11.21.3", + "@oxc-resolver/binding-linux-arm64-musl": "11.21.3", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.21.3", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.21.3", + "@oxc-resolver/binding-linux-riscv64-musl": "11.21.3", + "@oxc-resolver/binding-linux-s390x-gnu": "11.21.3", + "@oxc-resolver/binding-linux-x64-gnu": "11.21.3", + "@oxc-resolver/binding-linux-x64-musl": "11.21.3", + "@oxc-resolver/binding-openharmony-arm64": "11.21.3", + "@oxc-resolver/binding-wasm32-wasi": "11.21.3", + "@oxc-resolver/binding-win32-arm64-msvc": "11.21.3", + "@oxc-resolver/binding-win32-x64-msvc": "11.21.3" } }, "node_modules/p-limit": { @@ -26746,9 +26984,9 @@ } }, "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "license": "MIT" }, "node_modules/unicode-emoji-modifier-base": { @@ -27996,6 +28234,29 @@ "node": ">=20.0" } }, + "node_modules/xmlbuilder2/node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", @@ -28241,7 +28502,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.8.33" + "version": "3.8.34" } } } diff --git a/package.json b/package.json index cebadbb6d5..a27020b7bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.8.33", + "version": "3.8.34", "description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { @@ -145,6 +145,7 @@ "check:complexity": "node scripts/check/check-complexity.mjs", "check:dead-code": "node scripts/check/check-dead-code.mjs", "check:cognitive-complexity": "node scripts/check/check-cognitive-complexity.mjs", + "check:release-green": "node scripts/quality/validate-release-green.mjs", "check:type-coverage": "node scripts/check/check-type-coverage.mjs", "check:lockfile": "node scripts/check/check-lockfile.mjs", "check:bundle-size": "node scripts/check/check-bundle-size.mjs", @@ -194,7 +195,7 @@ "build:cli-api": "node --import tsx/esm scripts/cli/generate-api-commands.mjs" }, "dependencies": { - "@aws-sdk/client-bedrock-runtime": "^3.1045.0", + "@aws-sdk/client-bedrock-runtime": "^3.1073.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -205,7 +206,7 @@ "@ngrok/ngrok": "^1.7.0", "@swc/helpers": "0.5.23", "@types/mdx": "^2.0.13", - "@xyflow/react": "^12.10.2", + "@xyflow/react": "^12.11.1", "axios": "^1.16.1", "bcryptjs": "^3.0.3", "bottleneck": "^2.19.5", @@ -215,9 +216,9 @@ "express": "^5.2.1", "fetch-socks": "^1.3.3", "fflate": "^0.8.3", - "fumadocs-core": "^16.9.0", + "fumadocs-core": "^16.10.5", "fumadocs-mdx": "^15.0.7", - "fumadocs-ui": "^16.9.0", + "fumadocs-ui": "^16.10.5", "http-proxy-middleware": "^4.0.0", "https-proxy-agent": "^9.0.0", "ink": "^7.0.3", @@ -225,13 +226,13 @@ "ink-text-input": "^6.0.0", "ioredis": "^5.10.1", "jose": "^6.2.3", - "js-yaml": "^4.1.1", + "js-yaml": "^5.0.0", "jsonc-parser": "^3.3.1", "lowdb": "^7.0.1", - "lucide-react": "^1.16.0", + "lucide-react": "^1.21.0", "marked": "^18.0.4", "marked-terminal": "^7.3.0", - "material-symbols": "^0.45.1", + "material-symbols": "^0.45.2", "mermaid": "^11.15.0", "monaco-editor": "^0.55.1", "next": "^16.2.6", @@ -239,7 +240,7 @@ "next-themes": "^0.4.6", "node-machine-id": "^1.1.12", "open": "^11.0.0", - "ora": "^9.4.0", + "ora": "^9.4.1", "parse5": "^8.0.1", "pino": "^10.3.1", "pino-abstract-transport": "^3.0.0", @@ -289,7 +290,7 @@ "@types/better-sqlite3": "^7.6.13", "@types/bun": "latest", "@types/keytar": "^4.4.2", - "@types/node": "^25.9.1", + "@types/node": "^26.0.0", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "@types/ws": "^8.18.0", @@ -300,15 +301,15 @@ "dpdm": "^4.2.0", "eslint": "^9.39.4", "eslint-config-next": "16.2.9", - "eslint-plugin-sonarjs": "^4.0.3", + "eslint-plugin-sonarjs": "^4.1.0", "fast-check": "^4.8.0", "glob": "^13.0.6", "husky": "^9.1.7", "jscpd": "^4.2.5", "jsdom": "^29.1.1", - "knip": "^6.16.1", + "knip": "^6.18.0", "license-checker-rseidelsohn": "^5.0.1", - "lint-staged": "^17.0.5", + "lint-staged": "^17.0.8", "lockfile-lint": "^5.0.0", "node-loader": "^2.1.0", "prettier": "^3.8.3", diff --git a/scripts/check/check-fabricated-docs.mjs b/scripts/check/check-fabricated-docs.mjs index 7a4a867a07..02ba62ac33 100644 --- a/scripts/check/check-fabricated-docs.mjs +++ b/scripts/check/check-fabricated-docs.mjs @@ -131,6 +131,7 @@ const ENV_VAR_DENYLIST = new Set([ "REPOSITORY_MAP", "AUTHZ_GUIDE", "RESILIENCE_GUIDE", + "COMPRESSION_GUIDE", "MCP_SERVER", "MCP_AUDIT", "MCP_TOOLS", diff --git a/scripts/check/check-openapi-coverage.mjs b/scripts/check/check-openapi-coverage.mjs index 57cf6651d6..0476107ea8 100644 --- a/scripts/check/check-openapi-coverage.mjs +++ b/scripts/check/check-openapi-coverage.mjs @@ -9,7 +9,7 @@ import fs from "node:fs"; import path from "node:path"; -import yaml from "js-yaml"; +import * as yaml from "js-yaml"; const ROOT = process.cwd(); const API_ROOT = path.join(ROOT, "src", "app", "api"); diff --git a/scripts/check/check-openapi-routes.mjs b/scripts/check/check-openapi-routes.mjs index bb19af19b4..7f8b8b4923 100644 --- a/scripts/check/check-openapi-routes.mjs +++ b/scripts/check/check-openapi-routes.mjs @@ -9,7 +9,7 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import yaml from "js-yaml"; +import * as yaml from "js-yaml"; import { assertNoStale } from "./lib/allowlist.mjs"; const ROOT = process.cwd(); diff --git a/scripts/check/check-openapi-security-tiers.mjs b/scripts/check/check-openapi-security-tiers.mjs index 874dfed78e..c2acfd6d23 100644 --- a/scripts/check/check-openapi-security-tiers.mjs +++ b/scripts/check/check-openapi-security-tiers.mjs @@ -8,7 +8,7 @@ import fs from "node:fs"; import path from "node:path"; -import yaml from "js-yaml"; +import * as yaml from "js-yaml"; const ROOT = process.cwd(); const OPENAPI_PATH = path.join(ROOT, "docs", "reference", "openapi.yaml"); diff --git a/scripts/check/check-public-creds.mjs b/scripts/check/check-public-creds.mjs index b6cc521db6..f73c4a296c 100644 --- a/scripts/check/check-public-creds.mjs +++ b/scripts/check/check-public-creds.mjs @@ -81,15 +81,15 @@ const ENV_KEY_RE = /(clientId|clientSecret|apiKey)Env\s*:/; // // 6A.8: Expanded scope to open-sse/** + src/lib/oauth/**. Newly discovered FPs: // -// open-sse/services/usage.ts L582: `getMiniMaxUsage(apiKey: string, provider: "minimax" | "minimax-cn")` +// open-sse/services/usage.ts L499: `getMiniMaxUsage(apiKey: string, provider: "minimax" | "minimax-cn")` // The CRED_KEY_RE matches `apiKey:` in the TypeScript function-parameter type annotation. // "minimax" and "minimax-cn" are provider-name strings in the type annotation, NOT credentials. // This is a false positive (the gate was designed for object-literal assignments, not fn params). // TODO(6A.8): Consider tightening CRED_KEY_RE to exclude function-signature contexts — but // that adds complexity; the FP rate is low (1 file). Frozen by file:line:value key. export const KNOWN_LITERAL_CREDS = new Set([ - "open-sse/services/usage.ts:582:minimax", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (moved 543→547 by #3838/#4293, then 547→582 by the v3.8.33 usage.ts growth) - "open-sse/services/usage.ts:582:minimax-cn", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (moved 543→547 by #3838/#4293, then 547→582 by the v3.8.33 usage.ts growth) + "open-sse/services/usage.ts:499:minimax", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (moved 582→499 by the OpenCode/Ollama usage extraction) + "open-sse/services/usage.ts:499:minimax-cn", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (moved 582→499 by the OpenCode/Ollama usage extraction) ]); /** diff --git a/scripts/cli/generate-api-commands.mjs b/scripts/cli/generate-api-commands.mjs index 43bd10efad..ef4ce7d3a3 100644 --- a/scripts/cli/generate-api-commands.mjs +++ b/scripts/cli/generate-api-commands.mjs @@ -6,7 +6,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import yaml from "js-yaml"; +import * as yaml from "js-yaml"; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, "..", ".."); diff --git a/scripts/docs/gen-openapi-module.mjs b/scripts/docs/gen-openapi-module.mjs index 9bccdfd635..785ee055c2 100644 --- a/scripts/docs/gen-openapi-module.mjs +++ b/scripts/docs/gen-openapi-module.mjs @@ -18,7 +18,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import yaml from "js-yaml"; +import * as yaml from "js-yaml"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); diff --git a/scripts/quality/collect-metrics.mjs b/scripts/quality/collect-metrics.mjs index 2807409831..7baa98fb65 100644 --- a/scripts/quality/collect-metrics.mjs +++ b/scripts/quality/collect-metrics.mjs @@ -10,7 +10,7 @@ import { promises as fsAsync } from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { execFileSync } from "node:child_process"; -import yaml from "js-yaml"; +import * as yaml from "js-yaml"; const cwd = process.cwd(); const out = {}; diff --git a/scripts/quality/validate-release-green.mjs b/scripts/quality/validate-release-green.mjs new file mode 100644 index 0000000000..477cacea4c --- /dev/null +++ b/scripts/quality/validate-release-green.mjs @@ -0,0 +1,250 @@ +#!/usr/bin/env node +// scripts/quality/validate-release-green.mjs +// +// "Release-green" pre-flight validator (Solution C). +// +// WHY: the full gate (ci.yml — unit shards, vitest, ratchets, package-artifact) +// runs ONLY on the release PR (PR → main). PRs into release/** only get the +// fast-gates (quality.yml: TIA-impacted tests + typecheck + lint checks). So +// reds accumulate silently on the release branch and explode — in layers — at +// release time. This script reproduces the release-equivalent validation against +// the CURRENT working tree so the maintainer (or the nightly, Solution D) can see +// the real state of the release branch at any time. +// +// DESIGN — never blocking to contributors: +// • HARD checks (typecheck, lint errors, unit, vitest, db-rules, public-creds, +// optionally package-artifact) → a failure here is a real defect; exit 1. +// • DRIFT checks (eslint WARNINGS, cognitive-complexity, file-size) → ratchet +// drift accrued across the cycle is NOT a contributor's fault; it is reported +// and rebaselined by the maintainer at release. Drift NEVER changes the exit +// code, so wiring this as a check can never block anyone on drift. +// +// This script DIAGNOSES + REPORTS only (no auto-fix). The fix-to-green +// orchestration lives in the (future) /green-prs + review-prs flows that call it. +// +// Usage: +// node scripts/quality/validate-release-green.mjs [--json] [--with-build] [--quick] +// --json emit machine-readable JSON to stdout (report goes to stderr) +// --with-build also run check:pack-artifact (needs a dist/ build — slow) +// --quick skip the slow unit + vitest suites (drift + typecheck + lint only) + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, "..", ".."); +const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"; + +// ─── Pure helpers (exported for tests) ────────────────────────────────────── + +/** Read the committed ratchet baseline value for a metric (null if unknown). */ +export function baselineValue(metric, root = ROOT) { + try { + const raw = JSON.parse(readFileSync(join(root, "config/quality/quality-baseline.json"), "utf8")); + const metrics = raw.metrics || raw; + const v = metrics?.[metric]?.value; + return typeof v === "number" ? v : null; + } catch { + return null; + } +} + +/** Best-effort "first meaningful failure line" from captured command output. */ +export function firstFailureLine(out) { + const lines = String(out || "") + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + const hit = lines.find((l) => /✖|not ok|AssertionError|error TS|FAIL|Error:|REGRESS/i.test(l)); + return (hit || lines[lines.length - 1] || "failed").slice(0, 200); +} + +/** Sum {errorCount,warningCount} across an eslint --format json result array. */ +export function eslintCounts(parsed) { + let errors = 0; + let warnings = 0; + for (const f of parsed || []) { + errors += f.errorCount || 0; + warnings += f.warningCount || 0; + } + return { errors, warnings }; +} + +/** Parse the eslint JSON array out of mixed stdout (tolerates a leading banner). */ +export function parseEslintJson(out) { + const start = String(out || "").indexOf("["); + if (start < 0) return null; + try { + return JSON.parse(String(out).slice(start)); + } catch { + return null; + } +} + +/** Pull the cognitive-complexity violation count from the gate's output. */ +export function parseCognitiveCount(out) { + const m = String(out || "").match(/(\d+)\s+(?:function\(s\) exceed|violações|violations)/i); + return m ? Number(m[1]) : null; +} + +/** + * Drift verdict for a ratchet: a metric that grew past its committed baseline is + * "drift" (reported, never blocking). `direction:"down"` metrics (warnings, + * complexity, file-size counts) regress when current > baseline. + */ +export function isDrift(current, baseline) { + if (typeof current !== "number" || typeof baseline !== "number") return false; + return current > baseline; +} + +/** releaseGreen iff there are zero failing HARD checks (drift never blocks). */ +export function computeVerdict(results) { + const hardFailures = results.filter((r) => r.kind === "hard" && !r.ok); + const drift = results.filter((r) => r.kind === "drift" && !r.ok); + return { releaseGreen: hardFailures.length === 0, hardFailures, drift }; +} + +// ─── Orchestration (only when run directly) ───────────────────────────────── + +function run(cmd, cmdArgs) { + try { + const out = execFileSync(cmd, cmdArgs, { + cwd: ROOT, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: 256 * 1024 * 1024, + env: { ...process.env, FORCE_COLOR: "0" }, + }); + return { code: 0, out }; + } catch (err) { + return { + code: typeof err.status === "number" ? err.status : 1, + out: `${err.stdout || ""}${err.stderr || ""}`, + }; + } +} + +function main() { + const args = new Set(process.argv.slice(2)); + const JSON_OUT = args.has("--json"); + const WITH_BUILD = args.has("--with-build"); + const QUICK = args.has("--quick"); + + const results = []; + const record = (r) => { + results.push(r); + const icon = r.ok ? "✅" : r.kind === "drift" ? "🟡" : "❌"; + process.stderr.write(`${icon} [${r.kind}] ${r.label}${r.detail ? ` — ${r.detail}` : ""}\n`); + }; + + const hardCmd = (id, label, cmd, cmdArgs) => { + const { code, out } = run(cmd, cmdArgs); + record({ id, label, kind: "hard", ok: code === 0, detail: code === 0 ? "pass" : firstFailureLine(out) }); + }; + + process.stderr.write("🔎 Release-green validation (current working tree)\n\n"); + + hardCmd("typecheck", "Typecheck (core)", npmCmd, ["run", "typecheck:core"]); + + // ESLint: ONE pass → errors (hard) + warnings (drift) + { + const { out } = run("npx", ["eslint", ".", "--format", "json"]); + const parsed = parseEslintJson(out); + if (!parsed) { + record({ id: "lint", label: "ESLint", kind: "hard", ok: false, detail: "could not parse eslint json" }); + } else { + const { errors, warnings } = eslintCounts(parsed); + record({ id: "lint-errors", label: "ESLint errors", kind: "hard", ok: errors === 0, detail: `${errors} error(s)` }); + const base = baselineValue("eslintWarnings"); + const over = isDrift(warnings, base); + record({ + id: "eslint-warnings", + label: "ESLint warnings (ratchet)", + kind: "drift", + ok: !over, + detail: + base == null + ? `${warnings} (no baseline)` + : `${warnings} vs baseline ${base}${over ? ` (+${warnings - base} drift → rebaseline at release)` : ""}`, + }); + } + } + + hardCmd("db-rules", "DB rules", npmCmd, ["run", "check:db-rules"]); + hardCmd("public-creds", "Public creds", npmCmd, ["run", "check:public-creds"]); + + // Cognitive-complexity (drift) + { + const { out } = run(npmCmd, ["run", "check:cognitive-complexity"]); + const current = parseCognitiveCount(out); + const base = baselineValue("cognitiveComplexity"); + const over = isDrift(current, base); + record({ + id: "cognitive-complexity", + label: "Cognitive complexity (ratchet)", + kind: "drift", + ok: !over, + detail: + current == null + ? "could not parse count" + : `${current} vs baseline ${base}${over ? ` (+${current - base} drift → rebaseline at release)` : ""}`, + }); + } + + // file-size (drift) + { + const { code, out } = run(npmCmd, ["run", "check:file-size"]); + record({ + id: "file-size", + label: "File-size ratchet", + kind: "drift", + ok: code === 0, + detail: code === 0 ? "within frozen caps" : firstFailureLine(out), + }); + } + + if (!QUICK) { + hardCmd("unit", "Unit tests (full, CI concurrency)", npmCmd, ["run", "test:unit:ci"]); + hardCmd("vitest", "Vitest (MCP / autoCombo / cache)", npmCmd, ["run", "test:vitest"]); + } + if (WITH_BUILD) { + hardCmd("pack-artifact", "Package artifact (npm pack policy)", npmCmd, ["run", "check:pack-artifact"]); + } + + const { releaseGreen, hardFailures, drift } = computeVerdict(results); + + process.stderr.write("\n──────── verdict ────────\n"); + process.stderr.write(`HARD failures (block — real defects): ${hardFailures.length}\n`); + hardFailures.forEach((r) => process.stderr.write(` ❌ ${r.label}: ${r.detail}\n`)); + process.stderr.write(`Ratchet drift (non-blocking — rebaseline at release): ${drift.length}\n`); + drift.forEach((r) => process.stderr.write(` 🟡 ${r.label}: ${r.detail}\n`)); + process.stderr.write( + releaseGreen + ? "\n✅ RELEASE-GREEN (no hard failures). Any drift above is rebaselined at release, not a contributor concern.\n" + : "\n❌ NOT release-green — hard failures must be fixed (in the originating PR branch, via co-authorship).\n" + ); + + if (JSON_OUT) { + process.stdout.write( + JSON.stringify( + { + releaseGreen, + hardFailures: hardFailures.map((r) => ({ id: r.id, label: r.label, detail: r.detail })), + drift: drift.map((r) => ({ id: r.id, label: r.label, detail: r.detail })), + checks: results.map((r) => ({ id: r.id, kind: r.kind, ok: r.ok, detail: r.detail })), + }, + null, + 2 + ) + "\n" + ); + } + + process.exit(releaseGreen ? 0 : 1); +} + +// Run only when invoked directly (so tests can import the pure helpers). +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main(); +} diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index f6d3fa56bb..1461f2215b 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -13,10 +13,8 @@ import { useNotificationStore } from "@/store/notificationStore"; import { copyToClipboard } from "@/shared/utils/clipboard"; import { getProviderDisplayLabel } from "@/shared/utils/providerDisplayLabel"; import { useIsElectron, useOpenExternal } from "@/shared/hooks/useElectron"; -import { useLiveRequests } from "@/hooks/useLiveDashboard"; -import { selectActiveRequests } from "../home/topologyUtils"; +import { HomeProviderTopologySection } from "./HomeProviderTopologySection"; -const ProviderTopology = dynamic(() => import("../home/ProviderTopology"), { ssr: false }); const ProviderQuotaWidget = dynamic(() => import("../home/ProviderQuotaWidget"), { ssr: false }); import type { NewsAnnouncement } from "@/shared/utils/releaseNotes"; @@ -123,7 +121,8 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { const [updating, setUpdating] = useState(false); // Platform detection and download links for Electron - const platform = typeof globalThis.window === "undefined" ? undefined : globalThis.window.electronAPI?.platform; + const platform = + typeof globalThis.window === "undefined" ? undefined : globalThis.window.electronAPI?.platform; const electronDownload = useMemo(() => { const latest = versionInfo?.latest || ""; const cleanLatest = latest.replace(/^v/, ""); @@ -171,7 +170,8 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { }>({ status: "idle" }); useEffect(() => { - if (!isElectron || typeof globalThis.window === "undefined" || !globalThis.window.electronAPI) return; + if (!isElectron || typeof globalThis.window === "undefined" || !globalThis.window.electronAPI) + return; // Trigger initial check silently on mount globalThis.window.electronAPI.checkForUpdates().catch((err: any) => { @@ -196,9 +196,12 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { // Appearance settings for home page pinning const [pinProviderQuotaToHome, setPinProviderQuotaToHome] = useState(false); const [showQuickStartOnHome, setShowQuickStartOnHome] = useState(true); // default on - const [showProviderTopologyOnHome, setShowProviderTopologyOnHome] = useState(true); // default on + // #4596: default hidden until appearance settings load, so the live-WS + // topology connection is never opened before we know the user wants it. + const [showProviderTopologyOnHome, setShowProviderTopologyOnHome] = useState(false); const [autoRefreshProviderQuota, setAutoRefreshProviderQuota] = useState(false); const [autoRefreshProviderQuotaInterval, setAutoRefreshProviderQuotaInterval] = useState(180); + const [appearanceSettingsLoaded, setAppearanceSettingsLoaded] = useState(false); useEffect(() => { // Fetch the pin settings (lightweight) @@ -225,6 +228,9 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { }) .catch(() => { /* ignore — defaults stay */ + }) + .finally(() => { + setAppearanceSettingsLoaded(true); }); }, []); @@ -236,10 +242,9 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { const fetchData = useCallback(async () => { try { - const [provRes, modelsRes, metricsRes, versionRes] = await Promise.all([ + const [provRes, modelsRes, versionRes] = await Promise.all([ fetch("/api/providers"), fetch("/api/models"), - fetch("/api/provider-metrics"), fetch("/api/system/version"), ]); if (provRes.ok) { @@ -250,10 +255,6 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { const modelsData = await modelsRes.json(); setModels(modelsData.models || []); } - if (metricsRes.ok) { - const metricsData = await metricsRes.json(); - setProviderMetrics(metricsData.metrics || {}); - } if (versionRes.ok) { const versionData = await versionRes.json(); setVersionInfo(versionData); @@ -278,6 +279,10 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { }, []); useEffect(() => { + if (!appearanceSettingsLoaded || !showProviderTopologyOnHome) { + return; + } + let cancelled = false; let timeoutId: ReturnType | null = null; let controller: AbortController | null = null; @@ -317,7 +322,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { if (timeoutId) clearTimeout(timeoutId); controller?.abort(); }; - }, []); + }, [appearanceSettingsLoaded, showProviderTopologyOnHome]); // T07: Check for unhealthy API keys and show notification (once per session) const notifiedUnhealthyKeys = useRef>(new Set()); @@ -1061,9 +1066,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { {pinProviderQuotaToHome && ( }> )} @@ -1159,35 +1162,13 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { )} - {/* Provider Topology (controlled by Appearance setting, default on) */} {showProviderTopologyOnHome && ( - -
-
-

{t("providerTopology")}

-

- Connected providers routing through OmniRoute in real time -

-
-
- - Active - - - Recent - - - Error - -
-
- -
+ )} {/* Provider Models Modal */} diff --git a/src/app/(dashboard)/dashboard/HomeProviderTopologySection.tsx b/src/app/(dashboard)/dashboard/HomeProviderTopologySection.tsx new file mode 100644 index 0000000000..53abbbeed2 --- /dev/null +++ b/src/app/(dashboard)/dashboard/HomeProviderTopologySection.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import dynamic from "next/dynamic"; + +import { Card } from "@/shared/components"; +import { useLiveRequests } from "@/hooks/useLiveDashboard"; +import { selectActiveRequests } from "../home/topologyUtils"; + +const ProviderTopology = dynamic(() => import("../home/ProviderTopology"), { ssr: false }); + +type TopologyProvider = { + id: string; + provider: string; + name?: string; +}; + +export function HomeProviderTopologySection({ + providers, + lastProvider, + errorProvider, + enabled = true, +}: { + providers: TopologyProvider[]; + lastProvider: string; + errorProvider: string; + enabled?: boolean; +}) { + const t = useTranslations("home"); + // #4596: gate the live-WS connection so it only opens while the topology + // section is actually shown on the home page. + const { activeRequests: liveActiveRequests } = useLiveRequests({ enabled }); + + return ( + +
+
+

{t("providerTopology")}

+

+ Connected providers routing through OmniRoute in real time +

+
+
+ + Active + + + Recent + + + Error + +
+
+ +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts b/src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts index 7530b48d11..39474592f0 100644 --- a/src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts @@ -111,6 +111,7 @@ export function formatUsdCost(value: number, locale: string): string { */ export function maskKey(fullKey: string | null | undefined): string { if (!fullKey) return ""; + if (fullKey.includes("****")) return fullKey; return fullKey.length > 8 ? `${fullKey.slice(0, 8)}...` : fullKey; } diff --git a/src/app/(dashboard)/dashboard/cli-code/components/OpenClawToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/OpenClawToolCard.tsx index ff023a2f31..c817d72c4c 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/OpenClawToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/OpenClawToolCard.tsx @@ -339,6 +339,20 @@ export default function OpenClawToolCard({ : t("installCliPrompt", { tool: "Open Claw" })}

+ {/* + Always surface Manual Config even when the CLI is not + detected locally — typical of remote OmniRoute + deployments where the CLI lives on the user's machine, + not on the server. Upstream report: #579. + */} + )} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx index 1e44881b18..7f0447d4db 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx @@ -12,8 +12,6 @@ import { getProviderBaseUrlHint, getProviderBaseUrlPlaceholder, isGlmProvider, - parseRoutingTagsInput, - parseExcludedModelsInput, getWebSessionCredentialLabel, getWebSessionCredentialHint, getWebSessionCredentialCheckLabel, @@ -28,7 +26,8 @@ import { getWebSessionCredentialRequirement } from "../../webSessionCredentials" import { useOpenRouterPresetControl } from "../OpenRouterPresetInput"; import WebSessionCredentialGuide from "../WebSessionCredentialGuide"; import CcCompatibleRequestDefaultsFields from "./CcCompatibleRequestDefaultsFields"; -import { assignCcCompatibleRequestDefaults } from "./ccCompatibleRequestDefaults"; +import { buildAddProviderSpecificData } from "./connectionProviderSpecificData"; +import QuotaScrapingFields, { EMPTY_QUOTA_SCRAPING_FIELDS } from "./QuotaScrapingFields"; export interface AddApiKeyModalProps { isOpen: boolean; @@ -112,6 +111,7 @@ export default function AddApiKeyModal({ customUserAgent: "", accountId: "", consoleApiKey: "", + ...EMPTY_QUOTA_SCRAPING_FIELDS, ccCompatibleContext1m: false, ccCompatibleRedactThinking: false, passthroughModels: false, @@ -283,47 +283,27 @@ export default function AddApiKeyModal({ } } - const providerSpecificData: Record = {}; - if (formData.customUserAgent.trim()) { - providerSpecificData.customUserAgent = formData.customUserAgent.trim(); - } - openRouterPreset.applyTo(providerSpecificData); - if (formData.routingTags.trim()) { - providerSpecificData.tags = parseRoutingTagsInput(formData.routingTags); - } - if (formData.excludedModels.trim()) { - providerSpecificData.excludedModels = parseExcludedModelsInput(formData.excludedModels); - } - if (formData.passthroughModels) { - providerSpecificData.passthroughModels = true; - } - if (showFreeModelsToggle && formData.importFreeModelsOnly) { - providerSpecificData.importFreeModelsOnly = true; - } - if (provider === "bailian-coding-plan" && formData.consoleApiKey.trim()) { - providerSpecificData.consoleApiKey = formData.consoleApiKey.trim(); - } - if (isGooglePse && formData.cx.trim()) { - providerSpecificData.cx = formData.cx.trim(); - } - if (usesBaseUrl) { - providerSpecificData.baseUrl = validatedBaseUrl; - } else if (showsRegion) { - providerSpecificData.region = formData.region.trim() || defaultRegion; - } else if (isGlm) { - providerSpecificData.apiRegion = formData.apiRegion; - } else if (isCloudflare && formData.accountId.trim()) { - providerSpecificData.accountId = formData.accountId.trim(); - } - if (isCcCompatible) assignCcCompatibleRequestDefaults(providerSpecificData, formData); + const providerSpecificData = buildAddProviderSpecificData({ + provider, + formData, + openRouterPreset, + showFreeModelsToggle, + isGooglePse, + usesBaseUrl, + validatedBaseUrl, + showsRegion, + defaultRegion, + isGlm, + isCloudflare, + isCcCompatible, + }); const payload = { name: formData.name, apiKey: credentialInput.trim() || undefined, priority: formData.priority, testStatus: "active", - providerSpecificData: - Object.keys(providerSpecificData).length > 0 ? providerSpecificData : undefined, + providerSpecificData, }; const error = await onSave(payload); @@ -710,6 +690,12 @@ export default function AddApiKeyModal({ )} {freeModelsToggle} + setFormData({ ...formData, ...patch })} + t={t} + /> {isCompatible && !isCcCompatible && (

{isAnthropic diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx index 306a0caccd..049797e4df 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx @@ -41,8 +41,11 @@ export default function EditCompatibleNodeModal({ }); const [saving, setSaving] = useState(false); const [checkKey, setCheckKey] = useState(""); + const [checkModelId, setCheckModelId] = useState(""); const [validating, setValidating] = useState(false); - const [validationResult, setValidationResult] = useState(null); + const [validationResult, setValidationResult] = useState< + null | { valid: boolean; error?: string | null; method?: string | null } + >(null); const [showAdvanced, setShowAdvanced] = useState(false); useEffect(() => { @@ -113,12 +116,17 @@ export default function EditCompatibleNodeModal({ compatMode: isCcCompatible ? "cc" : undefined, chatPath: formData.chatPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_CHAT_PATH : ""), modelsPath: isCcCompatible ? "" : formData.modelsPath, + modelId: checkModelId.trim() || undefined, }), }); const data = await res.json(); - setValidationResult(data.valid ? "success" : "failed"); + setValidationResult({ + valid: !!data.valid, + error: data.error ?? null, + method: data.method ?? null, + }); } catch { - setValidationResult("failed"); + setValidationResult({ valid: false, error: "Network error" }); } finally { setValidating(false); } @@ -259,10 +267,26 @@ export default function EditCompatibleNodeModal({ + setCheckModelId(e.target.value)} + placeholder={t("testModelIdPlaceholder")} + hint={t("testModelIdHint")} + /> {validationResult && ( - - {validationResult === "success" ? t("valid") : t("invalid")} - +

+ + {validationResult.valid ? t("valid") : t("invalid")} + + {validationResult.error && ( + + {validationResult.error} + + )} +
)}
+ setFormData({ ...formData, ...patch })} + t={t} + editMode + /> {supportsGoogleProjectId && (
{isAntigravity && ( @@ -1020,10 +1013,9 @@ export default function EditConnectionModal({ return (
- {statusIcon} {t("primaryKey")}: {connection.apiKey.slice(0, 6)}... - {connection.apiKey.slice(-4)} + {statusIcon} {t("primaryKey")}: {connection.apiKey} {health && ( +) { + if (provider === "opencode-go") { + target.opencodeGoWorkspaceId = values.opencodeGoWorkspaceId.trim() || undefined; + if (values.opencodeGoAuthCookie.trim()) { + target.opencodeGoAuthCookie = values.opencodeGoAuthCookie.trim(); + } + } else if (provider === "ollama-cloud" && values.ollamaCloudUsageCookie.trim()) { + target.ollamaCloudUsageCookie = values.ollamaCloudUsageCookie.trim(); + } +} + +type QuotaScrapingFieldsProps = { + provider?: string; + values: QuotaScrapingFieldValues; + onChange: (patch: Partial) => void; + t: ProviderMessageTranslator; + editMode?: boolean; +}; + +export default function QuotaScrapingFields({ + provider, + values, + onChange, + t, + editMode = false, +}: QuotaScrapingFieldsProps) { + if (provider === "opencode-go") { + return ( +
+ onChange({ opencodeGoWorkspaceId: e.target.value })} + placeholder="workspace_..." + hint={providerText( + t, + "opencodeGoWorkspaceIdHint", + "Required for quota scraping. Copy it from the OpenCode Go workspace URL." + )} + autoComplete="off" + spellCheck={false} + /> + onChange({ opencodeGoAuthCookie: e.target.value })} + placeholder="auth=..." + hint={providerText( + t, + "opencodeGoAuthCookieHint", + editMode + ? "Leave blank to keep the stored cookie. Paste auth=... or only the cookie value to replace it." + : "Paste the auth cookie value from opencode.ai. The auth= prefix is accepted." + )} + autoComplete="off" + spellCheck={false} + autoCapitalize="off" + /> +
+ ); + } + + if (provider === "ollama-cloud") { + return ( +
+ onChange({ ollamaCloudUsageCookie: e.target.value })} + placeholder="__Secure-session=..." + hint={providerText( + t, + "ollamaCloudUsageCookieHint", + editMode + ? "Leave blank to keep the stored cookie. Paste the __Secure-session cookie value from ollama.com/settings to replace it." + : "Required for quota scraping. Paste the __Secure-session cookie value from ollama.com/settings." + )} + autoComplete="off" + spellCheck={false} + autoCapitalize="off" + /> +
+ ); + } + + return null; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/__tests__/connModals.test.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/__tests__/connModals.test.tsx index d74f01579f..24c3ce6efe 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/__tests__/connModals.test.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/__tests__/connModals.test.tsx @@ -149,6 +149,31 @@ describe("conn-modals (Phase 1c extraction)", () => { expect(c.querySelector("*")).not.toBeNull(); }); + it("EditConnectionModal renders the API key value returned by the provider connection API", () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const fullApiKey = "test-provider-key-full-1234567890abcdef78b9"; + const connection = { + id: "conn-full-key", + name: "Test Connection", + provider: "openai", + authType: "apikey", + priority: 1, + apiKey: fullApiKey, + }; + const c = renderModal( + + ); + + expect(c.textContent).toContain(fullApiKey); + expect(c.textContent).not.toContain("test-p...78b9"); + }); + it("EditConnectionModal renders OpenRouter preset when provider comes from the page", () => { const onSave = vi.fn().mockResolvedValue(undefined); const connection = { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/connectionProviderSpecificData.ts b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/connectionProviderSpecificData.ts new file mode 100644 index 0000000000..69abfc2f4b --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/connectionProviderSpecificData.ts @@ -0,0 +1,125 @@ +import { parseExcludedModelsInput, parseRoutingTagsInput } from "../../providerPageHelpers"; +import { + assignCcCompatibleRequestDefaults, + mergeCcCompatibleRequestDefaults, +} from "./ccCompatibleRequestDefaults"; +import { + assignQuotaScrapingProviderData, + type QuotaScrapingFieldValues, +} from "./QuotaScrapingFields"; + +type FormData = QuotaScrapingFieldValues & { + accountId: string; + apiRegion: string; + ccCompatibleContext1m: boolean; + ccCompatibleRedactThinking: boolean; + consoleApiKey: string; + customUserAgent: string; + cx: string; + excludedModels: string; + importFreeModelsOnly: boolean; + passthroughModels: boolean; + region: string; + routingTags: string; + tag?: string; + validationModelId?: string; +}; +type ProviderSpecificData = Record; + +export function buildAddProviderSpecificData(options: { + provider?: string; + formData: FormData; + openRouterPreset: { applyTo: (target: ProviderSpecificData) => void }; + showFreeModelsToggle: boolean; + isGooglePse: boolean; + usesBaseUrl: boolean; + validatedBaseUrl: string | null; + showsRegion: boolean; + defaultRegion: string; + isGlm: boolean; + isCloudflare: boolean; + isCcCompatible?: boolean; +}) { + const { + provider, + formData, + openRouterPreset, + showFreeModelsToggle, + isGooglePse, + usesBaseUrl, + validatedBaseUrl, + showsRegion, + defaultRegion, + isGlm, + isCloudflare, + isCcCompatible, + } = options; + const data: ProviderSpecificData = {}; + if (formData.customUserAgent.trim()) data.customUserAgent = formData.customUserAgent.trim(); + openRouterPreset.applyTo(data); + if (formData.routingTags.trim()) data.tags = parseRoutingTagsInput(formData.routingTags); + if (formData.excludedModels.trim()) { + data.excludedModels = parseExcludedModelsInput(formData.excludedModels); + } + if (formData.passthroughModels) data.passthroughModels = true; + if (showFreeModelsToggle && formData.importFreeModelsOnly) data.importFreeModelsOnly = true; + if (provider === "bailian-coding-plan" && formData.consoleApiKey.trim()) { + data.consoleApiKey = formData.consoleApiKey.trim(); + } + assignQuotaScrapingProviderData(provider, formData, data); + if (isGooglePse && formData.cx.trim()) data.cx = formData.cx.trim(); + if (usesBaseUrl) data.baseUrl = validatedBaseUrl; + else if (showsRegion) data.region = formData.region.trim() || defaultRegion; + else if (isGlm) data.apiRegion = formData.apiRegion; + else if (isCloudflare && formData.accountId.trim()) data.accountId = formData.accountId.trim(); + if (isCcCompatible) assignCcCompatibleRequestDefaults(data, formData); + return Object.keys(data).length > 0 ? data : undefined; +} + +export function assignEditApiKeyProviderSpecificData(options: { + provider: string; + formData: FormData; + target: ProviderSpecificData; + extraApiKeys: string[]; + openRouterPreset: { getPatch: () => ProviderSpecificData }; + usesBaseUrl: boolean; + validatedBaseUrl: string | null; + showsRegion: boolean; + defaultRegion: string; + isGlm: boolean; + isCloudflare: boolean; + supportsGoogleProjectId: boolean; + trimmedCloudCodeProjectId: string; + isGooglePse: boolean; + isCcCompatible: boolean; +}) { + const o = options; + Object.assign(o.target, { + extraApiKeys: o.extraApiKeys.filter((key) => key.trim().length > 0), + tag: o.formData.tag.trim() || undefined, + tags: parseRoutingTagsInput(o.formData.routingTags), + excludedModels: parseExcludedModelsInput(o.formData.excludedModels), + customUserAgent: o.formData.customUserAgent.trim(), + ...o.openRouterPreset.getPatch(), + ...(o.formData.passthroughModels ? { passthroughModels: true } : {}), + }); + if (o.provider === "bailian-coding-plan") { + o.target.consoleApiKey = o.formData.consoleApiKey.trim() || undefined; + } + assignQuotaScrapingProviderData(o.provider, o.formData, o.target); + if (o.formData.validationModelId) o.target.validationModelId = o.formData.validationModelId; + if (o.isGooglePse) o.target.cx = o.formData.cx.trim() || undefined; + if (o.usesBaseUrl) o.target.baseUrl = o.validatedBaseUrl; + else if (o.showsRegion) o.target.region = o.formData.region.trim() || o.defaultRegion; + else if (o.isGlm) o.target.apiRegion = o.formData.apiRegion; + else if (o.isCloudflare && o.formData.accountId.trim()) { + o.target.accountId = o.formData.accountId.trim(); + } + if (o.supportsGoogleProjectId) o.target.projectId = o.trimmedCloudCodeProjectId || null; + if (o.isCcCompatible) { + o.target.requestDefaults = mergeCcCompatibleRequestDefaults( + o.target.requestDefaults, + o.formData + ); + } +} diff --git a/src/app/(dashboard)/dashboard/providers/components/AddCompatibleProviderModal.tsx b/src/app/(dashboard)/dashboard/providers/components/AddCompatibleProviderModal.tsx index 8b0b10a8cd..5eecba769e 100644 --- a/src/app/(dashboard)/dashboard/providers/components/AddCompatibleProviderModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/components/AddCompatibleProviderModal.tsx @@ -90,8 +90,11 @@ export default function AddCompatibleProviderModal({ const [formData, setFormData] = useState(() => createInitialForm(mode)); const [submitting, setSubmitting] = useState(false); const [checkKey, setCheckKey] = useState(""); + const [checkModelId, setCheckModelId] = useState(""); const [validating, setValidating] = useState(false); - const [validationResult, setValidationResult] = useState<"success" | "failed" | null>(null); + const [validationResult, setValidationResult] = useState< + null | { valid: boolean; error?: string | null; method?: string | null } + >(null); const [showAdvanced, setShowAdvanced] = useState(false); const apiTypeOptions = useMemo( @@ -209,6 +212,8 @@ export default function AddCompatibleProviderModal({ body.compatMode = defaults.compatMode; body.chatPath = formData.chatPath || CC_DEFAULT_CHAT_PATH; } + const trimmedModelId = checkModelId.trim(); + if (trimmedModelId) body.modelId = trimmedModelId; const res = await fetch("/api/provider-nodes/validate", { method: "POST", @@ -216,9 +221,13 @@ export default function AddCompatibleProviderModal({ body: JSON.stringify(body), }); const data = await res.json(); - setValidationResult(data.valid ? "success" : "failed"); + setValidationResult({ + valid: !!data.valid, + error: data.error ?? null, + method: data.method ?? null, + }); } catch { - setValidationResult("failed"); + setValidationResult({ valid: false, error: "Network error" }); } finally { setValidating(false); } @@ -322,10 +331,26 @@ export default function AddCompatibleProviderModal({
+ setCheckModelId(e.target.value)} + placeholder={t("testModelIdPlaceholder")} + hint={t("testModelIdHint")} + /> {validationResult && ( - - {validationResult === "success" ? t("valid") : t("invalid")} - +
+ + {validationResult.valid ? t("valid") : t("invalid")} + + {validationResult.error && ( + + {validationResult.error} + + )} +
)}
diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index 731457bfb5..8a2567af1b 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useMemo } from "react"; import { Card, CardSkeleton, Badge, Button, CollapsibleSection } from "@/shared/components"; import { AGGREGATOR_PROVIDER_IDS, @@ -9,7 +9,6 @@ import { IDE_PROVIDER_IDS, IMAGE_ONLY_PROVIDER_IDS, VIDEO_PROVIDER_IDS, - isClaudeCodeCompatibleProvider, } from "@/shared/constants/providers"; import { useRouter, useSearchParams } from "next/navigation"; import { getErrorCode, getRelativeTime } from "@/shared/utils"; @@ -19,6 +18,7 @@ import { useNotificationStore } from "@/store/notificationStore"; import { useTranslations } from "next-intl"; import { buildStaticProviderEntries, + buildCompatibleProviderGroups, filterConfiguredProviderEntries, shouldFilterProviderEntriesForDisplayMode, shouldShowFirstProviderHint, @@ -480,37 +480,18 @@ export default function ProvidersPage() { } }; - const compatibleProviders = providerNodes - .filter((node) => node.type === "openai-compatible") - .map((node) => ({ - id: node.id, - name: node.name || t("openaiCompatibleName"), - color: "#10A37F", - textIcon: "OC", - apiType: node.apiType, - })); - - const anthropicCompatibleProviders = providerNodes - .filter( - (node) => node.type === "anthropic-compatible" && !isClaudeCodeCompatibleProvider(node.id) - ) - .map((node) => ({ - id: node.id, - name: node.name || t("anthropicCompatibleName"), - color: "#D97757", - textIcon: "AC", - })); - - const ccCompatibleProviders = providerNodes - .filter( - (node) => node.type === "anthropic-compatible" && isClaudeCodeCompatibleProvider(node.id) - ) - .map((node) => ({ - id: node.id, - name: node.name || ccCompatibleLabel, - color: "#B45309", - textIcon: "CC", - })); + const compatibleProviderGroups = useMemo( + () => + buildCompatibleProviderGroups(providerNodes, { + openaiCompatibleName: t("openaiCompatibleName"), + anthropicCompatibleName: t("anthropicCompatibleName"), + claudeCodeCompatibleName: ccCompatibleLabel, + }), + [ccCompatibleLabel, providerNodes, t] + ); + const compatibleProviders = compatibleProviderGroups.openai; + const anthropicCompatibleProviders = compatibleProviderGroups.anthropic; + const ccCompatibleProviders = compatibleProviderGroups.claudeCode; const effectiveProviderDisplayMode = providerDisplayMode === "configured" && connections.length === 0 ? "all" : providerDisplayMode; @@ -530,7 +511,7 @@ export default function ProvidersPage() { activeServiceKind ); - const blockedProviderSet = new Set(blockedProviders); + const blockedProviderSet = useMemo(() => new Set(blockedProviders), [blockedProviders]); const rawNoAuthEntriesAll = buildStaticProviderEntries("no-auth", getProviderStats); const noAuthEntriesAll = rawNoAuthEntriesAll.filter(({ providerId, provider }) => { const alias = typeof provider.alias === "string" ? provider.alias : null; @@ -970,8 +951,8 @@ export default function ProvidersPage() { }`} title={t("testAllCompatible")} > - - {testingMode === "compatible" ? "sync" : "play_arrow"} + + play_arrow {testingMode === "compatible" ? t("testing") : t("testAll")} @@ -1065,8 +1046,8 @@ export default function ProvidersPage() { title={t("testAllOAuth")} aria-label={t("testAllOAuth")} > - - {testingMode === "oauth" ? "sync" : "play_arrow"} + + play_arrow {testingMode === "oauth" ? t("testing") : t("testAll")} @@ -1115,8 +1096,8 @@ export default function ProvidersPage() { title={t("testAll")} aria-label={t("testAll")} > - - {testingMode === "ide" ? "sync" : "play_arrow"} + + play_arrow {testingMode === "ide" ? t("testing") : t("testAll")} @@ -1172,8 +1153,8 @@ export default function ProvidersPage() { }`} title={t("testAll")} > - - {testingMode === "web-cookie" ? "sync" : "play_arrow"} + + play_arrow {testingMode === "web-cookie" ? t("testing") : t("testAll")} @@ -1216,8 +1197,8 @@ export default function ProvidersPage() { }`} title={t("testAll")} > - - {testingMode === "free" ? "sync" : "play_arrow"} + + play_arrow {testingMode === "free" ? t("testing") : t("testAll")} @@ -1261,8 +1242,8 @@ export default function ProvidersPage() { title={t("testAllApiKey")} aria-label={t("testAllApiKey")} > - - {testingMode === "apikey" ? "sync" : "play_arrow"} + + play_arrow {testingMode === "apikey" ? t("testing") : t("testAll")} @@ -1313,8 +1294,8 @@ export default function ProvidersPage() { }`} title={t("testAll")} > - - {testingMode === "no-auth" ? "sync" : "play_arrow"} + + play_arrow {testingMode === "no-auth" ? t("testing") : t("testAll")} @@ -1357,8 +1338,8 @@ export default function ProvidersPage() { }`} title={t("testAll")} > - - {testingMode === "upstream-proxy" ? "sync" : "play_arrow"} + + play_arrow {testingMode === "upstream-proxy" ? t("testing") : t("testAll")} @@ -1500,8 +1481,8 @@ export default function ProvidersPage() { }`} title={t("testAll")} > - - {testingMode === "cloud-agent" ? "sync" : "play_arrow"} + + play_arrow {testingMode === "cloud-agent" ? t("testing") : t("testAll")} @@ -1548,8 +1529,8 @@ export default function ProvidersPage() { }`} title={t("testAll")} > - - {testingMode === "local" ? "sync" : "play_arrow"} + + play_arrow {testingMode === "local" ? t("testing") : t("testAll")} @@ -1592,8 +1573,8 @@ export default function ProvidersPage() { }`} title={t("testAll")} > - - {testingMode === "search" ? "sync" : "play_arrow"} + + play_arrow {testingMode === "search" ? t("testing") : t("testAll")} @@ -1702,8 +1683,8 @@ export default function ProvidersPage() { }`} title={t("testAll")} > - - {testingMode === "audio" ? "sync" : "play_arrow"} + + play_arrow {testingMode === "audio" ? t("testing") : t("testAll")} diff --git a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts index f7a6b4d739..32da435927 100644 --- a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts @@ -7,6 +7,7 @@ import { type ResolvedProviderCatalogEntry, type StaticProviderCatalogCategory, } from "@/lib/providers/catalog"; +import { isClaudeCodeCompatibleProvider } from "@/shared/constants/providers"; import { getModelsByProviderId } from "@/shared/constants/models"; import { providerHasServiceKind } from "@/lib/providers/serviceKindIndex"; import { compareTr, matchesSearch } from "@/shared/utils/turkishText"; @@ -25,6 +26,20 @@ export interface ProviderEntry> { toggleAuthType: "oauth" | "free" | "apikey" | "no-auth"; } +export type CompatibleProviderInfo = { + id: string; + name: string; + color: string; + textIcon: string; + apiType?: string; +}; + +export type CompatibleProviderGroups = { + openai: CompatibleProviderInfo[]; + anthropic: CompatibleProviderInfo[]; + claudeCode: CompatibleProviderInfo[]; +}; + export function shouldApplyConfiguredOnlyFilter( showConfiguredOnly: boolean, connectionCount: number @@ -110,6 +125,53 @@ export function buildStaticProviderEntries( ); } +export function buildCompatibleProviderGroups( + providerNodes: Array<{ id: string; name?: string; type?: string; apiType?: string }>, + labels: { + openaiCompatibleName: string; + anthropicCompatibleName: string; + claudeCodeCompatibleName: string; + } +): CompatibleProviderGroups { + const openai: CompatibleProviderInfo[] = []; + const anthropic: CompatibleProviderInfo[] = []; + const claudeCode: CompatibleProviderInfo[] = []; + + for (const node of providerNodes) { + if (node.type === "openai-compatible") { + openai.push({ + id: node.id, + name: node.name || labels.openaiCompatibleName, + color: "#10A37F", + textIcon: "OC", + apiType: node.apiType, + }); + continue; + } + + if (node.type !== "anthropic-compatible") continue; + + if (isClaudeCodeCompatibleProvider(node.id)) { + claudeCode.push({ + id: node.id, + name: node.name || labels.claudeCodeCompatibleName, + color: "#B45309", + textIcon: "CC", + }); + continue; + } + + anthropic.push({ + id: node.id, + name: node.name || labels.anthropicCompatibleName, + color: "#D97757", + textIcon: "AC", + }); + } + + return { openai, anthropic, claudeCode }; +} + export function filterConfiguredProviderEntries( entries: ProviderEntry[], showConfiguredOnly: boolean, diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx index c09bb79c18..9c42bb0647 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx @@ -91,6 +91,7 @@ export default function QuotaCard({ quotas={quotas} loading={loading} error={error} + message={quota?.message ?? null} refreshedAt={displayRefreshedAt} hasStaleData={hasStaleData} onRefresh={onRefresh} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/formatters.ts b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/formatters.ts new file mode 100644 index 0000000000..d5eaf86caa --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/formatters.ts @@ -0,0 +1,6 @@ +export function formatAutoRefreshCountdown(ms: number): string { + const totalSeconds = Math.max(0, Math.ceil(ms / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index fcd9f47316..7176942e8a 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -21,6 +21,8 @@ import useEmailPrivacyStore from "@/store/emailPrivacyStore"; import { useNotificationStore } from "@/store/notificationStore"; import QuotaCutoffModal from "./QuotaCutoffModal"; import QuotaCardGrid from "./QuotaCardGrid"; +import { useVisibleQuotaData } from "./useVisibleQuotaData"; +import { formatAutoRefreshCountdown } from "./formatters"; import { translateUsageOrFallback, type UsageTranslationValues } from "./i18nFallback"; import { compareTr } from "@/shared/utils/turkishText"; @@ -33,7 +35,6 @@ const MIN_FETCH_INTERVAL_MS = 30000; const QUOTA_BAR_GREEN_THRESHOLD = 50; const QUOTA_BAR_YELLOW_THRESHOLD = 20; -// Display label per known provider; the icon is resolved by ProviderIcon. const PROVIDER_LABEL: Record = { antigravity: "Antigravity", "gemini-cli": "Gemini CLI", @@ -46,6 +47,7 @@ const PROVIDER_LABEL: Record = { zai: "Z.AI", glmt: "GLM Thinking", "opencode-go": "OpenCode Go", + "ollama-cloud": "Ollama Cloud", "kimi-coding": "Kimi Coding", minimax: "MiniMax", "minimax-cn": "MiniMax CN", @@ -53,8 +55,7 @@ const PROVIDER_LABEL: Record = { deepseek: "DeepSeek", }; -// Group ordering — single source of truth for "where does Codex sit -// relative to Antigravity on the page". +// Group ordering — single source of truth for provider placement. const PROVIDER_ORDER: Record = { antigravity: 1, "gemini-cli": 2, @@ -66,10 +67,11 @@ const PROVIDER_ORDER: Record = { zai: 8, glmt: 9, "opencode-go": 10, - "kimi-coding": 11, - minimax: 12, - "minimax-cn": 13, - nanogpt: 14, + "ollama-cloud": 11, + "kimi-coding": 12, + minimax: 13, + "minimax-cn": 14, + nanogpt: 15, }; const TIER_FILTERS = [ @@ -213,13 +215,6 @@ function aggregateWorst(statuses: StatusKey[]): "critical" | "alert" | "ok" | "e return worst; } -function formatAutoRefreshCountdown(ms: number): string { - const totalSeconds = Math.max(0, Math.ceil(ms / 1000)); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; -} - interface ProviderLimitsProps { showFilters?: boolean; autoRefreshInterval?: number; @@ -564,6 +559,7 @@ export default function ProviderLimits({ (a, b) => (PROVIDER_ORDER[a.provider] || 99) - (PROVIDER_ORDER[b.provider] || 99) ); }, [filteredConnections]); + const visibleQuotaData = useVisibleQuotaData(sortedConnections, quotaData); const resolvedPlanByConnection = useMemo(() => { const out: Record = {}; @@ -613,10 +609,10 @@ export default function ProviderLimits({ const statusByConnection = useMemo(() => { const out: Record = {}; for (const conn of sortedConnections) { - out[conn.id] = getWorstStatus(quotaData[conn.id]?.quotas); + out[conn.id] = getWorstStatus(visibleQuotaData[conn.id]?.quotas); } return out; - }, [sortedConnections, quotaData]); + }, [sortedConnections, visibleQuotaData]); const purchaseTypeCounts = useMemo(() => { const counts: Record = { @@ -697,8 +693,8 @@ export default function ProviderLimits({ const sa = statusRank[statusByConnection[a.id] || "empty"]; const sb = statusRank[statusByConnection[b.id] || "empty"]; if (sa !== sb) return sa - sb; - const ra = getSoonestResetMs(quotaData[a.id]?.quotas); - const rb = getSoonestResetMs(quotaData[b.id]?.quotas); + const ra = getSoonestResetMs(visibleQuotaData[a.id]?.quotas); + const rb = getSoonestResetMs(visibleQuotaData[b.id]?.quotas); return ra - rb; }); }, [ @@ -711,7 +707,7 @@ export default function ProviderLimits({ statusByConnection, envFilter, providerFilter, - quotaData, + visibleQuotaData, ]); // Distinct provider keys present in the current connection set (after the @@ -1055,7 +1051,7 @@ export default function ProviderLimits({ renderInlineQuotaSummary(quota.quotas)} onRefresh={refreshProvider} onOpenCutoff={(conn) => { - const windows = (quotaData[conn.id]?.quotas || []).filter( + const windows = (visibleQuotaData[conn.id]?.quotas || []).filter( (q: any) => q && typeof q.name === "string" && !q.isCredits ); setCutoffModalWindows(windows); diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx index 2b6c2511cf..47641ec896 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx @@ -25,6 +25,7 @@ interface Props { quotas: any[]; loading: boolean; error: string | null; + message?: string | null; refreshedAt?: string; hasStaleData: boolean; onRefresh: () => void; @@ -109,6 +110,7 @@ export default function QuotaCardExpanded({ quotas, loading, error, + message, refreshedAt, hasStaleData, onRefresh, @@ -143,6 +145,10 @@ export default function QuotaCardExpanded({ error {error}
+ ) : quotas.length === 0 && message ? ( +
+ {message} +
) : quotas.length === 0 ? (
{t("noQuotaData")}
) : ( diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts new file mode 100644 index 0000000000..dfed75ba20 --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts @@ -0,0 +1,189 @@ +import { getModelsByProviderId } from "@omniroute/open-sse/config/providerModels.ts"; +import { safePercentage } from "@/shared/utils/formatting"; + +const GLM_QUOTA_ORDER: Record = { session: 0, weekly: 1, mcp_monthly: 2 }; + +function quotaEntries(data: any): Array<[string, any]> { + return data?.quotas && typeof data.quotas === "object" ? Object.entries(data.quotas) : []; +} + +function isUnlimitedEmpty(quota: any): boolean { + return Boolean(quota?.unlimited && (!quota?.total || quota.total <= 0)); +} + +function isPastResetWindow(resetAt: any): boolean { + if (!resetAt) return false; + const resetTime = + typeof resetAt === "number" ? resetAt : typeof resetAt === "string" ? Date.parse(resetAt) : NaN; + return Number.isFinite(resetTime) && Date.now() >= resetTime; +} + +function getResetAdjustedQuota(quota: any) { + const usedRaw = Number(quota?.used || 0); + const totalRaw = Number(quota?.total || 0); + const total = Number.isFinite(totalRaw) ? totalRaw : 0; + const remainingRaw = safePercentage(quota?.remainingPercentage); + const hasPendingUsage = usedRaw > 0 || (remainingRaw !== undefined && remainingRaw < 100); + const staleAfterReset = isPastResetWindow(quota?.resetAt || null) && hasPendingUsage; + + return { + staleAfterReset, + total, + used: staleAfterReset ? 0 : usedRaw, + remainingPercentage: staleAfterReset && total > 0 ? 100 : remainingRaw, + }; +} + +function normalizeQuotaEntry(name: string, quota: any = {}, extras: any = {}) { + const adjusted = getResetAdjustedQuota(quota); + return { + name, + used: Number.isFinite(adjusted.used) ? adjusted.used : 0, + total: adjusted.total, + resetAt: quota?.resetAt || null, + staleAfterReset: adjusted.staleAfterReset, + ...(adjusted.remainingPercentage !== undefined + ? { remainingPercentage: adjusted.remainingPercentage } + : {}), + ...extras, + }; +} + +function parseGeneric(data: any) { + return quotaEntries(data).map(([name, quota]) => normalizeQuotaEntry(name, quota)); +} + +function parseGithub(data: any) { + return quotaEntries(data) + .filter(([, quota]) => !isUnlimitedEmpty(quota)) + .map(([name, quota]) => normalizeQuotaEntry(name, quota)); +} + +function parseGlmFamily(data: any) { + return quotaEntries(data).map(([name, quota]) => + normalizeQuotaEntry(name, quota, { + displayName: quota?.displayName, + details: Array.isArray(quota?.details) ? quota.details : undefined, + isPercentageOnly: + Number(quota?.total || 0) === 100 && quota?.remainingPercentage !== undefined, + }) + ); +} + +function buildCreditsQuota( + name: string, + remaining: number, + remainingPercentage: number, + extra = {} +) { + return { + name, + used: 0, + total: 0, + remaining, + resetAt: null, + unlimited: false, + isCredits: true, + remainingPercentage, + creditCount: remaining, + ...extra, + }; +} + +function parseAntigravityQuota(modelKey: string, quota: any) { + if (modelKey === "credits") { + const remaining = Number(quota?.remaining ?? 0); + return buildCreditsQuota("credits", remaining, remaining > 50 ? 100 : remaining > 10 ? 60 : 20); + } + if (modelKey === "models" || isUnlimitedEmpty(quota)) return null; + return normalizeQuotaEntry(modelKey, quota, { + modelKey, + isPercentageOnly: quota?.fractionReported === true, + ...(quota?.quotaSource ? { quotaSource: quota.quotaSource } : {}), + ...(quota?.fractionReported !== undefined ? { fractionReported: quota.fractionReported } : {}), + }); +} + +function parseAntigravity(data: any) { + return quotaEntries(data) + .map(([modelKey, quota]) => parseAntigravityQuota(modelKey, quota)) + .filter(Boolean); +} + +function parseCodex(data: any) { + return quotaEntries(data).map(([quotaType, quota]) => + normalizeQuotaEntry(quotaType, quota, { + displayName: quota?.displayName, + isPercentageOnly: true, + }) + ); +} + +function parseClaude(data: any) { + if (data?.message) + return [{ name: "error", used: 0, total: 0, resetAt: null, message: data.message }]; + return quotaEntries(data).map(([name, quota]) => + normalizeQuotaEntry(name, quota, { isPercentageOnly: true }) + ); +} + +function parseGeminiCli(data: any) { + return quotaEntries(data).map(([modelKey, quota]) => + normalizeQuotaEntry(modelKey, quota, { modelKey }) + ); +} + +function parseDeepseekQuota(quotaKey: string, quota: any) { + const match = quotaKey.match(/^credits(?:_([a-z]{3}))?$/); + if (!match) return normalizeQuotaEntry(quotaKey, quota); + const remaining = Number(quota?.remaining ?? 0); + const currency = quota?.currency ?? (match[1] ? match[1].toUpperCase() : "USD"); + return buildCreditsQuota(currency, remaining, remaining > 20 ? 100 : remaining > 5 ? 60 : 20, { + currency, + }); +} + +function parseDeepseek(data: any) { + return quotaEntries(data).map(([quotaKey, quota]) => parseDeepseekQuota(quotaKey, quota)); +} + +function parseProviderQuotas(providerId: string, data: any) { + if (providerId === "github") return parseGithub(data); + if (["glm", "glm-cn", "glmt", "opencode-go"].includes(providerId)) return parseGlmFamily(data); + if (providerId === "antigravity" || providerId === "agy") return parseAntigravity(data); + if (providerId === "codex") return parseCodex(data); + if (providerId === "claude") return parseClaude(data); + if (providerId === "gemini-cli") return parseGeminiCli(data); + if (providerId === "deepseek") return parseDeepseek(data); + return parseGeneric(data); +} + +function sortProviderModelOrder(provider: string, quotas: any[]) { + const modelOrder = getModelsByProviderId(provider); + if (modelOrder.length === 0) return; + const orderMap = new Map(modelOrder.map((m, i) => [m.id, i])); + quotas.sort( + (a, b) => + (orderMap.get(a.modelKey || a.name) ?? 999) - (orderMap.get(b.modelKey || b.name) ?? 999) + ); +} + +function sortGlmOrder(providerId: string, quotas: any[]) { + if (!["glm", "glm-cn", "glmt", "opencode-go"].includes(providerId)) return; + quotas.sort((a, b) => (GLM_QUOTA_ORDER[a.name] ?? 99) - (GLM_QUOTA_ORDER[b.name] ?? 99)); +} + +export function parseQuotaData(provider: string | undefined, data: any) { + if (!data || typeof data !== "object") return []; + const providerId = String(provider || "").toLowerCase(); + + try { + const normalizedQuotas = parseProviderQuotas(providerId, data); + sortProviderModelOrder(provider, normalizedQuotas); + sortGlmOrder(providerId, normalizedQuotas); + return normalizedQuotas; + } catch (error) { + console.error(`Error parsing quota data for ${provider}:`, error); + return []; + } +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/useVisibleQuotaData.ts b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/useVisibleQuotaData.ts new file mode 100644 index 0000000000..ecf6b9618d --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/useVisibleQuotaData.ts @@ -0,0 +1,66 @@ +import { useEffect, useMemo, useState } from "react"; + +import { collectHiddenQuotaModelIds, filterHiddenModelQuotas } from "./utils"; + +function getProviderKey(connections: any[]): string { + const providers = new Set(); + for (const conn of connections) { + if (typeof conn?.provider === "string" && conn.provider) providers.add(conn.provider); + } + return Array.from(providers).sort().join("|"); +} + +export function useVisibleQuotaData( + connections: any[], + quotaData: Record +): Record { + const [hiddenModelsByProvider, setHiddenModelsByProvider] = useState>( + {} + ); + const providerKey = useMemo(() => getProviderKey(connections), [connections]); + + useEffect(() => { + if (!providerKey) return; + + let alive = true; + const providers = providerKey.split("|").filter(Boolean); + + Promise.all( + providers.map(async (provider) => { + try { + const response = await fetch( + `/api/provider-models?provider=${encodeURIComponent(provider)}` + ); + if (!response.ok) return [provider, []] as const; + const data = await response.json(); + return [provider, collectHiddenQuotaModelIds(provider, data)] as const; + } catch { + return [provider, []] as const; + } + }) + ).then((entries) => { + if (alive) setHiddenModelsByProvider(Object.fromEntries(entries)); + }); + + return () => { + alive = false; + }; + }, [providerKey]); + + return useMemo(() => { + const next: Record = {}; + for (const conn of connections) { + const data = quotaData[conn.id]; + if (!data) continue; + next[conn.id] = { + ...data, + quotas: filterHiddenModelQuotas( + conn.provider, + data.quotas, + hiddenModelsByProvider[conn.provider] + ), + }; + } + return next; + }, [connections, hiddenModelsByProvider, quotaData]); +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx index b8dec9cddb..e19f8b973b 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx @@ -1,5 +1,4 @@ -import { getModelsByProviderId } from "@omniroute/open-sse/config/providerModels.ts"; -import { safePercentage } from "@/shared/utils/formatting"; +export { parseQuotaData } from "./quotaParsing"; const PROVIDER_PLAN_FALLBACKS = new Set([ "claude code", @@ -36,12 +35,6 @@ const QUOTA_LABEL_MAP: Record = { time_limit: "Time Limit", }; -const GLM_QUOTA_ORDER: Record = { - session: 0, - weekly: 1, - mcp_monthly: 2, -}; - function toRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -179,267 +172,6 @@ export function calculatePercentage(used, total) { return Math.round(((total - used) / total) * 100); } -function isPastResetWindow(resetAt) { - if (!resetAt) return false; - const resetTime = - typeof resetAt === "number" ? resetAt : typeof resetAt === "string" ? Date.parse(resetAt) : NaN; - if (!Number.isFinite(resetTime)) return false; - return Date.now() >= resetTime; -} - -function normalizeQuotaEntry(name: string, quota: any = {}, extras: any = {}) { - const usedRaw = Number(quota?.used || 0); - const totalRaw = Number(quota?.total || 0); - const resetAt = quota?.resetAt || null; - - // T13: Only consider it stale if the reset time passed AND there's still usage shown. - // If usage is already 0 (or remaining is 100%), it's naturally reset and doesn't need to be marked as stale. - const passedReset = isPastResetWindow(resetAt); - const remainingPercentageRaw = safePercentage(quota?.remainingPercentage); - const hasPendingUsage = - usedRaw > 0 || (remainingPercentageRaw !== undefined && remainingPercentageRaw < 100); - const staleAfterReset = passedReset && hasPendingUsage; - - const used = staleAfterReset ? 0 : usedRaw; - const total = Number.isFinite(totalRaw) ? totalRaw : 0; - - const remainingPercentage = - staleAfterReset && total > 0 - ? 100 - : remainingPercentageRaw !== undefined - ? remainingPercentageRaw - : undefined; - - return { - name, - used: Number.isFinite(used) ? used : 0, - total, - resetAt, - staleAfterReset, - ...(remainingPercentage !== undefined ? { remainingPercentage } : {}), - ...extras, - }; -} - -/** - * Parse provider-specific quota structures into normalized array - * @param {string} provider - Provider name (github, antigravity, codex, kiro, claude) - * @param {Object} data - Raw quota data from provider - * @returns {Array} Normalized quota objects with { name, used, total, resetAt } - */ -export function parseQuotaData(provider, data) { - if (!data || typeof data !== "object") return []; - - const normalizedQuotas = []; - const providerId = String(provider || "").toLowerCase(); - - try { - switch (providerId) { - case "github": - if (data.quotas) { - Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => { - if (quota?.unlimited && (!quota?.total || quota.total <= 0)) { - return; - } - normalizedQuotas.push(normalizeQuotaEntry(name, quota)); - }); - } - break; - - case "glm": - case "glm-cn": - case "glmt": - case "opencode-go": - if (data.quotas) { - Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => { - normalizedQuotas.push( - normalizeQuotaEntry(name, quota, { - displayName: quota?.displayName, - details: Array.isArray(quota?.details) ? quota.details : undefined, - isPercentageOnly: - Number(quota?.total || 0) === 100 && quota?.remainingPercentage !== undefined, - }) - ); - }); - } - break; - - case "antigravity": - case "agy": - if (data.quotas) { - Object.entries(data.quotas).forEach(([modelKey, quota]: [string, any]) => { - if (modelKey === "credits") { - // Credit balance: render as "N credits remaining" counter, not a progress bar - const remaining = Number(quota?.remaining ?? 0); - normalizedQuotas.push({ - name: "credits", - used: 0, - total: 0, - remaining, - resetAt: null, - unlimited: false, - isCredits: true, - // Show green if >50, yellow if >10, red if ≤10 - remainingPercentage: remaining > 50 ? 100 : remaining > 10 ? 60 : 20, - creditCount: remaining, - }); - return; - } - if (modelKey === "models") { - // Summary row: skip — individual models are shown via modelQuotas if needed - return; - } - if (quota?.unlimited && (!quota?.total || quota.total <= 0)) { - return; - } - normalizedQuotas.push( - normalizeQuotaEntry(modelKey, quota, { - modelKey: modelKey, - isPercentageOnly: quota?.fractionReported === true, - ...(quota?.quotaSource ? { quotaSource: quota.quotaSource } : {}), - ...(quota?.fractionReported !== undefined - ? { fractionReported: quota.fractionReported } - : {}), - }) - ); - }); - } - break; - - case "codex": - if (data.quotas) { - Object.entries(data.quotas).forEach(([quotaType, quota]: [string, any]) => { - normalizedQuotas.push( - normalizeQuotaEntry(quotaType, quota, { - displayName: quota?.displayName, - isPercentageOnly: true, - }) - ); - }); - } - break; - - case "kiro": - case "amazon-q": - if (data.quotas) { - Object.entries(data.quotas).forEach(([quotaType, quota]: [string, any]) => { - normalizedQuotas.push(normalizeQuotaEntry(quotaType, quota)); - }); - } - break; - - case "claude": - if (data.message) { - // Handle error message case - normalizedQuotas.push({ - name: "error", - used: 0, - total: 0, - resetAt: null, - message: data.message, - }); - } else if (data.quotas) { - Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => { - normalizedQuotas.push( - normalizeQuotaEntry(name, quota, { - isPercentageOnly: true, - }) - ); - }); - } - break; - - case "gemini-cli": - if (data.quotas) { - Object.entries(data.quotas).forEach(([modelKey, quota]: [string, any]) => { - normalizedQuotas.push(normalizeQuotaEntry(modelKey, quota, { modelKey })); - }); - } - break; - - case "nanogpt": - if (data.quotas) { - Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => { - normalizedQuotas.push(normalizeQuotaEntry(name, quota)); - }); - } - break; - - case "deepseek": - // DeepSeek balance: credits-style display with currency - // Match any "credits" key with optional 3-letter currency suffix - if (data.quotas) { - Object.entries(data.quotas).forEach(([quotaKey, quota]: [string, any]) => { - // Match credits, credits_usd, credits_cny, credits_eur, etc. - const match = quotaKey.match(/^credits(?:_([a-z]{3}))?$/); - if (match) { - const remaining = Number(quota?.remaining ?? 0); - // Extract currency from key suffix or use quota.currency, fallback to USD - const currency = quota?.currency ?? (match[1] ? match[1].toUpperCase() : "USD"); - normalizedQuotas.push({ - name: currency, - used: 0, - total: 0, - remaining, - resetAt: null, - unlimited: false, - isCredits: true, - currency, - creditCount: remaining, - // Color coding based on balance amount: green >20, yellow 5-20, red <5 - remainingPercentage: remaining > 20 ? 100 : remaining > 5 ? 60 : 20, - }); - } else { - normalizedQuotas.push(normalizeQuotaEntry(quotaKey, quota)); - } - }); - } - break; - - default: - // Generic fallback for unknown providers - if (data.quotas) { - Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => { - normalizedQuotas.push(normalizeQuotaEntry(name, quota)); - }); - } - } - } catch (error) { - console.error(`Error parsing quota data for ${provider}:`, error); - return []; - } - - // Sort quotas according to PROVIDER_MODELS order - const modelOrder = getModelsByProviderId(provider); - if (modelOrder.length > 0) { - const orderMap = new Map(modelOrder.map((m, i) => [m.id, i])); - - normalizedQuotas.sort((a, b) => { - // Use modelKey for antigravity, otherwise use name - const keyA = a.modelKey || a.name; - const keyB = b.modelKey || b.name; - const orderA = orderMap.get(keyA) ?? 999; - const orderB = orderMap.get(keyB) ?? 999; - return (orderA as number) - (orderB as number); - }); - } - - if ( - providerId === "glm" || - providerId === "glm-cn" || - providerId === "glmt" || - providerId === "opencode-go" - ) { - normalizedQuotas.sort((a, b) => { - const orderA = GLM_QUOTA_ORDER[a.name] ?? 99; - const orderB = GLM_QUOTA_ORDER[b.name] ?? 99; - return orderA - orderB; - }); - } - - return normalizedQuotas; -} - /** * Resolve the best available plan label using live usage first, then persisted * provider-specific connection metadata. @@ -472,107 +204,93 @@ export function resolvePlanValue(plan, providerSpecificData) { return livePlan || null; } +function unknownPlanTier(raw: string | null = null) { + return { key: "unknown", label: "Unknown", variant: "default", rank: 0, raw }; +} + +function formatUnknownPlanLabel(raw: string) { + return raw + .toLowerCase() + .split(/[\s_-]+/) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function matchClaudePlanTier(raw: string, upper: string) { + const match = upper.match(/(?:DEFAULT_)?CLAUDE_(MAX|PRO|TEAM|ENTERPRISE|FREE)(?:_(\d+X))?/); + if (!match) return null; + + const multiplier = match[2] ? ` ${match[2].toLowerCase()}` : ""; + const tiers = { + MAX: { key: "ultra", label: `Max${multiplier}`, variant: "success", rank: 4, raw }, + PRO: { key: "pro", label: "Pro", variant: "success", rank: 3, raw }, + TEAM: { key: "team", label: "Team", variant: "info", rank: 6, raw }, + ENTERPRISE: { key: "enterprise", label: "Enterprise", variant: "info", rank: 7, raw }, + FREE: { key: "free", label: "Free", variant: "default", rank: 1, raw }, + }; + return tiers[match[1]]; +} + +function matchKeywordPlanTier(raw: string, upper: string) { + if (upper.includes("PRO+") || upper.includes("PRO PLUS") || upper.includes("PROPLUS")) + return { key: "plus", label: "Pro+", variant: "success", rank: 4, raw }; + if (upper.includes("ENTERPRISE") || upper.includes("CORP") || upper.includes("ORG")) + return { key: "enterprise", label: "Enterprise", variant: "info", rank: 7, raw }; + if (upper.includes("TEAM") || upper.includes("CHATGPTTEAM")) + return { key: "team", label: "Team", variant: "info", rank: 6, raw }; + if (upper.includes("BUSINESS") || upper.includes("STANDARD") || upper.includes("BIZ")) + return { key: "business", label: "Business", variant: "warning", rank: 5, raw }; + if (upper.includes("STUDENT")) + return { key: "pro", label: "Student", variant: "success", rank: 3, raw }; + if (upper.includes("ULTRA")) + return { key: "ultra", label: "Ultra", variant: "success", rank: 4, raw }; + return null; +} + +function matchTokenPlanTier(raw: string, upper: string) { + if (hasTierToken(upper, "MAX")) + return { key: "ultra", label: "Max", variant: "success", rank: 4, raw }; + if (hasTierToken(upper, "PRO") || hasTierToken(upper, "PREMIUM")) + return { key: "pro", label: "Pro", variant: "success", rank: 3, raw }; + if (hasTierToken(upper, "STARTER")) + return { key: "lite", label: "Starter", variant: "primary", rank: 2, raw }; + if (hasTierToken(upper, "LITE") || hasTierToken(upper, "LIGHT")) + return { key: "lite", label: "Lite", variant: "primary", rank: 2, raw }; + if (hasTierToken(upper, "PLUS") || hasTierToken(upper, "PAID")) + return { key: "plus", label: "Plus", variant: "success", rank: 2, raw }; + return null; +} + +function matchFreePlanTier(raw: string, upper: string) { + return upper.includes("FREE") || + upper.includes("BASIC") || + upper.includes("TRIAL") || + upper.includes("LEGACY") + ? { key: "free", label: "Free", variant: "default", rank: 1, raw } + : null; +} + /** * Normalize provider-specific plan labels into a shared tier taxonomy. * Supported tiers: enterprise, business, team, ultra, pro, plus, lite, free, unknown. */ export function normalizePlanTier(plan) { const raw = typeof plan === "string" ? plan.trim() : ""; - if (!raw) { - return { key: "unknown", label: "Unknown", variant: "default", rank: 0, raw: null }; - } + if (!raw) return unknownPlanTier(null); const upper = raw.toUpperCase(); // Provider names that are not real plan tiers — treat as unknown - if (PROVIDER_PLAN_FALLBACKS.has(raw.toLowerCase())) { - return { key: "unknown", label: "Unknown", variant: "default", rank: 0, raw }; - } + if (PROVIDER_PLAN_FALLBACKS.has(raw.toLowerCase())) return unknownPlanTier(raw); // Match Anthropic bootstrap strings (claude_max, default_claude_max_20x, etc.) // before the generic PRO/TEAM checks so underscored values don't fall through. - const claudeMatch = upper.match(/(?:DEFAULT_)?CLAUDE_(MAX|PRO|TEAM|ENTERPRISE|FREE)(?:_(\d+X))?/); - if (claudeMatch) { - const family = claudeMatch[1]; - const multiplier = claudeMatch[2] ? ` ${claudeMatch[2].toLowerCase()}` : ""; - if (family === "MAX") { - return { key: "ultra", label: `Max${multiplier}`, variant: "success", rank: 4, raw }; - } - if (family === "PRO") { - return { key: "pro", label: "Pro", variant: "success", rank: 3, raw }; - } - if (family === "TEAM") { - return { key: "team", label: "Team", variant: "info", rank: 6, raw }; - } - if (family === "ENTERPRISE") { - return { key: "enterprise", label: "Enterprise", variant: "info", rank: 7, raw }; - } - if (family === "FREE") { - return { key: "free", label: "Free", variant: "default", rank: 1, raw }; - } - } - - if (upper.includes("PRO+") || upper.includes("PRO PLUS") || upper.includes("PROPLUS")) { - return { key: "plus", label: "Pro+", variant: "success", rank: 4, raw }; - } - - if (upper.includes("ENTERPRISE") || upper.includes("CORP") || upper.includes("ORG")) { - return { key: "enterprise", label: "Enterprise", variant: "info", rank: 7, raw }; - } - - // Team plan (e.g., ChatGPT Team, GitHub Team) - if (upper.includes("TEAM") || upper.includes("CHATGPTTEAM")) { - return { key: "team", label: "Team", variant: "info", rank: 6, raw }; - } - - if (upper.includes("BUSINESS") || upper.includes("STANDARD") || upper.includes("BIZ")) { - return { key: "business", label: "Business", variant: "warning", rank: 5, raw }; - } - - if (upper.includes("STUDENT")) { - return { key: "pro", label: "Student", variant: "success", rank: 3, raw }; - } - - if (upper.includes("ULTRA")) { - return { key: "ultra", label: "Ultra", variant: "success", rank: 4, raw }; - } - - if (hasTierToken(upper, "MAX")) { - return { key: "ultra", label: "Max", variant: "success", rank: 4, raw }; - } - - if (hasTierToken(upper, "PRO") || hasTierToken(upper, "PREMIUM")) { - return { key: "pro", label: "Pro", variant: "success", rank: 3, raw }; - } - - if (hasTierToken(upper, "STARTER")) { - return { key: "lite", label: "Starter", variant: "primary", rank: 2, raw }; - } - - if (hasTierToken(upper, "LITE") || hasTierToken(upper, "LIGHT")) { - return { key: "lite", label: "Lite", variant: "primary", rank: 2, raw }; - } - - if (hasTierToken(upper, "PLUS") || hasTierToken(upper, "PAID")) { - return { key: "plus", label: "Plus", variant: "success", rank: 2, raw }; - } - - if ( - upper.includes("FREE") || - upper.includes("BASIC") || - upper.includes("TRIAL") || - upper.includes("LEGACY") - ) { - return { key: "free", label: "Free", variant: "default", rank: 1, raw }; - } - - const titleCased = raw - .toLowerCase() - .split(/[\s_-]+/) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); - - return { key: "unknown", label: titleCased || "Unknown", variant: "default", rank: 0, raw }; + const matched = + matchClaudePlanTier(raw, upper) || + matchKeywordPlanTier(raw, upper) || + matchTokenPlanTier(raw, upper) || + matchFreePlanTier(raw, upper); + return matched || { ...unknownPlanTier(raw), label: formatUnknownPlanLabel(raw) || "Unknown" }; } // === Card Grid Helpers (T7) ================================================= @@ -691,6 +409,68 @@ export function getNextResetSummary(quotas: any[] | undefined): string | null { return soonestIso ? formatCountdown(soonestIso) : null; } +function addQuotaModelIdVariants(out: Set, provider: string, modelId: string) { + const raw = modelId.trim().toLowerCase(); + const providerId = provider.trim().toLowerCase(); + if (!raw) return; + out.add(raw); + if (!providerId) return; + + const prefix = `${providerId}/`; + if (raw.startsWith(prefix)) { + const stripped = raw.slice(prefix.length); + if (stripped) out.add(stripped); + } else { + out.add(`${providerId}/${raw}`); + } +} + +export function collectHiddenQuotaModelIds(provider: string, payload: unknown): string[] { + const hidden = new Set(); + const data = toRecord(payload); + const collect = (entries: unknown) => { + if (!Array.isArray(entries)) return; + for (const entry of entries) { + const record = toRecord(entry); + if (record.isHidden !== true && record.isDeleted !== true) continue; + if (typeof record.id === "string") addQuotaModelIdVariants(hidden, provider, record.id); + } + }; + + collect(data.models); + collect(data.modelCompatOverrides); + return Array.from(hidden); +} + +export function filterHiddenModelQuotas( + provider: string, + quotas: any[] | undefined, + hiddenModelIds: string[] | undefined +): any[] { + if (!Array.isArray(quotas)) return []; + if (!hiddenModelIds || hiddenModelIds.length === 0) return quotas; + + const hidden = new Set( + hiddenModelIds.map((id) => id.trim().toLowerCase()).filter((id) => id.length > 0) + ); + if (hidden.size === 0) return quotas; + + return quotas.filter((quota) => { + if (!quota || quota.isCredits) return true; + const modelId = + typeof quota.modelKey === "string" + ? quota.modelKey + : typeof quota.modelId === "string" + ? quota.modelId + : ""; + if (!modelId) return true; + + const candidates = new Set(); + addQuotaModelIdVariants(candidates, provider, modelId); + return !Array.from(candidates).some((candidate) => hidden.has(candidate)); + }); +} + // --- Provider dropdown filter (PR #769 port) ----------------------------- // Pure helpers extracted from so the filter+dropdown logic // can be exercised by unit tests without rendering React. Keep them free of diff --git a/src/app/(dashboard)/home/ProviderQuotaWidget.tsx b/src/app/(dashboard)/home/ProviderQuotaWidget.tsx index 41cd067db2..79c4204bd2 100644 --- a/src/app/(dashboard)/home/ProviderQuotaWidget.tsx +++ b/src/app/(dashboard)/home/ProviderQuotaWidget.tsx @@ -28,6 +28,53 @@ function formatAutoRefreshCountdown(ms: number): string { return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; } +export function AutoRefreshButtonLabel({ + autoRefreshIntervalMs, + lastRefreshAllAt, + refreshingAll, + tr, +}: { + autoRefreshIntervalMs: number; + lastRefreshAllAt: number; + refreshingAll: boolean; + tr: (key: string, fallback: string) => string; +}) { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (autoRefreshIntervalMs <= 0 || refreshingAll) return; + + const tick = () => setNow(Date.now()); + tick(); + + const timer = window.setInterval(tick, 1000); + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") tick(); + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + window.clearInterval(timer); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [autoRefreshIntervalMs, refreshingAll, lastRefreshAllAt]); + + if (refreshingAll) { + return <>{tr("refreshing", "Refreshing")}; + } + + if (autoRefreshIntervalMs <= 0) { + return <>{tr("refreshAll", "Refresh All")}; + } + + return ( + <> + {tr("autoRefreshing", "Auto-refreshing")}{" "} + {formatAutoRefreshCountdown(Math.max(0, autoRefreshIntervalMs - (now - lastRefreshAllAt)))} + + ); +} + export default function ProviderQuotaWidget({ autoRefreshInterval = 0 }: ProviderQuotaWidgetProps) { const t = useTranslations("usage"); const tr = useCallback( @@ -42,8 +89,8 @@ export default function ProviderQuotaWidget({ autoRefreshInterval = 0 }: Provide const refreshingAllRef = useRef(false); const lastRefreshAllAtRef = useRef(Date.now()); + const [lastRefreshAllAt, setLastRefreshAllAt] = useState(() => lastRefreshAllAtRef.current); const autoRefreshIntervalMs = autoRefreshInterval > 0 ? autoRefreshInterval * 1000 : 0; - const [autoRefreshClock, setAutoRefreshClock] = useState(() => Date.now()); const fetchConnections = useCallback(async () => { try { @@ -87,30 +134,12 @@ export default function ProviderQuotaWidget({ autoRefreshInterval = 0 }: Provide loadData(); }, [loadData]); - useEffect(() => { - if (autoRefreshIntervalMs <= 0) return; - - const tick = () => setAutoRefreshClock(Date.now()); - tick(); - - const timer = window.setInterval(tick, 1000); - const handleVisibilityChange = () => { - if (document.visibilityState === "visible") tick(); - }; - - document.addEventListener("visibilitychange", handleVisibilityChange); - return () => { - window.clearInterval(timer); - document.removeEventListener("visibilitychange", handleVisibilityChange); - }; - }, [autoRefreshIntervalMs]); - const refreshAll = useCallback(async () => { if (refreshingAllRef.current) return; refreshingAllRef.current = true; const now = Date.now(); lastRefreshAllAtRef.current = now; - setAutoRefreshClock(now); + setLastRefreshAllAt(now); setRefreshingAll(true); try { @@ -120,31 +149,36 @@ export default function ProviderQuotaWidget({ autoRefreshInterval = 0 }: Provide throw new Error(err.error || "Refresh failed"); } const data = await res.json(); - // Re-fetch connections + caches to get fresh state - const [conns, caches] = await Promise.all([fetchConnections(), fetchCached()]); - const relevant = conns.filter( - (c) => - USAGE_SUPPORTED_PROVIDERS.includes(c.provider) && - (c.authType === "oauth" || c.authType === "apikey") - ); - setConnections(relevant); - setQuotaData(caches || data.caches || {}); + setQuotaData(data.caches || {}); } catch (e) { console.error("ProviderQuotaWidget refreshAll error:", e); } finally { refreshingAllRef.current = false; setRefreshingAll(false); } - }, [fetchConnections, fetchCached]); + }, []); useEffect(() => { if (autoRefreshIntervalMs <= 0) return; - if (document.visibilityState !== "visible") return; - if (refreshingAllRef.current) return; - if (autoRefreshClock - lastRefreshAllAtRef.current >= autoRefreshIntervalMs) { - void refreshAll(); - } - }, [autoRefreshClock, autoRefreshIntervalMs, refreshAll]); + + const maybeRefresh = () => { + if (document.visibilityState !== "visible") return; + if (refreshingAllRef.current) return; + if (Date.now() - lastRefreshAllAtRef.current >= autoRefreshIntervalMs) { + void refreshAll(); + } + }; + + maybeRefresh(); + const timer = window.setInterval(maybeRefresh, 1000); + const handleVisibilityChange = () => maybeRefresh(); + + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + window.clearInterval(timer); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [autoRefreshIntervalMs, refreshAll]); // Simple summary: group by provider for display const providerGroups = connections.reduce>((acc, conn) => { @@ -187,16 +221,12 @@ export default function ProviderQuotaWidget({ autoRefreshInterval = 0 }: Provide {autoRefreshIntervalMs > 0 ? "schedule" : "refresh"} - {refreshingAll - ? tr("refreshing", "Refreshing") - : autoRefreshIntervalMs > 0 - ? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown( - Math.max( - 0, - autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current) - ) - )}` - : tr("refreshAll", "Refresh All")} + diff --git a/src/app/api/openapi/spec/route.ts b/src/app/api/openapi/spec/route.ts index 647c1f69e1..a4092bd143 100644 --- a/src/app/api/openapi/spec/route.ts +++ b/src/app/api/openapi/spec/route.ts @@ -6,7 +6,7 @@ import { NextResponse } from "next/server"; import fs from "fs"; import path from "path"; -import yaml from "js-yaml"; +import * as yaml from "js-yaml"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; let cachedSpec: { data: any; mtime: number } | null = null; diff --git a/src/app/api/provider-nodes/validate/route.ts b/src/app/api/provider-nodes/validate/route.ts index 22d9136f73..024818fcaa 100644 --- a/src/app/api/provider-nodes/validate/route.ts +++ b/src/app/api/provider-nodes/validate/route.ts @@ -30,6 +30,54 @@ function sanitizeClaudeCodeCompatibleBaseUrl(baseUrl: string) { .replace(/\/(?:v\d+\/)?messages(?:\?[^#]*)?$/i, ""); } +// Status-specific error message for /models probe failures. +function getModelsErrorMessage(status: number) { + if (status === 401 || status === 403) return "API key unauthorized"; + if (status === 404) { + return "/models endpoint not found - enter a Model ID to validate via chat/completions instead"; + } + if (status >= 500) return "Server error - try again later"; + return `Unexpected response (${status})`; +} + +// Status-specific error message for the /chat/completions fallback probe. +function getChatErrorMessage(status: number) { + if (status === 401 || status === 403) return "API key unauthorized"; + if (status === 400) return "Invalid model or bad request"; + if (status === 404) return "Chat endpoint not found"; + if (status >= 500) return "Server error - try again later"; + return `Chat request failed (${status})`; +} + +async function probeChatFallback({ + baseUrl, + apiKey, + modelId, + extraHeaders = {}, +}: { + baseUrl: string; + apiKey: string; + modelId: string; + extraHeaders?: Record; +}) { + const chatUrl = `${baseUrl.replace(/\/$/, "")}/chat/completions`; + return safeOutboundFetch(chatUrl, { + ...SAFE_OUTBOUND_FETCH_PRESETS.validationRead, + guard: getProviderOutboundGuard(), + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + ...extraHeaders, + }, + body: JSON.stringify({ + model: modelId, + messages: [{ role: "user", content: "ping" }], + max_tokens: 1, + }), + }); +} + function sanitizeAuditBaseUrl(baseUrl: string) { if (!baseUrl) return null; try { @@ -66,7 +114,8 @@ export async function POST(request) { if (isValidationFailure(validation)) { return NextResponse.json({ error: validation.error }, { status: 400 }); } - const { baseUrl, apiKey, type, compatMode, chatPath, modelsPath } = validation.data; + const { baseUrl, apiKey, type, compatMode, chatPath, modelsPath, modelId } = validation.data; + const trimmedModelId = typeof modelId === "string" ? modelId.trim() : ""; // Anthropic Compatible Validation if (type === "anthropic-compatible") { @@ -111,18 +160,57 @@ export async function POST(request) { }, }); - return NextResponse.json({ valid: res.ok, error: res.ok ? null : "Invalid API key" }); + if (res.ok) return NextResponse.json({ valid: true, error: null }); + // Auth errors: chat fallback would not recover. Skip and surface clear message. + if (res.status === 401 || res.status === 403) { + return NextResponse.json({ valid: false, error: "API key unauthorized" }); + } + // Optional /chat/completions fallback when caller supplied a model ID + // (some Anthropic-compatible proxies expose only the chat endpoint). + if (trimmedModelId) { + const chatRes = await probeChatFallback({ + baseUrl: normalizedBase, + apiKey: apiKey ?? "", + modelId: trimmedModelId, + extraHeaders: { "x-api-key": apiKey ?? "", "anthropic-version": "2023-06-01" }, + }); + if (chatRes.ok) return NextResponse.json({ valid: true, error: null, method: "chat" }); + return NextResponse.json({ + valid: false, + error: getChatErrorMessage(chatRes.status), + method: "chat", + }); + } + return NextResponse.json({ valid: false, error: getModelsErrorMessage(res.status) }); } // OpenAI Compatible Validation (Default) - const modelsUrl = `${baseUrl.replace(/\/$/, "")}${modelsPath || "/models"}`; + const openAiBase = baseUrl.replace(/\/$/, ""); + const modelsUrl = `${openAiBase}${modelsPath || "/models"}`; const res = await safeOutboundFetch(modelsUrl, { ...SAFE_OUTBOUND_FETCH_PRESETS.validationRead, guard: getProviderOutboundGuard(), headers: { Authorization: `Bearer ${apiKey}` }, }); - return NextResponse.json({ valid: res.ok, error: res.ok ? null : "Invalid API key" }); + if (res.ok) return NextResponse.json({ valid: true, error: null }); + if (res.status === 401 || res.status === 403) { + return NextResponse.json({ valid: false, error: "API key unauthorized" }); + } + if (trimmedModelId) { + const chatRes = await probeChatFallback({ + baseUrl: openAiBase, + apiKey: apiKey ?? "", + modelId: trimmedModelId, + }); + if (chatRes.ok) return NextResponse.json({ valid: true, error: null, method: "chat" }); + return NextResponse.json({ + valid: false, + error: getChatErrorMessage(chatRes.status), + method: "chat", + }); + } + return NextResponse.json({ valid: false, error: getModelsErrorMessage(res.status) }); } catch (error) { const status = getSafeOutboundFetchErrorStatus(error); if (status) { diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index 811da78b65..00bc0cf398 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -26,6 +26,19 @@ import { isCliTokenAuthValid } from "@/lib/middleware/cliTokenAuth"; import { extractApiKey } from "@/sse/services/auth"; import { getApiKeyMetadata } from "@/lib/db/apiKeys"; +/** + * Force this route to run dynamically per-request and never be cached/prerendered. + * Combined with the `Cache-Control: no-store` response header below, this keeps + * persisted settings (e.g. dashboard preferences, debugMode, hidden sidebar + * items) visible immediately after refresh or restart instead of falling back + * to stale Next.js fetch cache. Ported from upstream decolua/9router#951. + */ +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +/** Response headers applied to every successful GET/PATCH on /api/settings. */ +const SETTINGS_RESPONSE_HEADERS = { "Cache-Control": "no-store" } as const; + /** * Settings keys whose change broadens attack surface. Spec §Security: * password re-auth is required when any of these is present in a PATCH body. @@ -162,19 +175,22 @@ export async function GET(request: Request) { // best effort — don't fail GET /api/settings if this lookup fails } - return NextResponse.json({ - ...safeSettings, - hasPassword: hasManagementPasswordConfigured(settings), - runtimePorts, - apiPort: runtimePorts.apiPort, - dashboardPort: runtimePorts.dashboardPort, - cloudConfigured: Boolean(cloudUrl), - cloudUrl, - machineId, - ...(cliproxyapiModelMapping !== null - ? { cliproxyapi_model_mapping: cliproxyapiModelMapping } - : {}), - }); + return NextResponse.json( + { + ...safeSettings, + hasPassword: hasManagementPasswordConfigured(settings), + runtimePorts, + apiPort: runtimePorts.apiPort, + dashboardPort: runtimePorts.dashboardPort, + cloudConfigured: Boolean(cloudUrl), + cloudUrl, + machineId, + ...(cliproxyapiModelMapping !== null + ? { cliproxyapi_model_mapping: cliproxyapiModelMapping } + : {}), + }, + { headers: SETTINGS_RESPONSE_HEADERS } + ); } catch (error) { console.log("Error getting settings:", error); return NextResponse.json({ error: "Failed to load settings" }, { status: 500 }); @@ -376,7 +392,7 @@ export async function PATCH(request: Request) { } const { password, ...safeSettings } = settings; - return NextResponse.json(safeSettings); + return NextResponse.json(safeSettings, { headers: SETTINGS_RESPONSE_HEADERS }); } catch (error) { console.log("Error updating settings:", error); return NextResponse.json({ error: "Failed to update settings" }, { status: 500 }); diff --git a/src/app/api/v1/models/[...model]/route.ts b/src/app/api/v1/models/[...model]/route.ts new file mode 100644 index 0000000000..cb3d22e8b0 --- /dev/null +++ b/src/app/api/v1/models/[...model]/route.ts @@ -0,0 +1,26 @@ +import { handleCorsOptions } from "@/shared/utils/cors"; +import { getUnifiedModelsResponse } from "../catalog"; +import { handleGetModelById } from "../modelById"; + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return handleCorsOptions(); +} + +/** + * GET /v1/models/{model} — OpenAI-compatible single-model retrieval (#4674). + * + * Catch-all (`[...model]`) so provider-prefixed ids that contain a slash + * (e.g. `cgpt-web/gpt-5.5`, `claude/claude-sonnet-4-6`) are captured intact. + */ +export async function GET( + request: Request, + { params }: { params: Promise<{ model: string[] }> } +) { + const { model } = await params; + const segments = Array.isArray(model) ? model : [model]; + const requestedId = decodeURIComponent(segments.join("/")); + return handleGetModelById(request, requestedId, getUnifiedModelsResponse); +} diff --git a/src/app/api/v1/models/modelById.ts b/src/app/api/v1/models/modelById.ts new file mode 100644 index 0000000000..5a8b2eadb8 --- /dev/null +++ b/src/app/api/v1/models/modelById.ts @@ -0,0 +1,62 @@ +import { CORS_HEADERS } from "@/shared/utils/cors"; + +/** + * #4674 — Shared logic for `GET /v1/models/{model}`. + * + * Before this route existed, a request for a single model fell through to the + * Next.js catch-all and returned the HTML dashboard, which broke Claude Code's + * model-validation probe (it expects the OpenAI `{ id, object: "model", ... }` + * shape). Kept in a sibling module (not the route file) so the lookup stays + * unit-testable without the DB-backed catalog. + */ + +type CatalogModel = { id?: unknown } & Record; + +/** Find a model entry in the unified catalog `data` array by its exact id. */ +export function findModelById( + data: CatalogModel[] | null | undefined, + requestedId: string +): CatalogModel | null { + if (!Array.isArray(data)) return null; + return data.find((m) => typeof m?.id === "string" && m.id === requestedId) ?? null; +} + +/** + * Resolve a single model from the unified catalog response. + * + * @param getModels Returns the `{ object: "list", data: [...] }` Response — in + * the route this is `getUnifiedModelsResponse`; tests inject a fake. + */ +export async function handleGetModelById( + request: Request, + requestedId: string, + getModels: (request: Request, corsHeaders?: Record) => Promise +): Promise { + const listResp = await getModels(request, CORS_HEADERS); + // Propagate auth rejections / 5xx (already JSON) unchanged. + if (!listResp.ok) return listResp; + + let data: CatalogModel[] | undefined; + try { + const body = (await listResp.json()) as { data?: CatalogModel[] }; + data = body?.data; + } catch { + data = undefined; + } + + const found = findModelById(data, requestedId); + if (found) { + return Response.json(found, { headers: CORS_HEADERS }); + } + + return Response.json( + { + error: { + message: `The model '${requestedId}' does not exist`, + type: "invalid_request_error", + code: "model_not_found", + }, + }, + { status: 404, headers: CORS_HEADERS } + ); +} diff --git a/src/app/api/v1/relay/chat/completions/bifrost/route.ts b/src/app/api/v1/relay/chat/completions/bifrost/route.ts index a6b4bf2aba..936cbc7a64 100644 --- a/src/app/api/v1/relay/chat/completions/bifrost/route.ts +++ b/src/app/api/v1/relay/chat/completions/bifrost/route.ts @@ -36,8 +36,14 @@ import { recordRelayUsage, } from "@/lib/db/relayProxies"; import { buildErrorBody } from "@omniroute/open-sse/utils/error"; -import { createHash } from "node:crypto"; import { z } from "zod"; +import { + checkIpRateLimit, + extractToken, + getClientIp, + hashToken, + sanitizeForensicHeader, +} from "../relaySecurity"; // Minimal request-shape validation (Rule #7). `.passthrough()` keeps every other // OpenAI chat-completion field intact (temperature, tools, response_format, …) — @@ -64,33 +70,53 @@ const BIFROST_ENABLED = process.env.BIFROST_ENABLED !== "0"; const injectionGuard = createInjectionGuard(); +type RelayUsageRecorder = (status: "success" | "error", statusCode: number) => void; + export async function OPTIONS() { return handleCorsOptions(); } -function sanitizeForensicHeader(value: string | null, max = 256): string { - if (!value) return "unknown"; - return value.replace(/[\r\n]+/g, " ").slice(0, max); -} +function finalizeReadableStream( + body: ReadableStream, + onFinalize: (error?: unknown) => void +): ReadableStream { + const reader = body.getReader(); + let finalized = false; -function extractToken(request: Request): string | null { - const auth = request.headers.get("authorization") || ""; - const match = auth.match(/^Bearer\s+(.+)$/i); - if (match) return match[1]; - return request.headers.get("x-relay-token"); -} + const finalizeOnce = (error?: unknown) => { + if (finalized) return; + finalized = true; + onFinalize(error); + }; -function hashToken(token: string): string { - return createHash("sha256").update(token).digest("hex"); + return new ReadableStream({ + async pull(controller) { + try { + const { done, value } = await reader.read(); + if (done) { + finalizeOnce(); + controller.close(); + return; + } + controller.enqueue(value); + } catch (error) { + finalizeOnce(error); + controller.error(error); + } + }, + async cancel(reason) { + try { + await reader.cancel(reason); + } finally { + finalizeOnce(reason); + } + }, + }); } export async function POST(request: Request) { const startTime = Date.now(); - const clientIp = sanitizeForensicHeader( - request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || - request.headers.get("x-real-ip") || - null - ); + const clientIp = getClientIp(request); const userAgent = sanitizeForensicHeader(request.headers.get("user-agent")); if (!BIFROST_ENABLED) { @@ -168,6 +194,28 @@ export async function POST(request: Request) { }); } + // 2a. Per-(token,IP) gate — mirrors the TypeScript relay fallback so the + // sidecar path does not weaken leaked-token abuse protection. + const ipCheck = checkIpRateLimit(token.id, clientIp); + if (!ipCheck.allowed) { + recordRelayUsage(token.id, { + requestId: request.headers.get("x-request-id") || undefined, + status: "rate_limited", + statusCode: 429, + latencyMs: Date.now() - startTime, + clientIp, + userAgent, + }); + return new Response(JSON.stringify(buildErrorBody(429, "Per-IP rate limit exceeded")), { + status: 429, + headers: { + ...JSON_CORS_HEADERS, + "Retry-After": String(ipCheck.resetIn), + "X-RateLimit-Scope": "ip", + }, + }); + } + const rateCheck = checkRateLimit(token.id); if (!rateCheck.allowed) { recordRelayUsage(token.id, { @@ -254,7 +302,11 @@ export async function POST(request: Request) { } const ac = new AbortController(); - const tid = setTimeout(() => ac.abort(), BIFROST_TIMEOUT_MS); + let timedOut = false; + const tid = setTimeout(() => { + timedOut = true; + ac.abort(); + }, BIFROST_TIMEOUT_MS); let upstream: Response; try { @@ -264,20 +316,23 @@ export async function POST(request: Request) { body: JSON.stringify(body), signal: ac.signal, }); - } finally { + } catch (error) { clearTimeout(tid); + throw error; } - // 5. Forward response. For streaming we pass the body stream through - // unmodified — Node's fetch streams chunked correctly. - recordRelayUsage(token.id, { - requestId: request.headers.get("x-request-id") || undefined, - status: upstream.status < 500 ? "success" : "error", - statusCode: upstream.status, - latencyMs: Date.now() - startTime, - clientIp, - userAgent, - }); + // 5. Forward response. Non-streaming responses can be accounted for as soon + // as headers arrive; streaming responses finalize on body close/cancel/error. + const recordUsage: RelayUsageRecorder = (status, statusCode) => { + recordRelayUsage(token.id, { + requestId: request.headers.get("x-request-id") || undefined, + status, + statusCode, + latencyMs: Date.now() - startTime, + clientIp, + userAgent, + }); + }; const newHeaders = new Headers(upstream.headers); newHeaders.set("X-Routed-By", "bifrost"); @@ -286,6 +341,23 @@ export async function POST(request: Request) { newHeaders.set("Content-Type", upstream.headers.get("Content-Type") ?? "application/json"); } + if (wantsStream && upstream.body) { + const stream = finalizeReadableStream(upstream.body, (error) => { + clearTimeout(tid); + const statusCode = timedOut ? 504 : upstream.status; + const status = error || statusCode >= 500 ? "error" : "success"; + recordUsage(status, statusCode); + }); + + return new Response(stream, { + status: upstream.status, + headers: newHeaders, + }); + } + + clearTimeout(tid); + recordUsage(upstream.status < 500 ? "success" : "error", upstream.status); + return new Response(upstream.body, { status: upstream.status, headers: newHeaders, @@ -311,4 +383,4 @@ export async function POST(request: Request) { } ); } -} \ No newline at end of file +} diff --git a/src/app/api/v1/relay/chat/completions/relaySecurity.ts b/src/app/api/v1/relay/chat/completions/relaySecurity.ts new file mode 100644 index 0000000000..2090093f70 --- /dev/null +++ b/src/app/api/v1/relay/chat/completions/relaySecurity.ts @@ -0,0 +1,72 @@ +import { createHash } from "node:crypto"; + +// Forensic-only sanitization: client IP / user-agent come from untrusted +// headers and feed recordRelayUsage() rows. Strip CR/LF so a malicious header +// cannot forge log lines, and cap length. +export function sanitizeForensicHeader(value: string | null, max = 256): string { + if (!value) return "unknown"; + return value.replace(/[\r\n]+/g, " ").slice(0, max); +} + +export function getClientIp(request: Request): string { + return sanitizeForensicHeader( + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + request.headers.get("x-real-ip") || + null + ); +} + +export function extractToken(request: Request): string | null { + const auth = request.headers.get("authorization") || ""; + const match = auth.match(/^Bearer\s+(.+)$/i); + if (match) return match[1]; + + // Also check X-Relay-Token header. + return request.headers.get("x-relay-token"); +} + +export function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +// ── In-memory per-(token,IP) rate limit ───────────────────────────────────── +// Defence-in-depth on top of the DB-backed per-token limit: a leaked relay +// token redistributed across N IPs would otherwise consume the per-token quota +// in parallel. This second gate caps a *single* IP using a token to +// RELAY_IP_PER_MINUTE req/min. +// +// In-memory by design: cheap, no DB round-trip, no extra migration. Per +// instance only — if you run multiple relay replicas behind a load balancer, +// the effective ceiling is RELAY_IP_PER_MINUTE * replicas. +const RELAY_IP_PER_MINUTE = Number(process.env.RELAY_IP_PER_MINUTE || "30"); +const ipBuckets = new Map(); + +export function checkIpRateLimit(tokenId: string, ip: string): { allowed: boolean; resetIn: number } { + if (!Number.isFinite(RELAY_IP_PER_MINUTE) || RELAY_IP_PER_MINUTE <= 0) { + return { allowed: true, resetIn: 0 }; + } + + const now = Math.floor(Date.now() / 1000); + const windowStart = Math.floor(now / 60) * 60; + const key = tokenId + "|" + ip; + const bucket = ipBuckets.get(key); + + if (!bucket || bucket.windowStart !== windowStart) { + ipBuckets.set(key, { count: 1, windowStart }); + if (ipBuckets.size > 10_000) { + // Bound memory: drop stale buckets when the table grows past 10k. + const cutoff = windowStart - 60; + for (const [k, b] of ipBuckets) { + if (b.windowStart < cutoff) ipBuckets.delete(k); + } + } + return { allowed: true, resetIn: 60 - (now % 60) }; + } + + if (bucket.count >= RELAY_IP_PER_MINUTE) { + return { allowed: false, resetIn: 60 - (now % 60) }; + } + + bucket.count++; + return { allowed: true, resetIn: 60 - (now % 60) }; +} diff --git a/src/app/api/v1/relay/chat/completions/route.ts b/src/app/api/v1/relay/chat/completions/route.ts index 8046b7c835..5d97fd2b2f 100644 --- a/src/app/api/v1/relay/chat/completions/route.ts +++ b/src/app/api/v1/relay/chat/completions/route.ts @@ -11,84 +11,25 @@ import { handleChat } from "@/sse/handlers/chat"; import { createInjectionGuard } from "@/middleware/promptInjectionGuard"; import { getRelayTokenByHash, checkRateLimit, recordRelayUsage } from "@/lib/db/relayProxies"; import { buildErrorBody } from "@omniroute/open-sse/utils/error"; -import { createHash } from "node:crypto"; +import { + checkIpRateLimit, + extractToken, + getClientIp, + hashToken, + sanitizeForensicHeader, +} from "./relaySecurity"; const JSON_CORS_HEADERS = { ...CORS_HEADERS, "Content-Type": "application/json" } as const; -// Forensic-only sanitization: client IP / user-agent come from untrusted -// headers and feed `recordRelayUsage()` rows. Strip CR/LF so a malicious -// header cannot forge log lines, and cap length. -function sanitizeForensicHeader(value: string | null, max = 256): string { - if (!value) return "unknown"; - return value.replace(/[\r\n]+/g, " ").slice(0, max); -} - -// ── In-memory per-(token,IP) rate limit ───────────────────────────────────── -// Defence-in-depth on top of the DB-backed per-token limit: a leaked relay -// token redistributed across N IPs would otherwise consume the per-token -// quota in parallel. This second gate caps a *single* IP using a token to -// `RELAY_IP_PER_MINUTE` req/min — legit clients keep working, distributed -// abuse of one token hits the wall fast. -// -// In-memory by design: cheap, no DB round-trip, no extra migration. Per -// instance only — if you run multiple relay replicas behind a load balancer, -// the effective ceiling is `RELAY_IP_PER_MINUTE * replicas`, which is still -// orders of magnitude tighter than no gate. -const RELAY_IP_PER_MINUTE = Number(process.env.RELAY_IP_PER_MINUTE || "30"); -const ipBuckets = new Map(); - -function checkIpRateLimit(tokenId: string, ip: string): { allowed: boolean; resetIn: number } { - if (!Number.isFinite(RELAY_IP_PER_MINUTE) || RELAY_IP_PER_MINUTE <= 0) { - return { allowed: true, resetIn: 0 }; - } - const now = Math.floor(Date.now() / 1000); - const windowStart = Math.floor(now / 60) * 60; - const key = tokenId + "|" + ip; - const bucket = ipBuckets.get(key); - if (!bucket || bucket.windowStart !== windowStart) { - ipBuckets.set(key, { count: 1, windowStart }); - if (ipBuckets.size > 10_000) { - // Bound memory: drop the oldest half when the table grows past 10k. - const cutoff = windowStart - 60; - for (const [k, b] of ipBuckets) { - if (b.windowStart < cutoff) ipBuckets.delete(k); - } - } - return { allowed: true, resetIn: 60 - (now % 60) }; - } - if (bucket.count >= RELAY_IP_PER_MINUTE) { - return { allowed: false, resetIn: 60 - (now % 60) }; - } - bucket.count++; - return { allowed: true, resetIn: 60 - (now % 60) }; -} - const injectionGuard = createInjectionGuard(); export async function OPTIONS() { return handleCorsOptions(); } -function extractToken(request: Request): string | null { - const auth = request.headers.get("authorization") || ""; - const match = auth.match(/^Bearer\s+(.+)$/i); - if (match) return match[1]; - - // Also check X-Relay-Token header - return request.headers.get("x-relay-token"); -} - -function hashToken(token: string): string { - return createHash("sha256").update(token).digest("hex"); -} - export async function POST(request: Request) { const startTime = Date.now(); - const clientIp = sanitizeForensicHeader( - request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || - request.headers.get("x-real-ip") || - null - ); + const clientIp = getClientIp(request); const userAgent = sanitizeForensicHeader(request.headers.get("user-agent")); try { diff --git a/src/domain/assessment/types.ts b/src/domain/assessment/types.ts index d5103571c5..b20e07c8a0 100644 --- a/src/domain/assessment/types.ts +++ b/src/domain/assessment/types.ts @@ -347,6 +347,15 @@ export const AUTO_COMBO_TEMPLATES: AutoComboTemplate[] = [ tiers: ["premium", "balanced"], strategy: "priority", }, + { + name: "auto/best-free", + displayName: "Best Free", + categories: ["coding", "chat", "fast"], + tiers: ["free"], + strategy: "weighted", + systemMessage: + "You are a helpful coding assistant. Write clean, efficient code.", + }, ]; export {}; diff --git a/src/hooks/useLiveDashboard.ts b/src/hooks/useLiveDashboard.ts index e216b7a584..bd72bdb57f 100644 --- a/src/hooks/useLiveDashboard.ts +++ b/src/hooks/useLiveDashboard.ts @@ -50,6 +50,8 @@ export interface DashboardConnectionState { export interface UseLiveDashboardOptions { /** WebSocket URL (default: ws://hostname:20129) */ wsUrl?: string; + /** Whether the WebSocket connection should be active (default: true) */ + enabled?: boolean; /** API key for authentication */ apiKey?: string; /** Channels to subscribe to */ @@ -66,6 +68,7 @@ export interface UseLiveDashboardOptions { */ export function useLiveDashboard({ wsUrl = DEFAULT_WS_URL, + enabled = true, apiKey, channels = ["requests", "combo", "credentials"], autoReconnect = true, @@ -202,6 +205,23 @@ export function useLiveDashboard({ // Connect on mount and on reconnect trigger useEffect(() => { + mountedRef.current = true; + if (!enabled) { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + wsRef.current?.close(); + wsRef.current = null; + setConnection({ + isConnected: false, + isConnecting: false, + error: null, + reconnectAttempt: 0, + }); + return; + } + connect(); return () => { mountedRef.current = false; @@ -210,7 +230,7 @@ export function useLiveDashboard({ } wsRef.current?.close(); }; - }, [connect]); + }, [connect, enabled]); // Connect (for manual retry) const reconnect = useCallback(() => { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index c676e87f12..6d650df399 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4326,6 +4326,9 @@ "editCompatibleTitle": "Edit {type} Compatible", "compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.", "apiKeyForCheck": "API Key (for Check)", + "testModelIdLabel": "Model ID (optional)", + "testModelIdPlaceholder": "e.g. my-model-id", + "testModelIdHint": "If the provider lacks a /models endpoint, enter a model ID to validate via /chat/completions instead.", "compatibleProdPlaceholder": "{type} Compatible (Prod)", "tokenRefreshed": "Token refreshed successfully", "tokenRefreshFailed": "Token refresh failed", diff --git a/src/lib/agentSkills/openapiParser.ts b/src/lib/agentSkills/openapiParser.ts index ffdf95ed84..c6cc0648fb 100644 --- a/src/lib/agentSkills/openapiParser.ts +++ b/src/lib/agentSkills/openapiParser.ts @@ -9,7 +9,7 @@ import fs from "node:fs"; import path from "node:path"; -import yaml from "js-yaml"; +import * as yaml from "js-yaml"; import type { SkillArea } from "./types"; // ── Types ──────────────────────────────────────────────────────────────────── @@ -159,7 +159,6 @@ export function parseOpenapi(): ParsedOpenapi { ); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any const doc = yaml.load(rawContent) as Record; if (!doc || typeof doc !== "object") { diff --git a/src/lib/cli-helper/log-streamer.ts b/src/lib/cli-helper/log-streamer.ts index 9a4c237fed..1fdc151848 100644 --- a/src/lib/cli-helper/log-streamer.ts +++ b/src/lib/cli-helper/log-streamer.ts @@ -1,11 +1,9 @@ -import os from "node:os"; -import path from "node:path"; - export interface LogStreamOptions { baseUrl?: string; filters?: string[]; follow?: boolean; timeout?: number; + headers?: HeadersInit; } export interface LogStream { @@ -18,6 +16,7 @@ export function createLogStream(options: LogStreamOptions = {}): LogStream { const filters = options.filters || []; const follow = options.follow ?? false; const timeout = options.timeout || 30000; + const headers = options.headers; const controller = new AbortController(); const { signal } = controller; @@ -35,7 +34,7 @@ export function createLogStream(options: LogStreamOptions = {}): LogStream { }, timeout); try { - const response = await fetch(url, { signal }); + const response = await fetch(url, { signal, headers }); if (!response.ok) { controller.error(new Error(`HTTP ${response.status}: ${response.statusText}`)); diff --git a/src/lib/db/cleanup.ts b/src/lib/db/cleanup.ts index 1772240fdf..83563f11a2 100644 --- a/src/lib/db/cleanup.ts +++ b/src/lib/db/cleanup.ts @@ -64,7 +64,7 @@ export async function cleanupCallLogs(): Promise { const result: CleanupResult = { deleted: 0, errors: 0 }; try { - const stmt = db.prepare("DELETE FROM call_logs WHERE created_at < ?"); + const stmt = db.prepare("DELETE FROM call_logs WHERE timestamp < ?"); const runResult = stmt.run(cutoffISO); result.deleted = runResult.changes; @@ -141,7 +141,7 @@ export async function cleanupCompressionAnalytics(): Promise { const result: CleanupResult = { deleted: 0, errors: 0 }; try { - const stmt = db.prepare("DELETE FROM compression_analytics WHERE created_at < ?"); + const stmt = db.prepare("DELETE FROM compression_analytics WHERE timestamp < ?"); const runResult = stmt.run(cutoffISO); result.deleted = runResult.changes; @@ -171,7 +171,7 @@ export async function cleanupMcpAudit(): Promise { const result: CleanupResult = { deleted: 0, errors: 0 }; try { - const stmt = db.prepare("DELETE FROM mcp_audit_log WHERE timestamp < ?"); + const stmt = db.prepare("DELETE FROM mcp_tool_audit WHERE timestamp < ?"); const runResult = stmt.run(cutoffISO); result.deleted = runResult.changes; @@ -201,7 +201,7 @@ export async function cleanupA2aEvents(): Promise { const result: CleanupResult = { deleted: 0, errors: 0 }; try { - const stmt = db.prepare("DELETE FROM a2a_events WHERE timestamp < ?"); + const stmt = db.prepare("DELETE FROM a2a_task_events WHERE timestamp < ?"); const runResult = stmt.run(cutoffISO); result.deleted = runResult.changes; @@ -229,7 +229,7 @@ export async function cleanupMemoryEntries(): Promise { const result: CleanupResult = { deleted: 0, errors: 0 }; try { - const stmt = db.prepare("DELETE FROM memory_entries WHERE created_at < ?"); + const stmt = db.prepare("DELETE FROM memories WHERE created_at < ?"); const runResult = stmt.run(cutoffISO); result.deleted = runResult.changes; @@ -270,6 +270,7 @@ export async function runAutoCleanup(): Promise<{ mcpAudit: await cleanupMcpAudit(), a2aEvents: await cleanupA2aEvents(), memoryEntries: await cleanupMemoryEntries(), + proxyLogs: await cleanupProxyLogs(), }; const totalDeleted = Object.values(results).reduce((sum, r) => sum + r.deleted, 0); @@ -349,3 +350,114 @@ export async function purgeDetailedLogs(): Promise { return result; } + +/** + * Clean up old proxy_logs based on retention settings. + * Uses the same retention period as call_logs (30 days default). + */ +export async function cleanupProxyLogs(): Promise { + const db = getDbInstance(); + const retention = getRetentionSettings(); + + const retentionDays = retention.callLogs; + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - retentionDays); + const cutoffISO = cutoffDate.toISOString(); + + const result: CleanupResult = { deleted: 0, errors: 0 }; + + try { + const stmt = db.prepare("DELETE FROM proxy_logs WHERE timestamp < ?"); + const runResult = stmt.run(cutoffISO); + result.deleted = runResult.changes; + + console.log( + `[Cleanup] Deleted ${result.deleted} proxy_logs older than ${retentionDays} days` + ); + } catch (err: unknown) { + console.error("[Cleanup] Error cleaning proxy_logs:", err); + result.errors++; + } + + return result; +} + +// ──────────────── Background Cleanup Scheduler ──────────────── + +const CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours +let _cleanupSchedulerTimer: ReturnType | null = null; + +/** + * Start the background cleanup scheduler. Runs cleanup on startup + * and then every 6 hours. Runs VACUUM after deletes to reclaim disk space. + * + * Without this, tables grow unboundedly (compression_analytics 600K+ rows, + * usage_history 250K+ rows) causing 1.4GB+ SQLite files and 3-8GB RSS + * from better-sqlite3 memory mapping. + */ +export function startCleanupScheduler(): void { + if (_cleanupSchedulerTimer) return; + + // Run cleanup 30s after startup (let the server initialize first). + setTimeout(async () => { + try { + const result = await runAutoCleanup(); + const proxyResult = await cleanupProxyLogs(); + const totalDeleted = result.totalDeleted + proxyResult.deleted; + if (totalDeleted > 0) { + console.log( + `[Cleanup] Startup cleanup freed ${totalDeleted} rows. Running VACUUM...` + ); + try { + const db = getDbInstance(); + db.exec("VACUUM"); + console.log("[Cleanup] VACUUM completed after startup cleanup."); + } catch (vacErr) { + console.error("[Cleanup] VACUUM after cleanup failed:", vacErr); + } + } + } catch (err) { + console.error("[Cleanup] Startup cleanup failed:", err); + } + }, 30_000); + + // Schedule periodic cleanup every 6 hours. + _cleanupSchedulerTimer = setInterval(async () => { + try { + const result = await runAutoCleanup(); + const proxyResult = await cleanupProxyLogs(); + const totalDeleted = result.totalDeleted + proxyResult.deleted; + if (totalDeleted > 0) { + console.log( + `[Cleanup] Periodic cleanup freed ${totalDeleted} rows. Running VACUUM...` + ); + try { + const db = getDbInstance(); + db.exec("VACUUM"); + console.log("[Cleanup] VACUUM completed after periodic cleanup."); + } catch (vacErr) { + console.error("[Cleanup] VACUUM after cleanup failed:", vacErr); + } + } + } catch (err) { + console.error("[Cleanup] Periodic cleanup failed:", err); + } + }, CLEANUP_INTERVAL_MS); + + // Don't keep the process alive solely for cleanup. + if (_cleanupSchedulerTimer && typeof _cleanupSchedulerTimer.unref === "function") { + _cleanupSchedulerTimer.unref(); + } + + console.log("[Cleanup] Background cleanup scheduler started (every 6 hours)."); +} + +/** + * Stop the background cleanup scheduler (for tests). + */ +export function stopCleanupScheduler(): void { + if (_cleanupSchedulerTimer) { + clearInterval(_cleanupSchedulerTimer); + _cleanupSchedulerTimer = null; + } +} diff --git a/src/lib/modelCapabilities.ts b/src/lib/modelCapabilities.ts index 27f3ada397..940e94a260 100644 --- a/src/lib/modelCapabilities.ts +++ b/src/lib/modelCapabilities.ts @@ -53,7 +53,7 @@ export interface ResolvedModelCapabilities { temperature: boolean | null; contextWindow: number | null; maxInputTokens: number | null; - maxOutputTokens: number; + maxOutputTokens: number | null; defaultThinkingBudget: number; thinkingBudgetCap: number | null; thinkingOverhead: number | null; @@ -382,7 +382,7 @@ export function getResolvedModelCapabilities(input: CapabilityInput): ResolvedMo synced?.limit_output ?? (typeof registryModel?.maxOutputTokens === "number" ? registryModel.maxOutputTokens : null) ?? spec?.maxOutputTokens ?? - MODEL_SPECS.__default__.maxOutputTokens, + null, defaultThinkingBudget: spec?.defaultThinkingBudget ?? 0, thinkingBudgetCap: spec?.thinkingBudgetCap ?? null, thinkingOverhead: spec?.thinkingOverhead ?? null, @@ -416,9 +416,11 @@ export function supportsMaxTokens(input: CapabilityInput): boolean { return getResolvedModelCapabilities(input).supportsMaxTokens; } -export function capMaxOutputTokens(input: CapabilityInput, requested?: number): number { +export function capMaxOutputTokens(input: CapabilityInput, requested?: number): number | null { const cap = getResolvedModelCapabilities(input).maxOutputTokens; - return requested ? Math.min(requested, cap) : cap; + const hasRequested = typeof requested === "number" && Number.isFinite(requested); + if (cap === null) return hasRequested ? requested : null; + return hasRequested ? Math.min(requested, cap) : cap; } export function getDefaultThinkingBudget(input: CapabilityInput): number { diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index fcd07ef76e..f415963fa6 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -99,8 +99,8 @@ export const GEMINI_CONFIG = { // Qwen OAuth Configuration (Device Code Flow with PKCE) export const QWEN_CONFIG = { clientId: resolvePublicCred("qwen_id", "QWEN_OAUTH_CLIENT_ID"), - deviceCodeUrl: "https://chat.qwen.ai/api/v1/oauth2/device/code", - tokenUrl: "https://chat.qwen.ai/api/v1/oauth2/token", + deviceCodeUrl: "https://qwen.ai/api/v1/oauth2/device/code", + tokenUrl: "https://qwen.ai/api/v1/oauth2/token", scope: "openid profile email model.completion", codeChallengeMethod: "S256", }; diff --git a/src/lib/providers/requestDefaults.ts b/src/lib/providers/requestDefaults.ts index 479d6f532f..a517484964 100644 --- a/src/lib/providers/requestDefaults.ts +++ b/src/lib/providers/requestDefaults.ts @@ -209,6 +209,13 @@ export function sanitizeProviderSpecificDataForResponse(value: unknown): JsonRec delete sanitized.awsSecretAccessKey; delete sanitized.sessionToken; delete sanitized.awsSessionToken; + delete sanitized.openCodeGoAuthCookie; + delete sanitized.opencodeGoAuthCookie; + delete sanitized.authCookie; + delete sanitized.ollamaUsageCookie; + delete sanitized.ollamaCloudUsageCookie; + delete sanitized.ollamaCloudCookie; + delete sanitized.usageCookie; return sanitized; } diff --git a/src/lib/resilience/settings.ts b/src/lib/resilience/settings.ts index 4dd3b5dc71..b726ab1c9e 100644 --- a/src/lib/resilience/settings.ts +++ b/src/lib/resilience/settings.ts @@ -1,4 +1,5 @@ import { DEFAULT_API_LIMITS, PROVIDER_PROFILES } from "@omniroute/open-sse/config/constants"; +import { resolveFeatureFlag } from "@/shared/utils/featureFlags"; type JsonRecord = Record; type AuthCategory = "oauth" | "apikey"; @@ -105,7 +106,7 @@ export interface StreamRecoverySettings { * open-sse/config/constants.ts) so an early cutoff can be retried before any byte * reaches the client. OFF by default because holding the window adds up to * STREAM_RECOVERY.HOLDBACK_MS of time-to-first-token latency on every stream. - * Default seeds from the STREAM_RECOVERY_ENABLED env var. + * Default seeds from the STREAM_RECOVERY_ENABLED feature flag / env var. */ enabled: boolean; /** @@ -113,8 +114,8 @@ export interface StreamRecoverySettings { * bytes already reached the client, re-request with the partial text as an assistant * prefill and stitch the missing suffix (plain-text OpenAI-compatible streams only; * never with a tool call in flight). OFF by default because the recovered tail arrives - * as one burst rather than token-by-token. Default seeds from - * STREAM_RECOVERY_MIDSTREAM_ENABLED. + * as one burst rather than token-by-token. Default seeds from the + * STREAM_RECOVERY_MIDSTREAM_ENABLED feature flag / env var. */ continueMidStream: boolean; } @@ -168,6 +169,40 @@ function toBoolean(value: unknown, fallback: boolean): boolean { return typeof value === "boolean" ? value : fallback; } +function parseFeatureFlagBoolean(value: string, fallback: boolean): boolean { + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") { + return true; + } + if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") { + return false; + } + return fallback; +} + +function resolveBooleanFeatureFlag(key: string, fallback: boolean): boolean { + try { + return parseFeatureFlagBoolean(resolveFeatureFlag(key), fallback); + } catch (error) { + const envValue = process.env[key]; + if (typeof envValue === "string" && envValue.trim() !== "") { + return parseFeatureFlagBoolean(envValue, fallback); + } + console.error( + `[resilience] Failed to resolve ${key}, falling back to ${String(fallback)}:`, + error instanceof Error ? error.message : error + ); + return fallback; + } +} + +function resolveStreamRecoveryDefaults(): StreamRecoverySettings { + return { + enabled: resolveBooleanFeatureFlag("STREAM_RECOVERY_ENABLED", false), + continueMidStream: resolveBooleanFeatureFlag("STREAM_RECOVERY_MIDSTREAM_ENABLED", false), + }; +} + 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; @@ -500,6 +535,7 @@ function normalizeStreamRecoverySettings( function buildLegacyFallback(settings: JsonRecord): ResilienceSettings { const profiles = asRecord(settings.providerProfiles); const defaults = asRecord(settings.rateLimitDefaults); + const streamRecoveryDefaults = resolveStreamRecoveryDefaults(); const oauthLegacy = asRecord(profiles.oauth); const apikeyLegacy = asRecord(profiles.apikey); @@ -583,7 +619,7 @@ function buildLegacyFallback(settings: JsonRecord): ResilienceSettings { }, providerCooldown: DEFAULT_RESILIENCE_SETTINGS.providerCooldown, quotaPreflight: DEFAULT_RESILIENCE_SETTINGS.quotaPreflight, - streamRecovery: DEFAULT_RESILIENCE_SETTINGS.streamRecovery, + streamRecovery: streamRecoveryDefaults, }; } @@ -670,10 +706,7 @@ export function mergeResilienceSettings( current.providerCooldown ), quotaPreflight: normalizeQuotaPreflightSettings(updates.quotaPreflight, current.quotaPreflight), - streamRecovery: normalizeStreamRecoverySettings( - updates.streamRecovery, - current.streamRecovery - ), + streamRecovery: normalizeStreamRecoverySettings(updates.streamRecovery, current.streamRecovery), }; } diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 80db7e6846..40898f7727 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -35,7 +35,6 @@ import { isUserCallableAgyModelId } from "@omniroute/open-sse/config/agyModels.t import { onUsageRecorded } from "./usageEvents"; type JsonRecord = Record; - type SyncSource = "manual" | "scheduled"; interface ProviderConnectionLike { @@ -64,6 +63,7 @@ const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([ "zai", "glmt", "opencode-go", + "ollama-cloud", "minimax", "minimax-cn", "crof", diff --git a/src/lib/usage/usageHistory.ts b/src/lib/usage/usageHistory.ts index ae7772be7a..599619eb0a 100644 --- a/src/lib/usage/usageHistory.ts +++ b/src/lib/usage/usageHistory.ts @@ -215,26 +215,18 @@ const pendingRequests: { */ const pendingById = new Map(); -/** - * Orphaned-pending-request reaper. - * - * Pending details are normally removed when a request finalizes (clean completion, - * tracked error, client cancel). But a request that never finalizes cleanly — an - * upstream/fetch error thrown before the finalize call, a client disconnect, or a - * process-level timeout — leaves its detail in `pendingById` (and `pendingRequests.details`) - * forever, each retaining truncated request/response payload previews. Under real proxy - * traffic a steady fraction of requests terminate abnormally, so this grows monotonically - * (previously only an admin reset via clearPendingRequests() could free it). - * - * The reaper drops entries whose `startedAt` is older than MAX_PENDING_REQUEST_AGE_MS — far - * longer than any genuine request — so only truly-orphaned entries are evicted; a live - * request always finalizes long before that. MAX_PENDING_DETAILS is a hard backstop. - */ -const MAX_PENDING_REQUEST_AGE_MS = 15 * 60 * 1000; +const DEFAULT_MAX_PENDING_REQUEST_AGE_MS = 60 * 60 * 1000; const MAX_PENDING_DETAILS = 5000; const PENDING_SWEEP_INTERVAL_MS = 5 * 60 * 1000; let _pendingSweepTimer: ReturnType | null = null; +export function getMaxPendingRequestAgeMs( + rawValue: string | undefined = process.env.MAX_PENDING_REQUEST_AGE_MS +): number { + const parsed = Number.parseInt(rawValue ?? "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_PENDING_REQUEST_AGE_MS; +} + function ensurePendingSweepTimer(): void { if (_pendingSweepTimer || typeof setInterval !== "function") return; _pendingSweepTimer = setInterval(() => { @@ -256,7 +248,7 @@ function ensurePendingSweepTimer(): void { */ export function sweepStalePendingRequests( now: number = Date.now(), - maxAgeMs: number = MAX_PENDING_REQUEST_AGE_MS + maxAgeMs: number = getMaxPendingRequestAgeMs() ): number { let removed = 0; @@ -347,12 +339,13 @@ export function trackPendingRequest( if (!pendingRequests.details[connectionId][modelKey]) { pendingRequests.details[connectionId][modelKey] = []; } + const now = Date.now(); const newDetail = { - id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + id: `${now}-${Math.random().toString(36).slice(2, 8)}`, model, provider, connectionId, - startedAt: Date.now(), + startedAt: now, ...normalizedMetadata, }; pendingRequests.details[connectionId][modelKey].push(newDetail); diff --git a/src/server-init.ts b/src/server-init.ts index 0fd6070bc6..b182d7b783 100644 --- a/src/server-init.ts +++ b/src/server-init.ts @@ -6,6 +6,7 @@ import { initAuditLog, cleanupExpiredLogs, logAuditEvent } from "./lib/complianc import { initConsoleInterceptor } from "./lib/consoleInterceptor"; import { startBudgetResetJob } from "./lib/jobs/budgetResetJob"; import { startReasoningCacheCleanupJob } from "./lib/jobs/reasoningCacheCleanupJob"; +import { startCleanupScheduler } from "./lib/db/cleanup"; import { getSettings } from "./lib/db/settings"; import { applyRuntimeSettings } from "./lib/config/runtimeSettings"; import { setSystemPromptConfig } from "@omniroute/open-sse/services/systemPrompt.ts"; @@ -105,6 +106,7 @@ async function startServer() { await initializeCloudSync(); startBudgetResetJob(); startReasoningCacheCleanupJob(); + startCleanupScheduler(); startRuntimeConfigHotReload(); startupLog.info("Server started with cloud sync initialized"); diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index b4ba40a71b..aafd9aa8ef 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -222,7 +222,7 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ warningLevel: "info", }, - // ──────────────── Runtime (10) ──────────────── + // ──────────────── Runtime (12) ──────────────── { key: "OMNIROUTE_MCP_ENFORCE_SCOPES", label: "MCP Enforce Scopes", @@ -313,6 +313,30 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: false, warningLevel: "caution", }, + { + key: "STREAM_RECOVERY_ENABLED", + label: "Stream Recovery", + description: + "Enable transparent early retry for truncated upstream SSE streams before any response bytes reach the client.", + descriptionI18nKey: "featureFlagStreamRecoveryEnabledDescription", + category: "runtime", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "caution", + }, + { + key: "STREAM_RECOVERY_MIDSTREAM_ENABLED", + label: "Mid-Stream Continuation", + description: + "Allow stream recovery to re-request and stitch a response after bytes have already reached the client.", + descriptionI18nKey: "featureFlagStreamRecoveryMidstreamEnabledDescription", + category: "runtime", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "danger", + }, { key: "MODEL_CATALOG_INCLUDE_NAMES", label: "Model Catalog Names", diff --git a/src/shared/constants/headers.ts b/src/shared/constants/headers.ts index 9044575fe1..e8184efe28 100644 --- a/src/shared/constants/headers.ts +++ b/src/shared/constants/headers.ts @@ -1,6 +1,7 @@ export const OMNIROUTE_RESPONSE_HEADERS = { cache: "X-OmniRoute-Cache", cacheHit: "X-OmniRoute-Cache-Hit", + compression: "X-OmniRoute-Compression", costSaved: "X-OmniRoute-Cost-Saved", fallbackAttempts: "X-OmniRoute-Fallback-Attempts", latencyMs: "X-OmniRoute-Latency-Ms", diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index 5787d7d1d5..3f9e54eb3b 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -5,7 +5,7 @@ */ export interface ModelSpec { - maxOutputTokens: number; + maxOutputTokens?: number; contextWindow?: number; defaultThinkingBudget?: number; thinkingBudgetCap?: number; @@ -26,7 +26,7 @@ export interface ModelSpec { // (2026-05-19): "Any request that tries to set a fixed thinking budget gets a 400 error." adaptiveThinkingOnly?: boolean; // Explicit operator override for the no-thinking gateway alias (Fase 8.1). When unset, - // the catalog auto-advertises a `claude-3-omniroute-no-thinking/…` variant for + // the catalog auto-advertises a `no-think/…` variant for // Claude-family thinking-capable models that honor `disabled`. Set `true` to force the // variant on for any other model, or `false` to suppress it. See open-sse/utils/noThinkingAlias.ts. noThinkingAlias?: boolean; @@ -457,9 +457,7 @@ export const MODEL_SPECS: Record = { }, // Defaults - __default__: { - maxOutputTokens: 8192, - }, + __default__: {}, }; export function getModelSpec(modelId: string): ModelSpec | undefined { @@ -515,10 +513,12 @@ export function normalizeThinkingForModel>( return body; } -export function capMaxOutputTokens(modelId: string, requested?: number): number { +export function capMaxOutputTokens(modelId: string, requested?: number): number | undefined { const spec = getModelSpec(modelId); - const cap = spec?.maxOutputTokens ?? MODEL_SPECS.__default__.maxOutputTokens; - return requested ? Math.min(requested, cap) : cap; + const cap = spec?.maxOutputTokens; + const hasRequested = typeof requested === "number" && Number.isFinite(requested); + if (typeof cap !== "number") return hasRequested ? requested : undefined; + return hasRequested ? Math.min(requested, cap) : cap; } export function getDefaultThinkingBudget(modelId: string): number { diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 2816ad6439..1150c2e78d 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -1,5 +1,3 @@ -// Provider definitions - /** * Service kind — declarative tag for what a provider can do beyond basic LLM chat. * Affects UI filtering and playground routing; does not influence request routing. @@ -2970,17 +2968,15 @@ export function isSelfHostedChatProvider(providerId: unknown): boolean { export function providerAllowsOptionalApiKey(providerId: unknown): boolean { return ( + // ponytail: any noAuth provider auto-qualifies — no per-provider maintenance + (typeof providerId === "string" && providerId in NOAUTH_PROVIDERS) || providerId === "searxng-search" || providerId === "pollinations" || providerId === "copilot-web" || - providerId === "duckduckgo-web" || - providerId === "veoaifree-web" || providerId === "hackclub" || providerId === "huggingchat" || providerId === "gitlawb" || providerId === "gitlawb-gmi" || - providerId === "mimocode" || - providerId === "opencode" || isLocalProvider(providerId) || isSelfHostedChatProvider(providerId) || isOpenAICompatibleProvider(providerId) || @@ -3229,6 +3225,7 @@ export const USAGE_SUPPORTED_PROVIDERS = [ "zai", "glmt", "opencode-go", + "ollama-cloud", "minimax", "minimax-cn", "crof", diff --git a/src/shared/validation/providerSpecificData.ts b/src/shared/validation/providerSpecificData.ts index 3258d389e7..8a81208892 100644 --- a/src/shared/validation/providerSpecificData.ts +++ b/src/shared/validation/providerSpecificData.ts @@ -179,6 +179,50 @@ export function validateProviderSpecificData( }); } + for (const key of ["openCodeGoWorkspaceId", "opencodeGoWorkspaceId", "workspaceId"] as const) { + const value = data[key]; + if (value !== undefined && value !== null && typeof value !== "string") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `providerSpecificData.${key} must be a string`, + path: [key], + }); + } + if (typeof value === "string" && value.length > 1000) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `providerSpecificData.${key} must be at most 1000 characters`, + path: [key], + }); + } + } + + for (const key of [ + "openCodeGoAuthCookie", + "opencodeGoAuthCookie", + "authCookie", + "ollamaUsageCookie", + "ollamaCloudUsageCookie", + "ollamaCloudCookie", + "usageCookie", + ] as const) { + const value = data[key]; + if (value !== undefined && value !== null && typeof value !== "string") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `providerSpecificData.${key} must be a string`, + path: [key], + }); + } + if (typeof value === "string" && value.length > 10000) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `providerSpecificData.${key} must be at most 10000 characters`, + path: [key], + }); + } + } + const groupTag = data.tag; if ( groupTag !== undefined && diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts index 3c89152d82..c3817d93fb 100644 --- a/src/shared/validation/schemas/provider.ts +++ b/src/shared/validation/schemas/provider.ts @@ -18,7 +18,14 @@ import { } from "@/shared/constants/upstreamHeaders"; import { MAX_TIMER_TIMEOUT_MS } from "@/shared/utils/runtimeTimeouts"; -import { isHttpUrl, CODEX_REASONING_EFFORT_VALUES, REQUEST_DEFAULT_SERVICE_TIER_VALUES, upstreamHeadersRecordSchema, modelCompatPerProtocolSchema, customHeadersSchema } from "./misc.ts"; +import { + isHttpUrl, + CODEX_REASONING_EFFORT_VALUES, + REQUEST_DEFAULT_SERVICE_TIER_VALUES, + upstreamHeadersRecordSchema, + modelCompatPerProtocolSchema, + customHeadersSchema, +} from "./misc.ts"; export function validateProviderSpecificData( data: Record | undefined, @@ -184,6 +191,50 @@ export function validateProviderSpecificData( }); } + for (const key of ["openCodeGoWorkspaceId", "opencodeGoWorkspaceId", "workspaceId"] as const) { + const value = data[key]; + if (value !== undefined && value !== null && typeof value !== "string") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `providerSpecificData.${key} must be a string`, + path: [key], + }); + } + if (typeof value === "string" && value.length > 1000) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `providerSpecificData.${key} must be at most 1000 characters`, + path: [key], + }); + } + } + + for (const key of [ + "openCodeGoAuthCookie", + "opencodeGoAuthCookie", + "authCookie", + "ollamaUsageCookie", + "ollamaCloudUsageCookie", + "ollamaCloudCookie", + "usageCookie", + ] as const) { + const value = data[key]; + if (value !== undefined && value !== null && typeof value !== "string") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `providerSpecificData.${key} must be a string`, + path: [key], + }); + } + if (typeof value === "string" && value.length > 10000) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `providerSpecificData.${key} must be at most 10000 characters`, + path: [key], + }); + } + } + const groupTag = data.tag; if ( groupTag !== undefined && @@ -361,7 +412,10 @@ export const bulkWebSessionImportSchema = z.object({ .array( z.object({ name: z.string().min(1).max(200), - credential: z.string().min(1).max(64 * 1024, "Credential must be under 64 KB"), + credential: z + .string() + .min(1) + .max(64 * 1024, "Credential must be under 64 KB"), }) ) .min(1, "entries must contain at least 1 item") @@ -490,6 +544,7 @@ export const providerNodeValidateSchema = z.object({ compatMode: z.enum(["cc"]).optional(), chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")), modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")), + modelId: z.string().trim().max(200).optional().or(z.literal("")), }); export const updateProviderConnectionSchema = z @@ -631,4 +686,4 @@ export const validateProviderApiKeySchema = z path: ["cx"], }); } - }); \ No newline at end of file + }); diff --git a/src/shared/validation/schemas/settings.ts b/src/shared/validation/schemas/settings.ts index a57b349cc8..75e813c93b 100644 --- a/src/shared/validation/schemas/settings.ts +++ b/src/shared/validation/schemas/settings.ts @@ -101,7 +101,20 @@ export const providerCooldownSettingsSchema = z minRetryCooldownMs: z.number().int().min(0).max(300000).optional(), maxRetryCooldownMs: z.number().int().min(0).max(3600000).optional(), }) - .strict(); + .strict() + .superRefine((value, ctx) => { + if ( + typeof value.minRetryCooldownMs === "number" && + typeof value.maxRetryCooldownMs === "number" && + value.maxRetryCooldownMs < value.minRetryCooldownMs + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "maxRetryCooldownMs must be greater than or equal to minRetryCooldownMs", + path: ["maxRetryCooldownMs"], + }); + } + }); export const updateResilienceSchema = z .object({ diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 6ae3715699..802734ef0d 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -243,7 +243,7 @@ export async function handleChat( // Log request endpoint and model const url = new URL(request.url); - // No-thinking gateway alias (Fase 8.1): `claude-3-omniroute-no-thinking//` + // No-thinking gateway alias (Fase 8.1): `no-think//` // resolves back to the real model with reasoning suppressed in place, before any // model resolution / combo routing sees it. Claude/Messages path forces // `thinking:{type:"disabled"}`; OpenAI path drops the reasoning fields. diff --git a/tests/integration/chat-pipeline.test.ts b/tests/integration/chat-pipeline.test.ts index 80ac8f0f08..7627108d30 100644 --- a/tests/integration/chat-pipeline.test.ts +++ b/tests/integration/chat-pipeline.test.ts @@ -711,15 +711,14 @@ test("chat pipeline applies Codex CLI fingerprint to OAuth responses requests", assert.ok(headerOrder.indexOf("Accept") < headerOrder.indexOf("User-Agent")); const bodyOrder = Object.keys(JSON.parse(call.bodyString)); - assert.deepEqual(bodyOrder.slice(0, 7), [ - "model", - "stream", - "input", - "instructions", - "store", - "reasoning", - "prompt_cache_key", - ]); + // Order must match the canonical Codex fingerprint bodyFieldOrder (cliFingerprints.ts): + // …reasoning, prompt_cache_key, …, include — i.e. prompt_cache_key precedes include. + // (#4584 inadvertently flipped these two; fast-gates skip integration tests so it only + // surfaced on the release PR full CI.) + assert.deepEqual( + bodyOrder.slice(0, 8), + "model stream input instructions store reasoning prompt_cache_key include".split(" ") + ); assert.equal(call.body.model, "gpt-5.5"); assert.equal(call.body.store, false); assert.equal( diff --git a/tests/integration/memory-pipeline.test.ts b/tests/integration/memory-pipeline.test.ts index ac1bfcb09e..184601d3e1 100644 --- a/tests/integration/memory-pipeline.test.ts +++ b/tests/integration/memory-pipeline.test.ts @@ -60,12 +60,15 @@ test.after(async () => { await harness.cleanup(); }); -async function enableMemory(maxTokens = 400) { +async function enableMemory( + maxTokens = 400, + strategy: "recent" | "semantic" | "hybrid" = "recent" +) { await settingsDb.updateSettings({ memoryEnabled: true, memoryMaxTokens: maxTokens, memoryRetentionDays: 30, - memoryStrategy: "recent", + memoryStrategy: strategy, }); } @@ -172,6 +175,7 @@ test("later requests inject retrieved memories into upstream messages", async () test("memory search ranks query-relevant memories first", async () => { const apiKey = await seedApiKey(); + await enableMemory(400, "hybrid"); await memoryTools.omniroute_memory_add.handler({ apiKeyId: apiKey.id, diff --git a/tests/snapshots/translation/openai-to-claude/multi-turn.json b/tests/snapshots/translation/openai-to-claude/multi-turn.json index c565c4c70d..d928907bcb 100644 --- a/tests/snapshots/translation/openai-to-claude/multi-turn.json +++ b/tests/snapshots/translation/openai-to-claude/multi-turn.json @@ -1,5 +1,5 @@ { - "max_tokens": 8192, + "max_tokens": 64000, "messages": [ { "content": [ diff --git a/tests/snapshots/translation/openai-to-gemini/with-system.json b/tests/snapshots/translation/openai-to-gemini/with-system.json index 01b0a2af5f..2c1f51acf1 100644 --- a/tests/snapshots/translation/openai-to-gemini/with-system.json +++ b/tests/snapshots/translation/openai-to-gemini/with-system.json @@ -10,7 +10,6 @@ } ], "generationConfig": { - "maxOutputTokens": 8192, "temperature": 0.7, "thinkingConfig": { "includeThoughts": true, diff --git a/tests/unit/account-fallback-anthropic-quota.test.ts b/tests/unit/account-fallback-anthropic-quota.test.ts index c67b980912..978641ece2 100644 --- a/tests/unit/account-fallback-anthropic-quota.test.ts +++ b/tests/unit/account-fallback-anthropic-quota.test.ts @@ -8,7 +8,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { classifyErrorText, parseRetryFromErrorText, checkFallbackError } = +const { classifyErrorText, parseRetryFromErrorText, checkFallbackError, getProviderProfile } = await import("../../open-sse/services/accountFallback.ts"); const { RateLimitReason } = await import("../../open-sse/config/constants.ts"); @@ -70,9 +70,8 @@ test("#2321 checkFallbackError returns ~1h cooldown for OAuth 429 + Usage Limit ); }); -test("#2321 checkFallbackError honors ISO timestamp from body when present", () => { - const futureMs = 45 * 60 * 1000; - const future = new Date(Date.now() + futureMs).toISOString(); +test("#2321 checkFallbackError ignores ISO timestamp when upstream retry hints are disabled", () => { + const future = new Date(Date.now() + 45 * 60 * 1000).toISOString(); const out = checkFallbackError( 429, `Claude Pro usage limit reached. Try again at ${future}`, @@ -81,6 +80,24 @@ test("#2321 checkFallbackError honors ISO timestamp from body when present", () "claude" ); assert.equal(out.reason, RateLimitReason.QUOTA_EXHAUSTED); + assert.equal(out.usedUpstreamRetryHint, false); + assert.equal(out.cooldownMs, 60 * 60 * 1000); +}); + +test("#2321 checkFallbackError honors ISO timestamp when upstream retry hints are enabled", () => { + const futureMs = 45 * 60 * 1000; + const future = new Date(Date.now() + futureMs).toISOString(); + const profile = { ...getProviderProfile("claude"), useUpstreamRetryHints: true }; + const out = checkFallbackError( + 429, + `Claude Pro usage limit reached. Try again at ${future}`, + 0, + null, + "claude", + null, + profile + ); + assert.equal(out.reason, RateLimitReason.QUOTA_EXHAUSTED); // Within ~30s of the requested wait time. assert.ok( Math.abs(out.cooldownMs - futureMs) < 30_000, diff --git a/tests/unit/account-fallback-service.test.ts b/tests/unit/account-fallback-service.test.ts index f0baf38eff..13afd819a7 100644 --- a/tests/unit/account-fallback-service.test.ts +++ b/tests/unit/account-fallback-service.test.ts @@ -112,7 +112,7 @@ test("checkFallbackError locks Antigravity quota-reached 429 for the full reset "gemini-3-flash-agent", "antigravity", null, - makeProfile() + makeProfile({ useUpstreamRetryHints: true }) ); assert.equal(result.shouldFallback, true); diff --git a/tests/unit/api-manager-key-visibility.test.ts b/tests/unit/api-manager-key-visibility.test.ts index 261426c3e0..ed8e5fea22 100644 --- a/tests/unit/api-manager-key-visibility.test.ts +++ b/tests/unit/api-manager-key-visibility.test.ts @@ -17,6 +17,10 @@ describe("maskKey", () => { assert.equal(maskKey("sk-12345"), "sk-12345"); }); + it("does not double-mask API keys that are already masked by the server", () => { + assert.equal(maskKey("sk-live-****1002"), "sk-live-****1002"); + }); + it("keeps the first 8 chars and appends an ellipsis when the key is longer", () => { const full = "sk-or-1234567890abcdef"; const masked = maskKey(full); diff --git a/tests/unit/api/v1/bifrost-sidecar.test.ts b/tests/unit/api/v1/bifrost-sidecar.test.ts index 8cdc7a5fd7..286e8bffcb 100644 --- a/tests/unit/api/v1/bifrost-sidecar.test.ts +++ b/tests/unit/api/v1/bifrost-sidecar.test.ts @@ -1,6 +1,13 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; +import { + checkIpRateLimit, + getClientIp, + sanitizeForensicHeader, +} from "../../../../src/app/api/v1/relay/chat/completions/relaySecurity.ts"; +import { getDbInstance } from "../../../../src/lib/db/core.ts"; +import { getRelayLogs } from "../../../../src/lib/db/relayProxies.ts"; // ─── T-12 (#3932 PR-4): bifrost sidecar proxy route ────────────────────── // @@ -14,6 +21,39 @@ const ORIGINAL_BIFROST_API_KEY = process.env.BIFROST_API_KEY; const ORIGINAL_BIFROST_OMNI_KEY = process.env.OMNIROUTE_BIFROST_KEY; const ORIGINAL_BIFROST_TIMEOUT = process.env.BIFROST_TIMEOUT_MS; const ORIGINAL_BIFROST_STREAMING = process.env.BIFROST_STREAMING_ENABLED; +const ORIGINAL_FETCH = globalThis.fetch; + +function seedRelayToken(rawToken: string) { + const id = `rl_test_${Date.now()}_${Math.random().toString(16).slice(2)}`; + const now = Math.floor(Date.now() / 1000); + getDbInstance() + .prepare( + ` + INSERT INTO relay_tokens (id, name, token_hash, token_prefix, description, combo_id, + allowed_models, max_tokens_per_request, max_requests_per_minute, max_requests_per_day, + max_cost_per_day, enabled, created_at, updated_at, expires_at, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?) + ` + ) + .run( + id, + "bifrost-sse-lifecycle", + createHash("sha256").update(rawToken).digest("hex"), + "rl_test", + "", + null, + JSON.stringify(["*"]), + 128000, + 60, + 10000, + 0, + now, + now, + null, + "{}" + ); + return { id, rawToken }; +} function restoreEnv() { if (ORIGINAL_BIFROST_BASE_URL === undefined) delete process.env.BIFROST_BASE_URL; @@ -26,6 +66,7 @@ function restoreEnv() { else process.env.BIFROST_TIMEOUT_MS = ORIGINAL_BIFROST_TIMEOUT; if (ORIGINAL_BIFROST_STREAMING === undefined) delete process.env.BIFROST_STREAMING_ENABLED; else process.env.BIFROST_STREAMING_ENABLED = ORIGINAL_BIFROST_STREAMING; + globalThis.fetch = ORIGINAL_FETCH; } // Case 1: BIFROST_BASE_URL unset. We test this first because the route's @@ -117,3 +158,84 @@ test("bifrost route: OPTIONS responds with CORS headers", async () => { "missing Access-Control-Allow-Headers header" ); }); + +test("bifrost route: shared relay IP limiter blocks a repeated token from one IP", () => { + const tokenId = `bifrost-ip-limit-${Date.now()}-${Math.random()}`; + const ip = "203.0.113.77"; + + for (let i = 0; i < 30; i++) { + assert.equal(checkIpRateLimit(tokenId, ip).allowed, true); + } + + const blocked = checkIpRateLimit(tokenId, ip); + assert.equal(blocked.allowed, false); + assert.ok(blocked.resetIn >= 0 && blocked.resetIn <= 60); +}); + +test("bifrost route: shared relay security helpers sanitize forwarded client metadata", () => { + const req = new Request("http://localhost/api/v1/relay/chat/completions/bifrost", { + headers: { + "x-forwarded-for": "198.51.100.8, 10.0.0.2", + }, + }); + + assert.equal(getClientIp(req), "198.51.100.8"); + assert.equal(sanitizeForensicHeader("ua\r\nforged"), "ua forged"); +}); + +test("bifrost route: records relay usage after SSE stream completion", async () => { + process.env.BIFROST_BASE_URL = "http://bifrost.test.local:8080"; + process.env.BIFROST_TIMEOUT_MS = "5000"; + delete process.env.BIFROST_API_KEY; + delete process.env.OMNIROUTE_BIFROST_KEY; + delete process.env.BIFROST_STREAMING_ENABLED; + + const relayToken = seedRelayToken(`relay_bifrost_sse_${Date.now()}`); + + globalThis.fetch = async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("data: {\"delta\":\"hi\"}\n\n")); + controller.close(); + }, + }), + { + status: 200, + headers: { "content-type": "text/event-stream" }, + } + ); + + const { POST } = await import( + `../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts?case=${Date.now()}-${Math.random()}` + ); + + const req = new Request("http://localhost/api/v1/relay/chat/completions/bifrost", { + method: "POST", + headers: { + authorization: `Bearer ${relayToken.rawToken}`, + "content-type": "application/json", + "x-request-id": "bifrost-sse-lifecycle-test", + }, + body: JSON.stringify({ + model: "gpt-4", + stream: true, + messages: [{ role: "user", content: "hi" }], + }), + }); + + const res = await POST(req); + assert.equal(res.status, 200); + assert.equal(res.headers.get("X-Routed-By"), "bifrost"); + assert.equal(getRelayLogs(relayToken.id, 10).length, 0); + + assert.match(await res.text(), /delta/); + + const logs = getRelayLogs(relayToken.id, 10); + assert.equal(logs.length, 1); + assert.equal(logs[0].status, "success"); + assert.equal(logs[0].status_code, 200); + assert.equal(logs[0].request_id, "bifrost-sse-lifecycle-test"); + + restoreEnv(); +}); diff --git a/tests/unit/bailian-quota-fetcher.test.ts b/tests/unit/bailian-quota-fetcher.test.ts index f9c9d5baaf..77e5d10945 100644 --- a/tests/unit/bailian-quota-fetcher.test.ts +++ b/tests/unit/bailian-quota-fetcher.test.ts @@ -2,6 +2,9 @@ import test from "node:test"; import assert from "node:assert/strict"; import { + BAILIAN_WINDOW_5H, + BAILIAN_WINDOW_MONTHLY, + BAILIAN_WINDOW_WEEKLY, fetchBailianQuota, invalidateBailianQuotaCache, registerBailianCodingPlanQuotaFetcher, @@ -216,6 +219,9 @@ test("fetchBailianQuota parses triple-window and returns percentUsed = max(5h%, assert.equal(quota?.window5h?.percentUsed, 0.6); assert.equal(quota?.windowWeekly?.percentUsed, 0.8); assert.equal(quota?.windowMonthly?.percentUsed, 0.4); + assert.equal(quota?.windows?.[BAILIAN_WINDOW_5H]?.percentUsed, 0.6); + assert.equal(quota?.windows?.[BAILIAN_WINDOW_WEEKLY]?.percentUsed, 0.8); + assert.equal(quota?.windows?.[BAILIAN_WINDOW_MONTHLY]?.percentUsed, 0.4); invalidateBailianQuotaCache(connectionId); }); diff --git a/tests/unit/base-executor-sanitize-effort.test.ts b/tests/unit/base-executor-sanitize-effort.test.ts index 369fe275c2..fea0f6ffda 100644 --- a/tests/unit/base-executor-sanitize-effort.test.ts +++ b/tests/unit/base-executor-sanitize-effort.test.ts @@ -456,7 +456,7 @@ test("sanitizeReasoningEffortForProvider: OpenRouter DeepSeek still preserves xh const body = { model: "deepseek/deepseek-v4-pro", reasoning_effort: "xhigh", - messages: [{ role: "user", content: "hi" }], + messages: [], }; const result = sanitizeReasoningEffortForProvider( body, @@ -467,3 +467,54 @@ test("sanitizeReasoningEffortForProvider: OpenRouter DeepSeek still preserves xh assert.equal(result, body); assert.equal((result as any).reasoning_effort, "xhigh"); }); + +// ── opencode-go DeepSeek V4 Pro effort variants (#4647) ────────────────────── +// opencode-go proxies DeepSeek with the native DeepSeek API contract, which +// accepts {high, max} literally. The OpencodeExecutor's transformRequest sets +// reasoning_effort to the variant suffix (low|medium|high|max), and the +// sanitizer must NOT rewrite `max` → `xhigh` for this provider+model combo. + +test("sanitizeReasoningEffortForProvider: opencode-go DeepSeek V4 Pro preserves max", () => { + const body = { + model: "deepseek-v4-pro", + reasoning_effort: "max", + messages: [], + }; + const result = sanitizeReasoningEffortForProvider(body, "opencode-go", "deepseek-v4-pro", null); + assert.equal(result, body, "opencode-go DeepSeek max must pass through unchanged"); + assert.equal((result as any).reasoning_effort, "max"); +}); + +test("sanitizeReasoningEffortForProvider: opencode-go DeepSeek V4 Pro preserves variant suffix levels", () => { + for (const level of ["low", "medium", "high", "max"]) { + const body = { + model: `deepseek-v4-pro-${level}`, + reasoning_effort: level, + messages: [], + }; + const result = sanitizeReasoningEffortForProvider( + body, + "opencode-go", + `deepseek-v4-pro-${level}`, + null + ); + assert.equal( + (result as any).reasoning_effort, + level, + `opencode-go deepseek-v4-pro-${level} preserves reasoning_effort=${level}` + ); + } +}); + +test("sanitizeReasoningEffortForProvider: opencode-go with non-DeepSeek model still normalizes max → xhigh", () => { + // The opt-in must be scoped to DeepSeek models on opencode-go only — other + // opencode-go models (e.g. glm/kimi/mimo) follow the default xhigh policy. + const body = { + model: "mimo-v2.5-pro", + reasoning_effort: "max", + messages: [], + }; + const result = sanitizeReasoningEffortForProvider(body, "opencode-go", "mimo-v2.5-pro", null); + assert.notEqual(result, body); + assert.equal((result as any).reasoning_effort, "xhigh"); +}); diff --git a/tests/unit/cc-compatible-provider.test.ts b/tests/unit/cc-compatible-provider.test.ts index 47cc5ad958..621539cd4f 100644 --- a/tests/unit/cc-compatible-provider.test.ts +++ b/tests/unit/cc-compatible-provider.test.ts @@ -1011,7 +1011,7 @@ test("provider-nodes validate route supports enabled CC validation and OpenAI-st assert.equal(openAiResponse.status, 200); assert.deepEqual(await openAiResponse.json(), { valid: false, - error: "Invalid API key", + error: "API key unauthorized", }); assert.equal(calls[1].url, "https://proxy.example.com/models"); assert.equal(calls[1].init.headers.Authorization, "Bearer sk-openai-test"); @@ -1086,7 +1086,7 @@ test("provider-nodes validate route covers default CC paths, null method, anthro assert.equal(anthropicResponse.status, 200); assert.deepEqual(await anthropicResponse.json(), { valid: false, - error: "Invalid API key", + error: "API key unauthorized", }); assert.equal(anthropicCalls[0].url, "https://proxy.example.com/v1/models"); assert.equal(anthropicCalls[0].init.method, "GET"); diff --git a/tests/unit/chatcore-headers.test.ts b/tests/unit/chatcore-headers.test.ts index 290336703e..0472987411 100644 --- a/tests/unit/chatcore-headers.test.ts +++ b/tests/unit/chatcore-headers.test.ts @@ -1,7 +1,7 @@ -import test from "node:test"; +import test, { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { getHeaderValueCaseInsensitive } from "../../open-sse/handlers/chatCore/headers.ts"; +import { getHeaderValueCaseInsensitive, resolveCompressionHeader } from "../../open-sse/handlers/chatCore/headers.ts"; test("getHeaderValueCaseInsensitive reads a Headers instance via .get()", () => { const h = new Headers({ "Content-Type": "text/event-stream" }); @@ -46,3 +46,16 @@ test("getHeaderValueCaseInsensitive returns null for null/undefined/non-object i null ); }); + +describe("resolveCompressionHeader", () => { + it("reads the raw value case-insensitively and trims it", () => { + assert.equal(resolveCompressionHeader({ "x-omniroute-compression": " engine:rtk " }), "engine:rtk"); + assert.equal(resolveCompressionHeader(new Headers({ "X-OmniRoute-Compression": "off" })), "off"); + }); + + it("returns null when absent or blank", () => { + assert.equal(resolveCompressionHeader({}), null); + assert.equal(resolveCompressionHeader({ "x-omniroute-compression": " " }), null); + assert.equal(resolveCompressionHeader(null), null); + }); +}); diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index 4a9fa93b7c..263debfd60 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -274,11 +274,10 @@ async function resetStorage() { fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); } -// 10s ceiling: on 2-core CI runners under shard contention the 1500ms budget -// expired mid-flight (observed: 1580ms fail on the upstream-timeout test) — -// green runs return as soon as the condition holds, so the ceiling only -// bounds the failure case. -async function waitFor(fn, timeoutMs = 10000) { +// 30s ceiling: c8 instrumentation plus --test-concurrency=8 can stall CI workers +// well past the upstream timeout budget. Green runs return as soon as the condition +// holds, so the ceiling only bounds the failure case. +async function waitFor(fn, timeoutMs = 30000) { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { const result = await fn(); diff --git a/tests/unit/cleanup-column-fix.test.mjs b/tests/unit/cleanup-column-fix.test.mjs new file mode 100644 index 0000000000..eeb3aeafd9 --- /dev/null +++ b/tests/unit/cleanup-column-fix.test.mjs @@ -0,0 +1,114 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +// Source-level invariant tests for cleanup.ts fixes. +// These verify the critical column name fixes that were causing silent cleanup failures. + +const CLEANUP_PATH = path.resolve(import.meta.dirname, "../../src/lib/db/cleanup.ts"); +const source = fs.readFileSync(CLEANUP_PATH, "utf-8"); + +test("cleanup: compression_analytics uses 'timestamp' column (not 'created_at')", () => { + // The bug: cleanup used WHERE created_at < ? but the table has 'timestamp' column. + // This caused silent failures — 600K+ rows accumulated over 52 days. + assert.ok( + source.includes("DELETE FROM compression_analytics WHERE timestamp < ?"), + "compression_analytics cleanup must use 'timestamp' column, not 'created_at'" + ); + assert.ok( + !source.includes("DELETE FROM compression_analytics WHERE created_at"), + "must NOT use created_at for compression_analytics (column doesn't exist)" + ); +}); + +test("cleanup: call_logs uses 'timestamp' column (not 'created_at')", () => { + // Same bug as compression_analytics. + assert.ok( + source.includes("DELETE FROM call_logs WHERE timestamp < ?"), + "call_logs cleanup must use 'timestamp' column" + ); + assert.ok( + !source.includes("DELETE FROM call_logs WHERE created_at"), + "must NOT use created_at for call_logs (column doesn't exist)" + ); +}); + +test("cleanup: has proxy_logs cleanup function", () => { + assert.ok( + source.includes("cleanupProxyLogs"), + "must have cleanupProxyLogs function for the proxy_logs table" + ); + assert.ok( + source.includes("DELETE FROM proxy_logs WHERE timestamp < ?"), + "proxy_logs cleanup must use timestamp column" + ); +}); + +test("cleanup: proxy_logs is included in runAutoCleanup", () => { + assert.ok( + source.includes("proxyLogs: await cleanupProxyLogs()"), + "runAutoCleanup must include proxyLogs cleanup" + ); +}); + +test("cleanup: has background scheduler (startCleanupScheduler)", () => { + assert.ok( + source.includes("startCleanupScheduler"), + "must export startCleanupScheduler for periodic background cleanup" + ); + assert.ok( + source.includes("CLEANUP_INTERVAL_MS"), + "must have a cleanup interval constant" + ); + assert.ok( + source.includes("VACUUM"), + "scheduler must run VACUUM after deletes to reclaim disk space" + ); +}); + +test("cleanup: scheduler is wired into server-init.ts", () => { + const serverInitPath = path.resolve(import.meta.dirname, "../../src/server-init.ts"); + const serverInit = fs.readFileSync(serverInitPath, "utf-8"); + assert.ok( + serverInit.includes('import { startCleanupScheduler } from "./lib/db/cleanup"'), + "server-init.ts must import startCleanupScheduler" + ); + assert.ok( + serverInit.includes("startCleanupScheduler()"), + "server-init.ts must call startCleanupScheduler() at startup" + ); +}); + +test("cleanup: mcp_tool_audit uses correct table name (not 'mcp_audit_log')", () => { + assert.ok( + source.includes("DELETE FROM mcp_tool_audit WHERE"), + "must use correct table name mcp_tool_audit" + ); + assert.ok( + !source.includes("DELETE FROM mcp_audit_log WHERE"), + "must NOT use non-existent table name mcp_audit_log" + ); +}); + +test("cleanup: a2a_task_events uses correct table name (not 'a2a_events')", () => { + assert.ok( + source.includes("DELETE FROM a2a_task_events WHERE"), + "must use correct table name a2a_task_events" + ); + assert.ok( + !source.includes("DELETE FROM a2a_events WHERE"), + "must NOT use non-existent table name a2a_events" + ); +}); + +test("cleanup: memories uses correct table name (not 'memory_entries')", () => { + assert.ok( + source.includes("DELETE FROM memories WHERE"), + "must use correct table name memories" + ); + assert.ok( + !source.includes("DELETE FROM memory_entries WHERE"), + "must NOT use non-existent table name memory_entries" + ); +}); diff --git a/tests/unit/cli-data-dir-env-loading.test.ts b/tests/unit/cli-data-dir-env-loading.test.ts new file mode 100644 index 0000000000..c3d2ae3423 --- /dev/null +++ b/tests/unit/cli-data-dir-env-loading.test.ts @@ -0,0 +1,157 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const BIN = path.join(ROOT, "bin", "omniroute.mjs"); +const DATA_DIR_MODULE = path.join(ROOT, "bin", "cli", "data-dir.mjs"); + +async function withEnv( + updates: Record, + fn: () => Promise +): Promise { + const original = new Map(); + for (const key of Object.keys(updates)) { + original.set(key, process.env[key]); + if (updates[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = updates[key]; + } + } + + try { + return await fn(); + } finally { + for (const [key, value] of original) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +function readCliEnvShow(env: NodeJS.ProcessEnv, cwd: string): Record { + const result = spawnSync("node", [BIN, "env", "show", "--json"], { + cwd, + env, + encoding: "utf-8", + timeout: 60_000, + }); + + assert.equal( + result.status, + 0, + `omniroute env show failed\nstdout=${result.stdout}\nstderr=${result.stderr}` + ); + + const stdout = result.stdout ?? ""; + const jsonStart = stdout.indexOf("{"); + assert.ok(jsonStart >= 0, `expected JSON in stdout: ${stdout}`); + const parsed = JSON.parse(stdout.slice(jsonStart)) as { current: Record }; + return parsed.current; +} + +test("CLI data-dir resolver preserves an existing legacy ~/.omniroute before XDG/app data", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cli-data-dir-")); + const home = path.join(tmp, "home"); + const legacyDir = path.join(home, ".omniroute"); + const appData = path.join(tmp, "appdata"); + const xdgConfigHome = path.join(tmp, "xdg"); + + try { + fs.mkdirSync(legacyDir, { recursive: true }); + + await withEnv( + { + DATA_DIR: undefined, + HOME: home, + USERPROFILE: home, + APPDATA: appData, + XDG_CONFIG_HOME: xdgConfigHome, + }, + async () => { + const { resolveDataDir: cliResolveDataDir } = await import( + `${pathToFileURL(DATA_DIR_MODULE).href}?t=${Date.now()}` + ); + const { resolveDataDir: runtimeResolveDataDir } = await import( + `../../src/lib/dataPaths.ts?t=${Date.now()}` + ); + + assert.equal(cliResolveDataDir(), legacyDir); + assert.equal(cliResolveDataDir(), runtimeResolveDataDir()); + } + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("CLI startup loads later non-conflicting .env files without overriding earlier values", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cli-env-layers-")); + const home = path.join(tmp, "home"); + const dataDir = path.join(tmp, "data"); + const cwd = path.join(tmp, "cwd"); + const appDataDir = + process.platform === "win32" + ? path.join(tmp, "appdata", "omniroute") + : path.join(home, ".omniroute"); + + try { + fs.mkdirSync(dataDir, { recursive: true }); + fs.mkdirSync(appDataDir, { recursive: true }); + fs.mkdirSync(cwd, { recursive: true }); + + fs.writeFileSync( + path.join(dataDir, ".env"), + "OMNIROUTE_BASE_URL=https://data.example/v1\n", + "utf-8" + ); + fs.writeFileSync( + path.join(appDataDir, ".env"), + ["OMNIROUTE_BASE_URL=https://appdata.example/v1", "OMNIROUTE_HTTP_TIMEOUT_MS=1234", ""].join( + "\n" + ), + "utf-8" + ); + fs.writeFileSync( + path.join(cwd, ".env"), + ["OMNIROUTE_BASE_URL=https://cwd.example/v1", "PORT=34567", ""].join("\n"), + "utf-8" + ); + + const cleanEnv = { ...process.env }; + for (const key of [ + "OMNIROUTE_BASE_URL", + "OMNIROUTE_HTTP_TIMEOUT_MS", + "PORT", + "STORAGE_ENCRYPTION_KEY", + ]) { + delete cleanEnv[key]; + } + + const env = { + ...cleanEnv, + DATA_DIR: dataDir, + HOME: home, + USERPROFILE: home, + APPDATA: path.join(tmp, "appdata"), + CI: "1", + OMNIROUTE_CLI_SKIP_REPO_ENV: "1", + OMNIROUTE_NO_UPDATE_NOTIFIER: "1", + }; + + const current = readCliEnvShow(env, cwd); + assert.equal(current.OMNIROUTE_BASE_URL, "https://data.example/v1"); + assert.equal(current.OMNIROUTE_HTTP_TIMEOUT_MS, "1234"); + assert.equal(current.PORT, "34567"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/cli-data-dir-env.test.ts b/tests/unit/cli-data-dir-env.test.ts new file mode 100644 index 0000000000..798177db66 --- /dev/null +++ b/tests/unit/cli-data-dir-env.test.ts @@ -0,0 +1,68 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { resolveDataDir as cliResolveDataDir } from "../../bin/cli/data-dir.mjs"; +import { resolveDataDir as runtimeResolveDataDir } from "../../src/lib/dataPaths.ts"; + +const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const BIN = path.join(REPO_ROOT, "bin", "omniroute.mjs"); + +async function withTempEnv( + fn: (paths: { root: string; home: string; cwd: string }) => void | Promise +) { + const originalEnv = { ...process.env }; + const originalCwd = process.cwd(); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "omni-cli-env-")); + const home = path.join(root, "home"); + const cwd = path.join(root, "cwd"); + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(cwd, { recursive: true }); + + delete process.env.DATA_DIR; + delete process.env.XDG_CONFIG_HOME; + delete process.env.APPDATA; + process.env.HOME = home; + process.chdir(cwd); + + try { + await fn({ root, home, cwd }); + } finally { + process.chdir(originalCwd); + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) delete process.env[key]; + } + for (const [key, value] of Object.entries(originalEnv)) { + process.env[key] = value; + } + fs.rmSync(root, { recursive: true, force: true }); + } +} + +test("CLI data dir preserves existing legacy ~/.omniroute before XDG", async () => { + await withTempEnv(({ home, root }) => { + const legacyDir = path.join(home, ".omniroute"); + fs.mkdirSync(legacyDir, { recursive: true }); + process.env.XDG_CONFIG_HOME = path.join(root, "xdg"); + + assert.equal(cliResolveDataDir(), legacyDir); + assert.equal(cliResolveDataDir(), runtimeResolveDataDir()); + }); +}); + +test("CLI env loader scans all env paths while preserving first value wins", () => { + const source = fs.readFileSync(BIN, "utf8"); + const loaderStart = source.indexOf("function loadEnvFile()"); + const loaderEnd = source.indexOf("loadEnvFile();", loaderStart); + const loaderSource = source.slice(loaderStart, loaderEnd); + + assert.match(loaderSource, /for \(const envPath of envPaths\)/); + assert.match(loaderSource, /if \(process\.env\[key\] === undefined\)/); + assert.doesNotMatch( + loaderSource, + /Loaded env from \$\{envPath\}[\s\S]{0,80}\breturn;/ + ); +}); diff --git a/tests/unit/cli-logs-route.test.ts b/tests/unit/cli-logs-route.test.ts index 721f712ba1..8dcf83316e 100644 --- a/tests/unit/cli-logs-route.test.ts +++ b/tests/unit/cli-logs-route.test.ts @@ -35,9 +35,7 @@ const lines = [ ]; fs.writeFileSync(logPath, lines.join("\n") + "\n", "utf-8"); -const { GET } = await import( - "../../src/app/api/cli-tools/logs/route.ts" -); +const { GET } = await import("../../src/app/api/cli-tools/logs/route.ts"); test.before(async () => { await updateSettings({ requireLogin: false }); @@ -148,7 +146,14 @@ test("log-streamer.ts calls /api/cli-tools/logs (correct URL, not the missing ro globalThis.fetch = (async (url: string) => { captured.push(typeof url === "string" ? url : String(url)); // Return a mock Response with a body so the stream doesn't error immediately - return new Response(new ReadableStream({ start(c) { c.close(); } }), { status: 200 }); + return new Response( + new ReadableStream({ + start(c) { + c.close(); + }, + }), + { status: 200 } + ); }) as typeof fetch; try { @@ -171,3 +176,41 @@ test("log-streamer.ts calls /api/cli-tools/logs (correct URL, not the missing ro "log-streamer should not call /api/logs/console" ); }); + +test("log-streamer forwards auth headers to fetch (regression: 401 against authed servers)", async () => { + const { createLogStream } = await import("../../src/lib/cli-helper/log-streamer.ts"); + let capturedInit: RequestInit | undefined; + const origFetch = globalThis.fetch; + + globalThis.fetch = (async (_url: string, init?: RequestInit) => { + capturedInit = init; + return new Response( + new ReadableStream({ + start(c) { + c.close(); + }, + }), + { status: 200 } + ); + }) as typeof fetch; + + try { + const { stream, stop } = createLogStream({ + baseUrl: "http://localhost:20128", + headers: { authorization: "Bearer test-token" }, + }); + const reader = stream.getReader(); + await reader.read().catch(() => {}); + stop(); + } finally { + globalThis.fetch = origFetch; + } + + assert.ok(capturedInit, "fetch should receive an init object"); + const headers = new Headers(capturedInit!.headers); + assert.equal( + headers.get("authorization"), + "Bearer test-token", + "createLogStream must forward the provided auth headers to fetch" + ); +}); diff --git a/tests/unit/combo-routing-engine.test.ts b/tests/unit/combo-routing-engine.test.ts index ed07c9ac1e..fc0ce964b6 100644 --- a/tests/unit/combo-routing-engine.test.ts +++ b/tests/unit/combo-routing-engine.test.ts @@ -2978,8 +2978,8 @@ test("#3587 reasoning buffer is disabled without explicit model capability data" ); assert.equal( resolveReasoningBufferedMaxTokens("openai/default-cap-reasoning", 100), - null, - "default-sized caps are treated as unknown because registry fallbacks use the same value" + 1100, + "explicit default-sized caps are treated as real capability data" ); }); diff --git a/tests/unit/combo-strategies.test.ts b/tests/unit/combo-strategies.test.ts index 25e87c0d9a..f5d5c9a26f 100644 --- a/tests/unit/combo-strategies.test.ts +++ b/tests/unit/combo-strategies.test.ts @@ -423,30 +423,32 @@ test("reset-aware strategy avoids accounts near 5h exhaustion", async (t) => { assert.equal(await selectedConnectionFor(combo), healthy5h.id); }); -test("reset-aware strategy rotates similar scores with round-robin tie breaking", async (t) => { - const first = { id: `first-${randomUUID()}`, token: `token-first-${randomUUID()}` }; - const second = { - id: `second-${randomUUID()}`, - token: `token-second-${randomUUID()}`, - }; - const reset5hAt = Math.floor((Date.now() + 2 * 3600 * 1000) / 1000); - const reset7dAt = Math.floor((Date.now() + 3 * 24 * 3600 * 1000) / 1000); +test("reset-aware strategy rotates similar scores with round-robin tie breaking", async () => { + const provider = `tie-provider-${randomUUID()}`; + const first = `first-${randomUUID()}`; + const second = `second-${randomUUID()}`; const quota = { - rate_limit: { - primary_window: { used_percent: 50, reset_at: reset5hAt }, - secondary_window: { used_percent: 50, reset_at: reset7dAt }, - }, + used: 50, + total: 100, + percentUsed: 0.5, + resetAt: "2099-01-01T00:00:00.000Z", }; - t.after( - installCodexQuotaMock({ - [first.token]: quota, - [second.token]: quota, - }) - ); - const combo = resetAwareCombo(`reset-aware-rr-${randomUUID()}`, [first, second], { - resetAwareTieBandPercent: 100, - }); + registerQuotaFetcher(provider, async () => quota); + + const combo = { + name: `reset-aware-rr-${randomUUID()}`, + strategy: "reset-aware", + config: { resetAwareTieBandPercent: 100 }, + models: [first, second].map((connectionId, index) => ({ + kind: "model", + provider, + providerId: provider, + model: "balanced-model", + connectionId, + id: `tie-${index}`, + })), + }; const selections = [ await selectedConnectionFor(combo), @@ -454,8 +456,8 @@ test("reset-aware strategy rotates similar scores with round-robin tie breaking" await selectedConnectionFor(combo), ]; - assert.equal(selections.includes(first.id), true); - assert.equal(selections.includes(second.id), true); + assert.equal(selections.includes(first), true); + assert.equal(selections.includes(second), true); }); test("reset-aware strategy uses registered quota fetchers for non-Codex providers", async () => { diff --git a/tests/unit/combo-vision-aware-routing.test.ts b/tests/unit/combo-vision-aware-routing.test.ts index 7f36874b0c..f617537743 100644 --- a/tests/unit/combo-vision-aware-routing.test.ts +++ b/tests/unit/combo-vision-aware-routing.test.ts @@ -41,10 +41,7 @@ test.after(() => { // --- Part A: capability resolution ----------------------------------------- test("Pixtral resolves supportsVision=true via model-id heuristic (no synced data)", () => { - assert.equal( - getResolvedModelCapabilities("mistral/pixtral-12b-latest").supportsVision, - true - ); + assert.equal(getResolvedModelCapabilities("mistral/pixtral-12b-latest").supportsVision, true); }); test("a text-only Mistral model is NOT a vision false-positive", () => { @@ -115,3 +112,14 @@ test("text-only request: targets are untouched by the vision filter", () => { ); assert.equal(out.length, 1); }); + +test("large output request: unknown maxOutputTokens does not filter a target", () => { + const out = filterTargetsByRequestCompatibility( + [target("openai-compatible-local/custom-large-output-model"), target("openai/gpt-4o-mini")], + { messages: [{ role: "user", content: "hello" }], max_tokens: 32000 }, + noopLog + ); + const ids = out.map((t) => t.modelStr); + + assert.deepEqual(ids, ["openai-compatible-local/custom-large-output-model"]); +}); diff --git a/tests/unit/combo/auto-quota-cutoff.test.ts b/tests/unit/combo/auto-quota-cutoff.test.ts index 823d9c95f3..2b73e0f1c8 100644 --- a/tests/unit/combo/auto-quota-cutoff.test.ts +++ b/tests/unit/combo/auto-quota-cutoff.test.ts @@ -4,6 +4,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { scoreAutoTargets } from "../../../open-sse/services/combo/autoStrategy.ts"; +import { getConnectionStatusQuotaCutoffReason } from "../../../open-sse/services/combo.ts"; import type { AutoProviderCandidate, ResolvedComboTarget, @@ -121,3 +122,78 @@ test("blocked quota candidates are not included in the scoring pool", () => { "the blocked GLM latency must not inflate surviving candidates' scores" ); }); + +test("connection terminal status maps to quota cutoff reason", () => { + assert.equal( + getConnectionStatusQuotaCutoffReason({ testStatus: "credits_exhausted" }), + "credits_exhausted" + ); + assert.equal(getConnectionStatusQuotaCutoffReason({ testStatus: "expired" }), "expired"); + assert.equal(getConnectionStatusQuotaCutoffReason({ testStatus: "active" }), undefined); +}); + +test("future unavailable connection maps to rate_limited quota cutoff reason", () => { + assert.equal( + getConnectionStatusQuotaCutoffReason({ + testStatus: "unavailable", + rateLimitedUntil: new Date(Date.now() + 60_000).toISOString(), + }), + "rate_limited" + ); + assert.equal( + getConnectionStatusQuotaCutoffReason({ + testStatus: "unavailable", + rateLimitedUntil: new Date(Date.now() - 60_000).toISOString(), + }), + undefined + ); +}); + +test("status-blocked candidates are removed before auto scoring", () => { + const targets = [ + target("puter", "fast-free", "puter-empty"), + target("cerebras", "healthy", "cerebras-ok"), + ]; + const ranked = scoreAutoTargets( + targets, + [ + candidate("puter", "fast-free", "puter-empty", { + quotaRemaining: 0, + p95LatencyMs: 5, + quotaCutoffBlocked: true, + quotaCutoffReason: "credits_exhausted", + }), + candidate("cerebras", "healthy", "cerebras-ok", { + quotaRemaining: 100, + p95LatencyMs: 5000, + }), + ], + "coding", + latencyOnlyWeights + ); + + assert.equal(ranked.length, 1); + assert.equal(ranked[0]?.target.provider, "cerebras"); +}); + +// --- Maintainer gate (#4592 review): terminal-status cutoff is opt-in (#4483) --- +// buildAutoCandidates only marks a terminal-status connection as quotaCutoffBlocked +// when the quota-cutoff opt-in is enabled. With the opt-in OFF, the connection must +// fall through to normal connection-cooldown / model-lockout handling instead of being +// hard-excluded here (which would surface a misleading "below quota cutoff" 429 when +// every candidate is merely transiently unavailable). This locks the gating expression +// `quotaCutoffEnabled ? getConnectionStatusQuotaCutoffReason(conn) : undefined` +// against accidental removal. +test("terminal-status cutoff is consulted only when quota cutoff is enabled", () => { + const terminalConn = { testStatus: "credits_exhausted" }; + + // Helper itself still classifies the terminal status (unchanged). + assert.equal(getConnectionStatusQuotaCutoffReason(terminalConn), "credits_exhausted"); + + // The gate: enabled → reason flows through; disabled → suppressed (fall-through). + const gated = (enabled: boolean) => + enabled ? getConnectionStatusQuotaCutoffReason(terminalConn) : undefined; + + assert.equal(gated(true), "credits_exhausted", "enabled: terminal status blocks the candidate"); + assert.equal(gated(false), undefined, "disabled: terminal status must NOT pre-block (opt-in)"); +}); diff --git a/tests/unit/compression/compression-header-dispatch.test.ts b/tests/unit/compression/compression-header-dispatch.test.ts new file mode 100644 index 0000000000..09515c1014 --- /dev/null +++ b/tests/unit/compression/compression-header-dispatch.test.ts @@ -0,0 +1,191 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + selectCompressionStrategy, + selectCompressionPlan, + planFromHeader, + formatCompressionMeta, + buildNamedComboLookup, +} from "../../../open-sse/services/compression/strategySelector.ts"; +import { + DEFAULT_COMPRESSION_CONFIG, + type CompressionConfig, +} from "../../../open-sse/services/compression/types.ts"; + +const combos = { + c1: [ + { engine: "rtk", intensity: "standard" }, + { engine: "caveman", intensity: "full" }, + ], + "fast combo": [{ engine: "lite" }], +}; + +function cfg(overrides: Partial = {}): CompressionConfig { + return { ...DEFAULT_COMPRESSION_CONFIG, enabled: true, ...overrides }; +} + +// header is the 7th positional arg of selectCompressionPlan/selectCompressionStrategy. +function planWithHeader(config: CompressionConfig, header: string | null) { + return selectCompressionPlan(config, null, 0, undefined, undefined, combos, header); +} + +describe("planFromHeader (Phase 3)", () => { + it("off => mode off, source request-header", () => { + const p = planFromHeader(cfg(), "off", combos); + assert.deepEqual(p, { mode: "off", stackedPipeline: [], source: "request-header" }); + }); + + it("default => the panel-derived default, ignoring the active profile", () => { + const config = cfg({ + activeComboId: "c1", + enginesExplicit: true, + engines: { rtk: { enabled: true } }, + }); + const p = planFromHeader(config, "default", combos); + assert.equal(p?.mode, "rtk"); // engines map default, NOT the c1 active profile + assert.equal(p?.source, "request-header"); + }); + + it("engine: => that single engine when enabled (case-insensitive)", () => { + const config = cfg({ enginesExplicit: true, engines: { rtk: { enabled: true } } }); + assert.equal(planFromHeader(config, "engine:RTK", combos)?.mode, "rtk"); + }); + + it("engine: => null (fall-through) when the engine is disabled", () => { + const config = cfg({ engines: { rtk: { enabled: false } } }); + assert.equal(planFromHeader(config, "engine:rtk", combos), null); + }); + + it("engine: => tolerates whitespace after the colon", () => { + const config = cfg({ enginesExplicit: true, engines: { rtk: { enabled: true } } }); + assert.equal(planFromHeader(config, "engine: rtk", combos)?.mode, "rtk"); + }); + + it(" matches by name (case-insensitive) and by id", () => { + assert.deepEqual( + planFromHeader(cfg(), "FAST COMBO", combos)?.stackedPipeline, + combos["fast combo"] + ); + assert.deepEqual(planFromHeader(cfg(), "c1", combos)?.stackedPipeline, combos.c1); + }); + + it("unknown value => null (fall-through)", () => { + assert.equal(planFromHeader(cfg(), "nonsense", combos), null); + }); +}); + +describe("header precedence in resolveBasePlan (Phase 3)", () => { + it("a valid header beats the active profile", () => { + const config = cfg({ activeComboId: "c1" }); + assert.equal(planWithHeader(config, "off").mode, "off"); + assert.equal(planWithHeader(config, "off").source, "request-header"); + }); + + it("a valid header beats a routing-combo override", () => { + const config = cfg({ comboOverrides: { "route-x": "stacked" } }); + const plan = selectCompressionPlan(config, "route-x", 0, undefined, undefined, combos, "off"); + assert.equal(plan.mode, "off"); + assert.equal(plan.source, "request-header"); + }); + + it("a valid header bypasses auto-trigger (Decision B)", () => { + const config = cfg({ + autoTriggerTokens: 1000, + autoTriggerMode: "aggressive", + enginesExplicit: true, + engines: { rtk: { enabled: true } }, + }); + // Large prompt would auto-escalate to aggressive; the header pins the panel default. + assert.equal(planWithHeader(config, "default").mode, "rtk"); + }); + + it("an unknown header falls through to the normal resolution", () => { + const config = cfg({ activeComboId: "c1" }); + const plan = planWithHeader(config, "bogus"); + assert.equal(plan.mode, "stacked"); + assert.equal(plan.source, "active-profile"); + }); + + it("master-off beats the header (hard kill switch)", () => { + const config = cfg({ enabled: false, engines: { rtk: { enabled: true } } }); + const plan = planWithHeader(config, "engine:rtk"); + assert.equal(plan.mode, "off"); + assert.equal(plan.source, "off"); + }); +}); + +describe("source on non-header paths", () => { + it("routing-override / active-profile / auto-trigger / default / off", () => { + assert.equal( + selectCompressionPlan( + cfg({ comboOverrides: { r: "lite" } }), + "r", + 0, + undefined, + undefined, + combos, + null + ).source, + "routing-override" + ); + assert.equal(planWithHeader(cfg({ activeComboId: "c1" }), null).source, "active-profile"); + // auto-trigger only fires once estimatedTokens crosses autoTriggerTokens, so pass an + // estimate above the threshold (planWithHeader pins estimatedTokens=0 and never trips it). + assert.equal( + selectCompressionPlan( + cfg({ autoTriggerTokens: 10, autoTriggerMode: "lite" }), + null, + 50, + undefined, + undefined, + combos, + null + ).source, + "auto-trigger" + ); + assert.equal( + planWithHeader(cfg({ enginesExplicit: true, engines: { rtk: { enabled: true } } }), null) + .source, + "default" + ); + assert.equal(planWithHeader(cfg({ enabled: false }), null).source, "off"); + }); +}); + +describe("formatCompressionMeta", () => { + it("renders '; source='", () => { + assert.equal( + formatCompressionMeta({ mode: "aggressive", stackedPipeline: [], source: "request-header" }), + "aggressive; source=request-header" + ); + assert.equal(formatCompressionMeta({ mode: "off", stackedPipeline: [] }), "off; source=off"); + }); +}); + +describe("buildNamedComboLookup", () => { + const pipe = [{ engine: "lite" }]; + + it("keys each combo by both id and lowercased name", () => { + const map = buildNamedComboLookup([{ id: "abc-123", name: "My Fast Combo", pipeline: pipe }]); + assert.deepEqual(map["abc-123"], pipe); + assert.deepEqual(map["my fast combo"], pipe); + }); + + it("skips the name key (no '' key, no crash) when the name is blank/whitespace/missing", () => { + const map = buildNamedComboLookup([ + { id: "id-empty", name: "", pipeline: pipe }, + { id: "id-space", name: " ", pipeline: pipe }, + { id: "id-null", name: null, pipeline: pipe }, + ]); + assert.deepEqual(map["id-empty"], pipe); // id key always present + assert.deepEqual(map["id-space"], pipe); + assert.deepEqual(map["id-null"], pipe); + assert.equal(Object.prototype.hasOwnProperty.call(map, ""), false); // no blank key + assert.equal(Object.keys(map).length, 3); // only the three id keys + }); + + it("trims surrounding whitespace on the name key", () => { + const map = buildNamedComboLookup([{ id: "x", name: " Spaced ", pipeline: pipe }]); + assert.deepEqual(map["spaced"], pipe); + }); +}); diff --git a/tests/unit/db-usage-analytics-3500.test.ts b/tests/unit/db-usage-analytics-3500.test.ts index d61f7b1787..e9d49c4326 100644 --- a/tests/unit/db-usage-analytics-3500.test.ts +++ b/tests/unit/db-usage-analytics-3500.test.ts @@ -97,13 +97,12 @@ test.after(() => { // --------------------------------------------------------------------------- test("#3500 buildUnifiedSource — raw-only branch when sinceIso is recent", () => { - // A very recent sinceIso means no aggregated rows are needed - const recentIso = new Date(Date.now() - 3600_000).toISOString(); // 1 hour ago - const today = new Date().toISOString().split("T")[0]; + // sinceIso >= rawCutoffDate means no aggregated rows are needed. + const recentIso = "2025-06-02T12:00:00.000Z"; const result = mod.buildUnifiedSource({ sinceIso: recentIso, untilIso: null, - rawCutoffDate: today, + rawCutoffDate: "2025-06-01", apiKeyWhere: "", apiKeyParams: {}, }); diff --git a/tests/unit/deepseek-web-tools-execute.test.ts b/tests/unit/deepseek-web-tools-execute.test.ts new file mode 100644 index 0000000000..9c8a19d46d --- /dev/null +++ b/tests/unit/deepseek-web-tools-execute.test.ts @@ -0,0 +1,181 @@ +// @ts-nocheck +// deepseek-web executor-level tool behavior (full execute() pipeline, fetch mocked): +// 1. A reply interleaving natural-language text with a (DeepSeek-flavoured) tool call must +// surface BOTH the text AND the parsed tool_calls — streaming and non-streaming alike. +// 2. An agentic (tool-using) conversation must replay the whole trajectory (prior tool calls +// + their results) into the flat web `prompt`, so the model keeps context across turns +// instead of restarting. +// Pure parser / prompt-builder unit tests live in deepseek-web-tools-variants.test.ts. +import test from "node:test"; +import assert from "node:assert/strict"; + +const dsMod = await import("../../open-sse/executors/deepseek-web.ts"); +const { DeepSeekWebExecutor } = dsMod; + +const POW_CHALLENGE = { + algorithm: "DeepSeekHashV1", + challenge: "311b26ae1e0fe7375e242958ce46db5552a6c67fea3f96880dcd846c63a74286", + salt: "1122334455667788", + signature: "sig123", + difficulty: 1, + expire_at: 1778891543095, + expire_after: 300000, + target_path: "/api/v0/chat/completion", +}; + +function sseWithContent(text) { + return [ + "event: ready\n", + 'data: {"request_message_id":1,"response_message_id":2}\n', + "\n", + `data: ${JSON.stringify({ v: { response: { message_id: 2, fragments: [{ id: 1, type: "RESPONSE", content: text }] } } })}\n`, + "\n", + 'data: {"p":"response/status","o":"SET","v":"FINISHED"}\n', + "\n", + "event: close\n", + 'data: {"click_behavior":"none"}\n', + ].join(""); +} + +function installMock(completionText) { + const original = globalThis.fetch; + const calls = { completionBodies: [] }; + dsMod.tokenCache?.clear(); + dsMod.sessionCache?.clear(); + globalThis.fetch = async (url, opts = {}) => { + const u = String(url); + if (u.includes("/users/current")) + return new Response( + JSON.stringify({ code: 0, data: { biz_data: { token: "access-token-xyz" } } }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + if (u.includes("/chat_session/create")) + return new Response( + JSON.stringify({ code: 0, data: { biz_data: { chat_session: { id: "s-1" } } } }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + if (u.includes("/chat_session/delete")) + return new Response(JSON.stringify({ code: 0 }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + if (u.includes("/create_pow_challenge")) + return new Response( + JSON.stringify({ code: 0, data: { biz_data: { challenge: POW_CHALLENGE } } }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + if (u.includes("/chat/completion")) { + try { + calls.completionBodies.push(JSON.parse(opts.body)); + } catch { + calls.completionBodies.push(null); + } + return new Response(new TextEncoder().encode(sseWithContent(completionText)), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } + return new Response("not found", { status: 404 }); + }; + return { + calls, + restore: () => { + globalThis.fetch = original; + dsMod.tokenCache?.clear(); + dsMod.sessionCache?.clear(); + }, + }; +} + +const TOOLS = [ + { type: "function", function: { name: "todowrite", description: "Write todos" } }, + { type: "function", function: { name: "bash", description: "Run a shell command" } }, +]; + +// ── 1. Surrounding text + tool_calls are both surfaced ────────────────────── + +// Explanation text followed by a `` block. +const TEXT_PLUS_TOOL = 'I\'ll create a script.\n\n\n{"command": "echo hi"}\n'; +const EXPLANATION = "I'll create a script."; + +test("non-stream: surrounding text AND tool_calls are both returned", async () => { + const mock = installMock(TEXT_PLUS_TOOL); + try { + const result = await new DeepSeekWebExecutor().execute({ + model: "default", + body: { messages: [{ role: "user", content: "go" }], tools: TOOLS }, + stream: false, + credentials: { apiKey: "tkn-text-ns" }, + signal: AbortSignal.timeout(10000), + }); + const choice = JSON.parse(await result.response.text()).choices[0]; + assert.equal(choice.finish_reason, "tool_calls"); + assert.equal(choice.message.tool_calls[0].function.name, "bash"); + assert.deepEqual(JSON.parse(choice.message.tool_calls[0].function.arguments), { + command: "echo hi", + }); + assert.ok(choice.message.content.includes(EXPLANATION), "explanation text preserved"); + assert.ok(!choice.message.content.includes(" { + const mock = installMock(TEXT_PLUS_TOOL); + try { + const result = await new DeepSeekWebExecutor().execute({ + model: "default", + body: { messages: [{ role: "user", content: "go" }], tools: TOOLS }, + stream: true, + credentials: { apiKey: "tkn-text-stream" }, + signal: AbortSignal.timeout(10000), + }); + const text = await result.response.text(); + assert.ok(text.includes(EXPLANATION), "stream carries the explanation text"); + assert.ok(text.includes("tool_calls"), "stream carries tool_calls"); + assert.ok(text.includes("echo hi"), "stream carries the arguments"); + assert.ok(text.includes('"finish_reason":"tool_calls"')); + assert.ok(text.includes("[DONE]")); + } finally { + mock.restore(); + } +}); + +// ── 2. Agentic context is replayed into the upstream prompt ────────────────── + +test("execute forwards the full agentic trajectory into the upstream prompt", async () => { + const mock = installMock("All done."); + try { + await new DeepSeekWebExecutor().execute({ + model: "default", + body: { + tools: TOOLS, + messages: [ + { role: "user", content: "write train.py" }, + { + role: "assistant", + content: "", + tool_calls: [ + { + id: "c1", + type: "function", + function: { name: "todowrite", arguments: '{"todos":[]}' }, + }, + ], + }, + { role: "tool", tool_call_id: "c1", content: "todos created" }, + ], + }, + stream: false, + credentials: { apiKey: "tkn-ctx" }, + signal: AbortSignal.timeout(10000), + }); + const prompt = mock.calls.completionBodies[0].prompt; + assert.ok(prompt.includes("write train.py"), "task retained"); + assert.ok(prompt.includes("todowrite"), "prior tool call retained"); + assert.ok(prompt.includes("todos created"), "prior tool result retained"); + } finally { + mock.restore(); + } +}); diff --git a/tests/unit/deepseek-web-tools-variants.test.ts b/tests/unit/deepseek-web-tools-variants.test.ts new file mode 100644 index 0000000000..2ef046779f --- /dev/null +++ b/tests/unit/deepseek-web-tools-variants.test.ts @@ -0,0 +1,225 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { + parseDeepSeekToolCalls, + serializeDeepSeekToolPrompt, + buildToolConversationPrompt, +} from "../../open-sse/translator/deepseekWebTools.ts"; + +// chat.deepseek.com emits tool invocations in many ad-hoc shapes. The deepseek-specific +// parser must recognize all of them, recover the real tool name/arguments, and preserve +// any surrounding natural-language text so it can still be streamed to the client. + +const TOOLS = [ + { type: "function", function: { name: "todowrite", description: "Write todos" } }, + { type: "function", function: { name: "bash", description: "Run a shell command" } }, + { type: "function", function: { name: "write", description: "Write a file" } }, + { + type: "function", + function: { + name: "get_weather", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + }, +]; + +function firstCall(text: string) { + const { toolCalls } = parseDeepSeekToolCalls(text, "call", TOOLS); + assert.ok(toolCalls && toolCalls.length >= 1, `expected a tool call for: ${text.slice(0, 60)}`); + return toolCalls[0]; +} + +describe("deepseekWebTools — variants", () => { + test("Ex1: {json} — name in tag suffix, body is the arguments", () => { + const text = `I'll write a script.\n\n\n{"todos": [{"content": "a", "status": "in_progress", "priority": "high"}]}\n`; + const { content, toolCalls } = parseDeepSeekToolCalls(text, "call", TOOLS); + assert.equal(toolCalls?.length, 1); + assert.equal(toolCalls![0].function.name, "todowrite"); + assert.deepEqual(JSON.parse(toolCalls![0].function.arguments), { + todos: [{ content: "a", status: "in_progress", priority: "high" }], + }); + assert.ok(content.includes("I'll write a script."), "surrounding text preserved"); + assert.ok(!content.includes(" with id/type/params shape", () => { + const text = `I'll draft a plan.\n\n{"id": "todo_1", "type": "todo", "params": {"todos": [{"content": "x", "status": "pending", "priority": "high"}]}}\n`; + const call = firstCall(text); + assert.equal(call.function.name, "todowrite", "type 'todo' fuzzy-resolves to todowrite"); + assert.deepEqual(JSON.parse(call.function.arguments), { + todos: [{ content: "x", status: "pending", priority: "high" }], + }); + }); + + test("Ex5: nested {json} — inner canonical call", () => { + const text = `\n{"name": "todowrite", "arguments": {"todos": [{"content": "y", "status": "pending", "priority": "high"}]}}`; + const { content, toolCalls } = parseDeepSeekToolCalls(text, "call", TOOLS); + assert.equal(toolCalls?.length, 1); + assert.equal(toolCalls![0].function.name, "todowrite"); + assert.ok(!content.includes("{command} — body is the arguments payload", () => { + const text = `I'll create a script.\n\n\n{"command": "echo hi"}\n`; + const call = firstCall(text); + assert.equal(call.function.name, "bash"); + assert.deepEqual(JSON.parse(call.function.arguments), { command: "echo hi" }); + }); + + test('Ex7: write{json}', () => { + const text = `I'll create a script.\n\n write\n \n {"filePath": "/tmp/train.py", "content": "print(1)"}\n \n`; + const call = firstCall(text); + assert.equal(call.function.name, "write"); + assert.deepEqual(JSON.parse(call.function.arguments), { + filePath: "/tmp/train.py", + content: "print(1)", + }); + }); + + test('Ex8: {json} — name attribute on inner', () => { + const text = `\n{"todos":[{"content":"c","status":"in_progress","priority":"high"}]}\n`; + const call = firstCall(text); + assert.equal(call.function.name, "todowrite"); + assert.deepEqual(JSON.parse(call.function.arguments), { + todos: [{ content: "c", status: "in_progress", priority: "high" }], + }); + }); + + test('Ex9: — parameter style', () => { + const text = `\n`; + const call = firstCall(text); + assert.equal(call.function.name, "write"); + assert.deepEqual(JSON.parse(call.function.arguments), { content: "print('hi')" }); + }); + + test('Ex12: {json} — id resolves to a tool name', () => { + const text = `I'll create a script.\n\n{"todos": [{"content": "z", "status": "in_progress", "priority": "high"}]}\n`; + const call = firstCall(text); + assert.equal(call.function.name, "todowrite"); + assert.deepEqual(JSON.parse(call.function.arguments), { + todos: [{ content: "z", status: "in_progress", priority: "high" }], + }); + }); + + test("canonical {name,arguments} still works (no regression)", () => { + const text = `{"name": "get_weather", "arguments": {"city": "Paris"}}`; + const call = firstCall(text); + assert.equal(call.function.name, "get_weather"); + assert.deepEqual(JSON.parse(call.function.arguments), { city: "Paris" }); + }); + + test("bare JSON (no tags) still resolves via fuzzy name match", () => { + const text = `{"name":"getWeather","arguments":{"city":"Paris"}}`; + const call = firstCall(text); + assert.equal(call.function.name, "get_weather"); + assert.deepEqual(JSON.parse(call.function.arguments), { city: "Paris" }); + }); + + test("#3260: tag name attribute is bogus, real name is in JSON body", () => { + const text = `{"name": "get_weather", "arguments": {"city": "SP"}}`; + const call = firstCall(text); + assert.equal(call.function.name, "get_weather"); + }); + + test("multiple tags mixing content-attr and body styles are all captured", () => { + // Regression: an attribute-style with no closing tag must not let the + // body matcher swallow a following body-style ... (lost param). + const text = `\n\nprint(1)\n`; + const call = firstCall(text); + assert.equal(call.function.name, "write"); + assert.deepEqual(JSON.parse(call.function.arguments), { + filePath: "/tmp/a.py", + content: "print(1)", + }); + }); + + test("surrounding text is preserved both before and after the tool block", () => { + const text = `Before text.\n\n{"command": "ls"}\n\nAfter text.`; + const { content, toolCalls } = parseDeepSeekToolCalls(text, "call", TOOLS); + assert.equal(toolCalls?.length, 1); + assert.ok(content.includes("Before text.")); + assert.ok(content.includes("After text.")); + }); +}); + +describe("deepseekWebTools — pure-text (no tool) replies", () => { + for (const [label, text] of [ + ["Ex2 plan only", "I'll build a train animation. Plan:\n1. step\n2. step\nStarting now."], + ["Ex4 code fence only", "Plan:\n```python\nimport os\nprint(os.getcwd())\n```"], + ["Ex13 bash fence only", "```bash\nuv pip install rich\n```"], + ] as const) { + test(`${label} — returns plain content, no tool calls`, () => { + const { content, toolCalls } = parseDeepSeekToolCalls(text, "call", TOOLS); + assert.equal(toolCalls, null, "no tool call should be parsed"); + assert.equal(content, text, "content returned unchanged"); + }); + } +}); + +describe("deepseekWebTools — strict prompt", () => { + test("lists tools and mandates the exact JSON format", () => { + const prompt = serializeDeepSeekToolPrompt(TOOLS); + assert.ok(prompt.includes("todowrite")); + assert.ok(prompt.includes("get_weather")); + assert.ok(prompt.includes('{"name"'), "shows the canonical format"); + assert.ok(/never|not|do not/i.test(prompt), "warns against alternative formats"); + }); + + test("returns empty string when there are no usable tools", () => { + assert.equal(serializeDeepSeekToolPrompt([]), ""); + assert.equal(serializeDeepSeekToolPrompt(undefined), ""); + }); +}); + +describe("deepseekWebTools — buildToolConversationPrompt (agentic context)", () => { + // A tool-using conversation must replay the WHOLE trajectory (prior assistant tool_calls + + // their tool results) into the flat web `prompt`, otherwise the model is amnesiac and + // restarts every turn — re-creating todos, re-listing files, etc. + // The legacy builder only forwarded the last user message and dropped + // tool_calls / role:"tool" messages. The executor-level coverage lives in + // deepseek-web-tools-execute.test.ts. + test("replays prior tool calls and their results", () => { + const messages = [ + { role: "user", content: "write train.py" }, + { + role: "assistant", + content: "", + tool_calls: [ + { + id: "c1", + type: "function", + function: { name: "todowrite", arguments: '{"todos":[{"content":"a"}]}' }, + }, + ], + }, + { role: "tool", tool_call_id: "c1", content: "todos created" }, + { + role: "assistant", + content: "", + tool_calls: [ + { id: "c2", type: "function", function: { name: "bash", arguments: '{"command":"ls"}' } }, + ], + }, + { role: "tool", tool_call_id: "c2", content: "train.py\ncat.md" }, + ]; + + const prompt = buildToolConversationPrompt(messages, "TOOLS-HERE"); + + assert.ok(prompt.includes("TOOLS-HERE"), "tool system prompt leads"); + assert.ok(prompt.includes("User: write train.py"), "original task present"); + assert.ok(prompt.includes('"name": "todowrite"'), "prior todowrite call replayed"); + assert.ok( + prompt.includes("Tool result (todowrite): todos created"), + "todowrite result replayed" + ); + assert.ok(prompt.includes('"name": "bash"'), "prior bash call replayed"); + assert.ok(prompt.includes("Tool result (bash): train.py"), "bash result replayed"); + assert.ok(/Continue the task/i.test(prompt), "anchored to continue, not restart"); + }); + + test("first-turn (no prior tool activity) omits the continue anchor", () => { + const prompt = buildToolConversationPrompt([{ role: "user", content: "hi" }], "TOOLS"); + assert.ok(prompt.includes("User: hi")); + assert.ok(!/Continue the task/i.test(prompt), "no continue anchor on the first turn"); + }); +}); diff --git a/tests/unit/direct-dispatcher-pipelining-4580.test.ts b/tests/unit/direct-dispatcher-pipelining-4580.test.ts new file mode 100644 index 0000000000..a56ed90831 --- /dev/null +++ b/tests/unit/direct-dispatcher-pipelining-4580.test.ts @@ -0,0 +1,52 @@ +import { describe, it, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { + __getDefaultDispatcherOptionsForTest, + __getProxyDispatcherOptionsForTest, + getDefaultDispatcherConnectionLimit, + clearDispatcherCache, +} from "../../open-sse/utils/proxyDispatcher.ts"; + +afterEach(() => clearDispatcherCache()); + +// #4580 — On the DIRECT egress path, concurrent same-provider requests serialized +// behind a long/streaming request. The proxy dispatcher already got pipelining:0 + +// a connections cap in #4288, but the first-attempt direct dispatcher +// (getDispatcherOptions → new Agent) kept undici's default pipelining (1), so long +// SSE streams bottlenecked the single pooled socket. The direct dispatcher now +// mirrors that fix while KEEPING keep-alive (a proxy-only concern was the 1ms TTL). + +describe("#4580 direct dispatcher options", () => { + it("disables pipelining so concurrent streams open separate sockets", () => { + const opts = __getDefaultDispatcherOptionsForTest({}); + assert.equal(opts.pipelining, 0); + }); + + it("caps connections to a finite number (default 32)", () => { + const opts = __getDefaultDispatcherOptionsForTest({}); + assert.equal(typeof opts.connections, "number"); + assert.equal(opts.connections, 32); + }); + + it("preserves keep-alive (NOT the 1ms TTL the proxy path forces)", () => { + const direct = __getDefaultDispatcherOptionsForTest({}); + const proxy = __getProxyDispatcherOptionsForTest({}); + assert.equal(proxy.keepAliveTimeout, 1); + assert.ok( + (direct.keepAliveTimeout ?? 0) > 1, + `direct keepAliveTimeout should stay > 1 (got ${direct.keepAliveTimeout})` + ); + }); + + it("connection limit honors OMNIROUTE_DIRECT_DISPATCHER_CONNECTIONS", () => { + assert.equal(getDefaultDispatcherConnectionLimit({ OMNIROUTE_DIRECT_DISPATCHER_CONNECTIONS: "8" }), 8); + }); + + it("connection limit clamps invalid values to the default", () => { + assert.equal( + getDefaultDispatcherConnectionLimit({ OMNIROUTE_DIRECT_DISPATCHER_CONNECTIONS: "nonsense" }), + 32 + ); + assert.equal(getDefaultDispatcherConnectionLimit({}), 32); + }); +}); diff --git a/tests/unit/error-classification.test.ts b/tests/unit/error-classification.test.ts index ab06557545..fac70d4ff6 100644 --- a/tests/unit/error-classification.test.ts +++ b/tests/unit/error-classification.test.ts @@ -190,11 +190,10 @@ test("parseRetryFromErrorText: parses will reset after variant", () => { // ─── T06: Keyword Matching for Long Cooldowns ──────────────────────────────── -// Fix #2321: QUOTA_EXHAUSTED text now sets the upstream cooldown duration even when -// useUpstreamRetryHints = false (e.g., OAuth providers like antigravity). The generic -// upstream-retry-hint opt-in only governs transient rate-limit hints; subscription -// quota resets always carry a definite recovery time, so the text is always honored. -test("quota reset text is honored for oauth providers even when generic retry hints are disabled", () => { +// Fix #1308 / #4429: QUOTA_EXHAUSTED text can provide a precise upstream reset +// window, but the operator's upstream retry hint setting must still decide +// whether that window is used for cooldowns. +test("quota reset text is ignored for oauth providers when upstream retry hints are disabled", () => { const result = checkFallbackError( 429, "Your quota will reset after 27h41m36s", @@ -203,10 +202,13 @@ test("quota reset text is honored for oauth providers even when generic retry hi "antigravity", null ); - // 27*3600 + 41*60 + 36 = 99696 seconds = 99696000 ms assert.equal(result.shouldFallback, true); - assert.equal(result.cooldownMs, 99696000); - assert.equal(result.usedUpstreamRetryHint, true); + assert.notEqual(result.cooldownMs, 99696000); + assert.ok( + result.cooldownMs < 5 * 60 * 1000, + `expected local short cooldown, got ${result.cooldownMs}ms` + ); + assert.equal(result.usedUpstreamRetryHint, false); assert.equal(result.reason, "quota_exhausted"); }); diff --git a/tests/unit/executor-github.test.ts b/tests/unit/executor-github.test.ts index dd2c1a4b3c..6d8194e043 100644 --- a/tests/unit/executor-github.test.ts +++ b/tests/unit/executor-github.test.ts @@ -426,6 +426,44 @@ test("GithubExecutor.execute preserves complete SSE responses including terminal } }); +test("GithubExecutor.transformRequest strips temperature for gpt-5.4 (port from 9router#612 / closes upstream #536)", () => { + // GitHub Copilot's gpt-5.4 family rejects requests carrying `temperature` with HTTP 400: + // "Unsupported parameter: 'temperature' is not supported with this model." + // OmniRoute's existing `stripGpt5SamplingWhenReasoning` guard only fires for + // provider==="openai" (raw api.openai.com Chat Completions) — Copilot requests run + // through GithubExecutor and never hit that guard. Strip temperature here so the + // 400 cannot reach the user. Other GitHub Copilot models keep temperature intact. + const executor = new GithubExecutor(); + + const stripped = executor.transformRequest( + "gpt-5.4", + { temperature: 0.7, messages: [{ role: "user", content: "hi" }] }, + true, + {} + ); + assert.equal(stripped.temperature, undefined, "temperature must be stripped for gpt-5.4"); + + const strippedMini = executor.transformRequest( + "gpt-5.4-mini", + { temperature: 0.3, messages: [{ role: "user", content: "hi" }] }, + true, + {} + ); + assert.equal( + strippedMini.temperature, + undefined, + "temperature must be stripped for gpt-5.4-mini" + ); + + const kept = executor.transformRequest( + "gpt-4.1", + { temperature: 0.7, messages: [{ role: "user", content: "hi" }] }, + true, + {} + ); + assert.equal(kept.temperature, 0.7, "temperature must be preserved for non-gpt-5.4 models"); +}); + test("GithubExecutor.transformRequest strips invalid synthetic Responses reasoning ids", () => { const executor = new GithubExecutor(); const result = executor.transformRequest( diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index 18699577e8..ab7fa4afdc 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -34,13 +34,13 @@ const { // Test group 1 — Flag definitions registry // ────────────────────────────────────────────────────── describe("featureFlagDefinitions", () => { - it("has exactly 35 flag definitions", () => { - assert.strictEqual(FEATURE_FLAG_DEFINITIONS.length, 35); + it("has exactly 37 flag definitions", () => { + assert.strictEqual(FEATURE_FLAG_DEFINITIONS.length, 37); }); it("has unique keys for all flags", () => { const keys = FEATURE_FLAG_DEFINITIONS.map((d) => d.key); - assert.strictEqual(new Set(keys).size, 35); + assert.strictEqual(new Set(keys).size, 37); }); it("has valid categories for all flags", () => { @@ -126,6 +126,27 @@ describe("featureFlagDefinitions", () => { assert.strictEqual(def.requiresRestart, false); }); + it("defines stream recovery as runtime boolean flags disabled by default", () => { + const early = FEATURE_FLAG_DEFINITIONS.find((d) => d.key === "STREAM_RECOVERY_ENABLED"); + const midstream = FEATURE_FLAG_DEFINITIONS.find( + (d) => d.key === "STREAM_RECOVERY_MIDSTREAM_ENABLED" + ); + + assert.ok(early, "STREAM_RECOVERY_ENABLED should exist"); + assert.strictEqual(early.category, "runtime"); + assert.strictEqual(early.type, "boolean"); + assert.strictEqual(early.defaultValue, "false"); + assert.strictEqual(early.requiresRestart, false); + assert.strictEqual(early.warningLevel, "caution"); + + assert.ok(midstream, "STREAM_RECOVERY_MIDSTREAM_ENABLED should exist"); + assert.strictEqual(midstream.category, "runtime"); + assert.strictEqual(midstream.type, "boolean"); + assert.strictEqual(midstream.defaultValue, "false"); + assert.strictEqual(midstream.requiresRestart, false); + assert.strictEqual(midstream.warningLevel, "danger"); + }); + it("defines control-plane proxy direct fallback as a network boolean flag disabled by default", () => { const def = FEATURE_FLAG_DEFINITIONS.find( (d) => d.key === "OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK" @@ -274,9 +295,9 @@ describe("resolveFeatureFlag", () => { }); describe("resolveAllFeatureFlags", () => { - it("returns all 35 flags", () => { + it("returns all 37 flags", () => { const all = resolveAllFeatureFlags(); - assert.strictEqual(all.length, 35); + assert.strictEqual(all.length, 37); }); it("marks DB-overridden flags with source 'db'", () => { diff --git a/tests/unit/firecrawl-executor.test.ts b/tests/unit/firecrawl-executor.test.ts index 395a77c219..7a909886d9 100644 --- a/tests/unit/firecrawl-executor.test.ts +++ b/tests/unit/firecrawl-executor.test.ts @@ -183,3 +183,41 @@ test("firecrawlFetch forwards depth and wait_for_selector", async () => { globalThis.fetch = originalFetch; } }); + +// ── #4692 regression: includeMetadata must NOT send invalid includeTags ──────── +// Firecrawl returns metadata automatically in response.data.metadata. Sending +// includeTags with non-CSS-selector values ("og:title", "description") crashed +// Firecrawl's parser with HTTP 500. The includeMetadata flag must only gate +// whether we surface metadata, never inject includeTags into the request. +test("firecrawlFetch with includeMetadata=true does not send includeTags (4692)", async () => { + const originalFetch = globalThis.fetch; + let capturedBody: Record = {}; + + globalThis.fetch = async (_url, init = {}) => { + capturedBody = JSON.parse(String((init as RequestInit).body ?? "{}")); + return new Response( + JSON.stringify({ + data: { markdown: "# Result", metadata: { title: "Test", description: "Desc" } }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + try { + const result = await firecrawlFetch({ + url: "https://example.com", + format: "markdown", + depth: 0, + includeMetadata: true, + credentials: { apiKey: "fc-test-key" }, + }); + + assert.equal(result.success, true); + assert.ok( + !("includeTags" in capturedBody), + "includeMetadata must not inject includeTags (Firecrawl 500)" + ); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/livews-forward-backoff-4604.test.ts b/tests/unit/livews-forward-backoff-4604.test.ts new file mode 100644 index 0000000000..d7fe6a2026 --- /dev/null +++ b/tests/unit/livews-forward-backoff-4604.test.ts @@ -0,0 +1,83 @@ +import { test, beforeEach } from "node:test"; +import assert from "node:assert/strict"; + +import { + forwardDashboardEventToLiveWs, + __resetLiveWsForwardingState, +} from "../../open-sse/handlers/chatCore/telemetryHelpers.ts"; + +// #4604 — In single-port Docker deployments the live-WS sidecar (port 20129) is +// not running, but forwardDashboardEventToLiveWs POSTed to it on every compression +// event. Because the global fetch is proxyFetch, each ECONNREFUSED logged a +// "[ProxyFetch] Undici dispatcher failed" warning — 272 times in 42 minutes. The +// forwarder now backs off after consecutive failures (lazy recovery), so a missing +// sidecar stops spamming instead of firing on every request. + +beforeEach(() => __resetLiveWsForwardingState()); + +function makeClock(start = 1000) { + let t = start; + const now = () => t; + return { now, advance: (ms: number) => (t += ms) }; +} + +test("forwards the event when the sidecar is reachable", async () => { + let calls = 0; + const ok = async () => { + calls++; + return new Response("ok"); + }; + await forwardDashboardEventToLiveWs("compression.step", { a: 1 }, ok, makeClock().now); + assert.equal(calls, 1); +}); + +test("backs off after consecutive failures and stops calling fetch", async () => { + let calls = 0; + const fail = async () => { + calls++; + throw new Error("connect ECONNREFUSED 127.0.0.1:20129"); + }; + const clock = makeClock(); + // First N attempts go through (and fail); after the threshold the forwarder + // short-circuits without touching fetch. + for (let i = 0; i < 10; i++) { + await forwardDashboardEventToLiveWs("compression.step", {}, fail, clock.now); + } + assert.ok(calls >= 1, "should attempt at least once"); + assert.ok(calls <= 3, `should stop after the failure threshold (got ${calls})`); +}); + +test("retries once after the cooldown window elapses", async () => { + let calls = 0; + const fail = async () => { + calls++; + throw new Error("ECONNREFUSED"); + }; + const clock = makeClock(); + for (let i = 0; i < 5; i++) { + await forwardDashboardEventToLiveWs("e", {}, fail, clock.now); + } + const afterTrip = calls; + clock.advance(120_000); // past the cooldown + await forwardDashboardEventToLiveWs("e", {}, fail, clock.now); + assert.equal(calls, afterTrip + 1, "should attempt again once cooldown expires"); +}); + +test("a success resets the failure counter", async () => { + let calls = 0; + let mode: "fail" | "ok" = "fail"; + const impl = async () => { + calls++; + if (mode === "fail") throw new Error("ECONNREFUSED"); + return new Response("ok"); + }; + const clock = makeClock(); + await forwardDashboardEventToLiveWs("e", {}, impl, clock.now); // fail (1) + mode = "ok"; + await forwardDashboardEventToLiveWs("e", {}, impl, clock.now); // success → reset + mode = "fail"; + const before = calls; + // After a reset, the next failures should once again be attempted (not pre-tripped). + await forwardDashboardEventToLiveWs("e", {}, impl, clock.now); + assert.equal(calls, before + 1); +}); diff --git a/tests/unit/m365-bizchat-frames-4042.test.ts b/tests/unit/m365-bizchat-frames-4042.test.ts new file mode 100644 index 0000000000..b67323148f --- /dev/null +++ b/tests/unit/m365-bizchat-frames-4042.test.ts @@ -0,0 +1,215 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// #4042 — Microsoft 365 Copilot (individual / Substrate BizChat) frame mapping. +// Fixtures below are taken from @skyzea1's real, sanitized capture of an +// `m365.cloud.microsoft/chat` round-trip (test prompt → one-word "pong" reply). +// These pin the wire format so the executor's encode/decode cannot drift; the +// live socket round-trip is the separate Rule #18 validation gate. + +import { + RECORD_SEPARATOR, + encodeFrame, + handshakeFrame, + keepaliveFrame, + splitFrames, + parseFrame, + handshakeError, + buildChatInvocation, + isUpdateFrame, + isCompletionFrame, + isLastUpdate, + extractBotText, + incrementalDelta, +} from "../../open-sse/executors/copilot-m365-frames.ts"; + +// ── Real captured frames (#4042) ────────────────────────────────────────── + +const HANDSHAKE_ACK = {}; // SignalR success ack +const PROGRESS_UPDATE = { + type: 1, + target: "update", + arguments: [{ messages: [{ messageType: "Progress", author: "bot" }] }], +}; +const BOT_UPDATE = { + type: 1, + target: "update", + arguments: [ + { + messages: [ + { + text: "pong", + author: "bot", + responseIdentifier: "Default", + messageId: "00000000-0000-0000-0000-000000000001", + requestId: "trace-id", + adaptiveCards: [ + { type: "AdaptiveCard", version: "1.0", body: [{ type: "TextBlock", text: "pong" }] }, + ], + sourceAttributions: [], + contentOrigin: "DeepLeo", + }, + ], + nonce: "nonce", + requestId: "trace-id", + }, + ], +}; +const FINAL_UPDATE = { + type: 1, + target: "update", + arguments: [ + { + messages: [ + { text: "pong", author: "bot", sourceAttributions: [], references: {}, contentOrigin: "DeepLeo" }, + ], + isLastUpdate: true, + requestId: "trace-id", + }, + ], +}; +const FINAL_ITEM = { type: 2, invocationId: "0", item: { messages: [], requestId: "trace-id" } }; +const COMPLETION = { type: 3, invocationId: "0" }; + +// ── Framing ──────────────────────────────────────────────────────────────── + +test("RECORD_SEPARATOR is the SignalR 0x1e control char", () => { + assert.equal(RECORD_SEPARATOR, String.fromCharCode(0x1e)); + assert.equal(RECORD_SEPARATOR.charCodeAt(0), 0x1e); +}); + +test("handshakeFrame / keepaliveFrame emit the exact SignalR bytes", () => { + assert.equal(handshakeFrame(), `{"protocol":"json","version":1}` + RECORD_SEPARATOR); + assert.equal(keepaliveFrame(), `{"type":6}` + RECORD_SEPARATOR); +}); + +test("encodeFrame appends the record separator", () => { + assert.equal(encodeFrame({ a: 1 }), `{"a":1}` + RECORD_SEPARATOR); +}); + +test("splitFrames separates complete frames and keeps the trailing partial", () => { + const buffer = encodeFrame(HANDSHAKE_ACK) + encodeFrame(BOT_UPDATE) + `{"type":1,"par`; + const { frames, rest } = splitFrames(buffer); + assert.equal(frames.length, 2); + assert.deepEqual(parseFrame(frames[0]), {}); + assert.equal(isUpdateFrame(parseFrame(frames[1])), true); + assert.equal(rest, `{"type":1,"par`); +}); + +test("splitFrames returns empty rest when the buffer ends on a separator", () => { + const { frames, rest } = splitFrames(encodeFrame(COMPLETION)); + assert.equal(frames.length, 1); + assert.equal(rest, ""); +}); + +// ── Handshake ──────────────────────────────────────────────────────────── + +test("handshakeError is null on the {} ack and surfaces an error string", () => { + assert.equal(handshakeError(parseFrame(encodeFrame(HANDSHAKE_ACK).slice(0, -1))), null); + assert.equal(handshakeError({ error: "bad handshake" }), "bad handshake"); +}); + +// ── Send (type:4) ────────────────────────────────────────────────────────── + +test("buildChatInvocation produces a type:4 chat invocation carrying the user text", () => { + const frame = buildChatInvocation({ + text: "protocol capture test. Reply with one word: pong.", + traceId: "trace-id", + sessionId: "session-id", + isStartOfSession: true, + }); + assert.equal(frame.type, 4); + assert.equal(frame.target, "chat"); + assert.equal(frame.invocationId, "0"); + const arg = (frame.arguments as Array>)[0]; + assert.equal(arg.source, "officeweb"); + assert.equal(arg.streamingMode, "ConciseWithPadding"); + assert.equal(arg.traceId, "trace-id"); + assert.equal(arg.clientCorrelationId, "trace-id"); + assert.equal(arg.sessionId, "session-id"); + assert.equal(arg.isStartOfSession, true); + assert.ok(Array.isArray(arg.allowedMessageTypes)); + assert.ok((arg.allowedMessageTypes as string[]).includes("Chat")); + const message = arg.message as Record; + assert.equal(message.author, "user"); + assert.equal(message.inputMethod, "Keyboard"); + assert.equal(message.messageType, "Chat"); + assert.equal(message.text, "protocol capture test. Reply with one word: pong."); +}); + +test("buildChatInvocation serializes/round-trips through the framing", () => { + const frame = buildChatInvocation({ text: "hi", traceId: "t", sessionId: "s" }); + const wire = encodeFrame(frame); + assert.ok(wire.endsWith(RECORD_SEPARATOR)); + const { frames } = splitFrames(wire); + assert.deepEqual(parseFrame(frames[0]), frame); +}); + +// ── Response decode (type:1/2/3) ───────────────────────────────────────── + +test("isUpdateFrame / isCompletionFrame classify the captured frames", () => { + assert.equal(isUpdateFrame(BOT_UPDATE), true); + assert.equal(isUpdateFrame(FINAL_UPDATE), true); + assert.equal(isUpdateFrame(COMPLETION), false); + assert.equal(isUpdateFrame(FINAL_ITEM), false); + assert.equal(isCompletionFrame(COMPLETION), true); + assert.equal(isCompletionFrame(BOT_UPDATE), false); + assert.equal(isCompletionFrame(FINAL_ITEM), false); // type:2 is the final item, not completion +}); + +test("isLastUpdate only fires on the isLastUpdate:true update", () => { + assert.equal(isLastUpdate(BOT_UPDATE), false); + assert.equal(isLastUpdate(FINAL_UPDATE), true); + assert.equal(isLastUpdate(COMPLETION), false); +}); + +test("extractBotText reads the bot answer and ignores Progress frames", () => { + assert.equal(extractBotText(BOT_UPDATE), "pong"); + assert.equal(extractBotText(FINAL_UPDATE), "pong"); + assert.equal(extractBotText(PROGRESS_UPDATE), null); + assert.equal(extractBotText(COMPLETION), null); +}); + +// ── Accumulated → incremental delta ───────────────────────────────────── + +test("incrementalDelta emits only the new suffix of accumulated text", () => { + assert.equal(incrementalDelta("", "pong"), "pong"); + assert.equal(incrementalDelta("pong", "pong"), ""); + assert.equal(incrementalDelta("po", "pong"), "ng"); + assert.equal(incrementalDelta("", ""), ""); +}); + +test("incrementalDelta falls back to the full text on a non-extending replace", () => { + assert.equal(incrementalDelta("abc", "xyz"), "xyz"); +}); + +// ── End-to-end decode of the captured stream ───────────────────────────── + +test("decoding the captured frame sequence reconstructs the bot answer once", () => { + const wire = + encodeFrame(HANDSHAKE_ACK) + + encodeFrame(PROGRESS_UPDATE) + + encodeFrame(BOT_UPDATE) + + encodeFrame(FINAL_UPDATE) + + encodeFrame(FINAL_ITEM) + + encodeFrame(COMPLETION); + + const { frames } = splitFrames(wire); + let emitted = ""; + let prev = ""; + let completed = false; + for (const raw of frames) { + const frame = parseFrame(raw); + if (isUpdateFrame(frame)) { + const text = extractBotText(frame); + if (text != null) { + emitted += incrementalDelta(prev, text); + prev = text; + } + } else if (isCompletionFrame(frame)) { + completed = true; + } + } + assert.equal(emitted, "pong"); + assert.equal(completed, true); +}); diff --git a/tests/unit/m365-connection-4042.test.ts b/tests/unit/m365-connection-4042.test.ts new file mode 100644 index 0000000000..af4890de62 --- /dev/null +++ b/tests/unit/m365-connection-4042.test.ts @@ -0,0 +1,109 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// #4042 — M365 Copilot (individual) connection helpers: credential resolution, +// WS URL building, token redaction, and prompt flattening. Pure functions, no +// live socket — the round-trip is the separate Rule #18 validation gate. + +import { + M365_INDIVIDUAL_DEFAULTS, + newChatSessionId, + resolveConnectionParams, + buildWsUrl, + redactWsUrl, + buildPrompt, +} from "../../open-sse/executors/copilot-m365-connection.ts"; + +// ── Credential resolution ──────────────────────────────────────────────── + +test("resolveConnectionParams errors when the access_token is missing", () => { + const r = resolveConnectionParams(undefined); + assert.ok("error" in r); + assert.match((r as { error: string }).error, /access_token/i); +}); + +test("resolveConnectionParams errors when the Chathub path is missing", () => { + const r = resolveConnectionParams({ apiKey: "tok" }); + assert.ok("error" in r); + assert.match((r as { error: string }).error, /Chathub path/i); +}); + +test("resolveConnectionParams reads token from apiKey and path from providerSpecificData", () => { + const r = resolveConnectionParams({ + apiKey: "opaque-jwe-token", + providerSpecificData: { chathubPath: "user-oid@tenant-id" }, + }); + assert.ok(!("error" in r)); + const p = r as { host: string; chathubPath: string; accessToken: string }; + assert.equal(p.accessToken, "opaque-jwe-token"); + assert.equal(p.chathubPath, "user-oid@tenant-id"); + assert.equal(p.host, M365_INDIVIDUAL_DEFAULTS.host); +}); + +test("resolveConnectionParams accepts access_token in providerSpecificData and a custom host", () => { + const r = resolveConnectionParams({ + providerSpecificData: { + access_token: "tok2", + userTenant: "u@t", + host: "substrate.svc.cloud.microsoft", + }, + }); + assert.ok(!("error" in r)); + const p = r as { host: string; chathubPath: string; accessToken: string }; + assert.equal(p.accessToken, "tok2"); + assert.equal(p.chathubPath, "u@t"); + assert.equal(p.host, "substrate.svc.cloud.microsoft"); +}); + +// ── WS URL building ────────────────────────────────────────────────────── + +test("buildWsUrl targets the substrate Chathub with the individual-tier query", () => { + const url = buildWsUrl({ host: "substrate.office.com", chathubPath: "u@t", accessToken: "TOK" }); + assert.ok(url.startsWith("wss://substrate.office.com/m365Copilot/Chathub/u@t?")); + const qs = new URLSearchParams(url.split("?")[1]); + assert.equal(qs.get("licenseType"), "Starter"); + assert.equal(qs.get("agent"), "web"); + assert.equal(qs.get("scenario"), "OfficeWebPaidConsumerCopilot"); + assert.equal(qs.get("source"), "officeweb"); + assert.equal(qs.get("access_token"), "TOK"); + // chatsessionid == XRoutingParameterSessionKey == clientrequestid (same value) + const sid = qs.get("chatsessionid"); + assert.ok(sid && /^[0-9a-f]{32}$/.test(sid)); + assert.equal(qs.get("XRoutingParameterSessionKey"), sid); + assert.equal(qs.get("clientrequestid"), sid); +}); + +test("redactWsUrl strips the access_token so the URL is safe to log", () => { + const url = buildWsUrl({ host: "substrate.office.com", chathubPath: "u@t", accessToken: "SECRET" }); + const redacted = redactWsUrl(url); + assert.ok(!redacted.includes("SECRET"), "token must not survive redaction"); + assert.match(redacted, /access_token=REDACTED/); +}); + +test("newChatSessionId is 32 lowercase hex chars", () => { + const id = newChatSessionId(); + assert.match(id, /^[0-9a-f]{32}$/); + assert.notEqual(newChatSessionId(), id); +}); + +// ── Prompt flattening ──────────────────────────────────────────────────── + +test("buildPrompt returns the last user message", () => { + const prompt = buildPrompt({ + messages: [ + { role: "user", content: "first" }, + { role: "user", content: "second" }, + ], + }); + assert.equal(prompt, "second"); +}); + +test("buildPrompt prepends system instructions", () => { + const prompt = buildPrompt({ + messages: [ + { role: "system", content: "Be terse." }, + { role: "user", content: "hi" }, + ], + }); + assert.match(prompt, /\[System Instructions\]\nBe terse\.\n\nhi$/); +}); diff --git a/tests/unit/minimax-tts-1043.test.ts b/tests/unit/minimax-tts-1043.test.ts new file mode 100644 index 0000000000..08ec181aab --- /dev/null +++ b/tests/unit/minimax-tts-1043.test.ts @@ -0,0 +1,101 @@ +// Port of decolua/9router#1043 by toanalien +// MiniMax T2A v2 returns hex-encoded audio in a JSON envelope guarded by `base_resp`. +import test from "node:test"; +import assert from "node:assert/strict"; + +const { handleAudioSpeech } = await import("../../open-sse/handlers/audioSpeech.ts"); + +const TEXT = "hello minimax"; +const HEX_AUDIO = "deadbeefcafe1234"; // 8 bytes; base64 = "3q2+78r+EjQ=" + +test("handleAudioSpeech routes MiniMax format to T2A v2 with hex output", async () => { + const originalFetch = globalThis.fetch; + let captured: any; + + globalThis.fetch = async (url: any, options: any = {}) => { + captured = { + url: String(url), + headers: options.headers, + body: JSON.parse(String(options.body || "{}")), + }; + return new Response( + JSON.stringify({ + data: { audio: HEX_AUDIO }, + base_resp: { status_code: 0, status_msg: "success" }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + try { + const response = await handleAudioSpeech({ + body: { model: "minimax/speech-2.8-hd", input: TEXT, voice: "English_expressive_narrator" }, + credentials: { apiKey: "mm-key" }, + }); + + assert.equal(response.status, 200, "should 200 on success"); + assert.equal(captured.url, "https://api.minimax.io/v1/t2a_v2"); + assert.equal((captured.headers as any).Authorization, "Bearer mm-key"); + assert.equal(captured.body.model, "speech-2.8-hd"); + assert.equal(captured.body.text, TEXT); + assert.equal(captured.body.stream, false); + assert.equal(captured.body.output_format, "hex"); + assert.equal(captured.body.voice_setting.voice_id, "English_expressive_narrator"); + assert.ok(captured.body.audio_setting, "audio_setting present"); + + const ct = response.headers.get("content-type") || ""; + assert.ok(ct.startsWith("audio/"), `content-type should be audio/*, got ${ct}`); + + const buf = new Uint8Array(await response.arrayBuffer()); + assert.deepEqual( + Array.from(buf), + [0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0x12, 0x34], + "hex audio should be decoded to bytes" + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleAudioSpeech surfaces MiniMax base_resp error", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ base_resp: { status_code: 2013, status_msg: "invalid voice" } }), + { status: 200, headers: { "content-type": "application/json" } } + ); + try { + const response = await handleAudioSpeech({ + body: { model: "minimax/speech-2.8-hd", input: TEXT }, + credentials: { apiKey: "mm-key" }, + }); + assert.notEqual(response.status, 200, "non-zero base_resp.status_code must not be 200"); + const payload = (await response.json()) as any; + assert.match(String(payload?.error?.message || ""), /invalid voice|MiniMax/i); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleAudioSpeech rejects invalid hex audio from MiniMax", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ + data: { audio: "zzznot-hex" }, + base_resp: { status_code: 0, status_msg: "" }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + try { + const response = await handleAudioSpeech({ + body: { model: "minimax/speech-2.8-hd", input: TEXT }, + credentials: { apiKey: "mm-key" }, + }); + assert.notEqual(response.status, 200); + const payload = (await response.json()) as any; + assert.match(String(payload?.error?.message || ""), /invalid audio|MiniMax/i); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/model-capabilities-registry.test.ts b/tests/unit/model-capabilities-registry.test.ts index b927ca2704..53f0364c09 100644 --- a/tests/unit/model-capabilities-registry.test.ts +++ b/tests/unit/model-capabilities-registry.test.ts @@ -137,6 +137,27 @@ test("canonical model capability resolver lets exact synced metadata override gl assert.equal(bareGpt55.contextWindow, 1050000); }); +test("unknown models keep maxOutputTokens null instead of using a generic default", () => { + const unknown = modelCapabilities.getResolvedModelCapabilities( + "openai-compatible-local/custom-large-output-model" + ); + + assert.equal(unknown.contextWindow, null); + assert.equal(unknown.maxInputTokens, null); + assert.equal(unknown.maxOutputTokens, null); + assert.equal( + modelCapabilities.capMaxOutputTokens( + "openai-compatible-local/custom-large-output-model", + 32000 + ), + 32000 + ); + assert.equal( + modelCapabilities.capMaxOutputTokens("openai-compatible-local/custom-large-output-model"), + null + ); +}); + test("GPT OSS and DeepSeek Reasoner models support tool calling", () => { // GPT OSS models should not be blocked by the heuristic assert.equal(modelCapabilities.supportsToolCalling("fake-provider/gpt-oss-120b"), true); diff --git a/tests/unit/no-thinking-alias.test.ts b/tests/unit/no-thinking-alias.test.ts index e385698bc5..d70d517f25 100644 --- a/tests/unit/no-thinking-alias.test.ts +++ b/tests/unit/no-thinking-alias.test.ts @@ -14,11 +14,11 @@ import { // ── prefix predicates ──────────────────────────────────────────────────────── test("NO_THINKING_PREFIX is the documented gateway prefix", () => { - assert.equal(NO_THINKING_PREFIX, "claude-3-omniroute-no-thinking/"); + assert.equal(NO_THINKING_PREFIX, "no-think/"); }); test("isNoThinkingAlias detects the prefix only", () => { - assert.equal(isNoThinkingAlias("claude-3-omniroute-no-thinking/anthropic/claude-opus-4-5"), true); + assert.equal(isNoThinkingAlias("no-think/anthropic/claude-opus-4-5"), true); assert.equal(isNoThinkingAlias("anthropic/claude-opus-4-5"), false); assert.equal(isNoThinkingAlias("claude-opus-4-5"), false); // non-strings never match @@ -28,7 +28,7 @@ test("isNoThinkingAlias detects the prefix only", () => { test("stripNoThinkingAlias unwraps the prefix and passes plain ids through", () => { assert.equal( - stripNoThinkingAlias("claude-3-omniroute-no-thinking/anthropic/claude-opus-4-5"), + stripNoThinkingAlias("no-think/anthropic/claude-opus-4-5"), "anthropic/claude-opus-4-5" ); assert.equal(stripNoThinkingAlias("claude-opus-4-5"), "claude-opus-4-5"); @@ -37,7 +37,7 @@ test("stripNoThinkingAlias unwraps the prefix and passes plain ids through", () test("toNoThinkingAlias round-trips with stripNoThinkingAlias", () => { const real = "anthropic/claude-sonnet-4-6"; const alias = toNoThinkingAlias(real); - assert.equal(alias, "claude-3-omniroute-no-thinking/anthropic/claude-sonnet-4-6"); + assert.equal(alias, "no-think/anthropic/claude-sonnet-4-6"); assert.equal(isNoThinkingAlias(alias), true); assert.equal(stripNoThinkingAlias(alias), real); }); @@ -46,7 +46,7 @@ test("toNoThinkingAlias round-trips with stripNoThinkingAlias", () => { test("applyNoThinkingAlias rewrites the model and disables thinking (Claude format)", () => { const body: Record = { - model: "claude-3-omniroute-no-thinking/anthropic/claude-opus-4-5", + model: "no-think/anthropic/claude-opus-4-5", thinking: { type: "enabled", budget_tokens: 8000 }, reasoning_effort: "high", messages: [], @@ -61,7 +61,7 @@ test("applyNoThinkingAlias rewrites the model and disables thinking (Claude form test("applyNoThinkingAlias strips reasoning fields without a thinking block (OpenAI format)", () => { const body: Record = { - model: "claude-3-omniroute-no-thinking/openai/gpt-5.4", + model: "no-think/openai/gpt-5.4", reasoning_effort: "high", reasoning: { effort: "high" }, messages: [], @@ -90,12 +90,12 @@ test("applyNoThinkingAlias is a no-op for plain models", () => { }); test("applyNoThinkingAlias ignores a malformed prefix-only model", () => { - const body: Record = { model: "claude-3-omniroute-no-thinking/" }; + const body: Record = { model: "no-think/" }; const res = applyNoThinkingAlias(body, { claudeFormat: true }); assert.equal(res.applied, false); assert.equal( body.model, - "claude-3-omniroute-no-thinking/", + "no-think/", "left untouched when nothing follows the prefix" ); }); @@ -118,7 +118,7 @@ test("shouldExposeNoThinkingAlias rejects models where suppression is meaningles assert.equal(shouldExposeNoThinkingAlias(entry("my-combo", "combo")), false); // never double-alias assert.equal( - shouldExposeNoThinkingAlias(entry("claude-3-omniroute-no-thinking/anthropic/claude-opus-4-5")), + shouldExposeNoThinkingAlias(entry("no-think/anthropic/claude-opus-4-5")), false ); }); @@ -128,15 +128,15 @@ test("appendNoThinkingVariants adds one variant per eligible model and preserves const out = appendNoThinkingVariants(models); const ids = out.map((m) => m.id); assert.ok( - ids.includes("claude-3-omniroute-no-thinking/claude-opus-4-5"), + ids.includes("no-think/claude-opus-4-5"), "eligible model gets a variant" ); assert.ok( - !ids.includes("claude-3-omniroute-no-thinking/gpt-4o"), + !ids.includes("no-think/gpt-4o"), "non-thinking model has no variant" ); assert.ok( - !ids.includes("claude-3-omniroute-no-thinking/claude-fable-5"), + !ids.includes("no-think/claude-fable-5"), "reject-disabled model has no variant" ); assert.equal(out.length, models.length + 1, "exactly one variant appended"); @@ -155,11 +155,11 @@ test("appendNoThinkingVariants normalizes alias prefix to canonical when aliasTo const out = appendNoThinkingVariants(models, aliasToCanonical); const ids = out.map((m) => m.id); assert.ok( - ids.includes("claude-3-omniroute-no-thinking/claude/claude-opus-4-5"), + ids.includes("no-think/claude/claude-opus-4-5"), "uses canonical prefix" ); assert.ok( - !ids.includes("claude-3-omniroute-no-thinking/cc/claude-opus-4-5"), + !ids.includes("no-think/cc/claude-opus-4-5"), "alias prefix not used" ); }); @@ -169,7 +169,7 @@ test("appendNoThinkingVariants keeps alias prefix when no map is provided", () = const out = appendNoThinkingVariants(models); const ids = out.map((m) => m.id); assert.ok( - ids.includes("claude-3-omniroute-no-thinking/cc/claude-opus-4-5"), + ids.includes("no-think/cc/claude-opus-4-5"), "alias prefix preserved" ); }); diff --git a/tests/unit/noauth-provider-validation.test.ts b/tests/unit/noauth-provider-validation.test.ts new file mode 100644 index 0000000000..fe28289d30 --- /dev/null +++ b/tests/unit/noauth-provider-validation.test.ts @@ -0,0 +1,31 @@ +/** + * Tests for noAuth provider validation: + * - Bug 1: `theoldllm` and `chipotle` missing from providerAllowsOptionalApiKey + * - Bug 2: `kimi` API key provider incorrectly routed through KimiWebExecutor + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { providerAllowsOptionalApiKey } from "../../src/shared/constants/providers.ts"; +import { hasSpecializedExecutor } from "../../open-sse/executors/index.ts"; + +// Bug 1: all noAuth providers should allow optional API key +for (const provider of ["theoldllm", "chipotle", "mimocode", "opencode", "duckduckgo-web", "veoaifree-web"]) { + test(`${provider} allows optional API key (noAuth provider)`, () => { + assert.equal(providerAllowsOptionalApiKey(provider), true); + }); +} + +// Bug 2: kimi API key provider should NOT have a specialized web executor +test("kimi API key provider falls through to DefaultExecutor", () => { + assert.equal(hasSpecializedExecutor("kimi"), false); +}); + +// no regression: kimi-web and kimi-coding still have their executors +test("kimi-web still has specialized executor", () => { + assert.equal(hasSpecializedExecutor("kimi-web"), true); +}); + +test("kimi-coding-apikey still has specialized executor", () => { + assert.equal(hasSpecializedExecutor("kimi-coding-apikey"), true); +}); diff --git a/tests/unit/oauth-providers-config.test.ts b/tests/unit/oauth-providers-config.test.ts index 20afd19c86..04f4ff2520 100644 --- a/tests/unit/oauth-providers-config.test.ts +++ b/tests/unit/oauth-providers-config.test.ts @@ -293,6 +293,18 @@ test("all provider endpoint URLs use HTTPS when a URL is configured", () => { } }); +test("Qwen OAuth uses qwen.ai (not chat.qwen.ai) for device/token URLs — upstream PR #683 / decolua issue #572", () => { + // The legacy host `chat.qwen.ai` started returning errors; the correct authoritative + // host for Qwen's device-code OAuth endpoints is `qwen.ai`. Regression guard for the + // port of decolua/9router#683 (closes decolua issue #572). + const deviceUrl = new URL(QWEN_CONFIG.deviceCodeUrl); + const tokenUrl = new URL(QWEN_CONFIG.tokenUrl); + assert.equal(deviceUrl.hostname, "qwen.ai", "deviceCodeUrl must use qwen.ai"); + assert.equal(tokenUrl.hostname, "qwen.ai", "tokenUrl must use qwen.ai"); + assert.equal(deviceUrl.pathname, "/api/v1/oauth2/device/code"); + assert.equal(tokenUrl.pathname, "/api/v1/oauth2/token"); +}); + test("browser-based providers expose buildAuthUrl and return provider-specific auth URLs", () => { const redirectUri = "http://localhost:43121/callback"; const state = "state-123"; diff --git a/tests/unit/ollama-cloud-usage.test.ts b/tests/unit/ollama-cloud-usage.test.ts new file mode 100644 index 0000000000..9c54789911 --- /dev/null +++ b/tests/unit/ollama-cloud-usage.test.ts @@ -0,0 +1,169 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const usage = await import("../../open-sse/services/usage.ts"); +const { USAGE_SUPPORTED_PROVIDERS } = await import("../../src/shared/constants/providers.ts"); + +test("USAGE_SUPPORTED_PROVIDERS includes ollama-cloud", () => { + assert.ok( + (USAGE_SUPPORTED_PROVIDERS as string[]).includes("ollama-cloud"), + "ollama-cloud must be in the usage-supported providers allowlist" + ); +}); + +test("getUsageForProvider returns helpful message when Ollama Cloud has no usage cookie", async () => { + const originalCookie = process.env.OLLAMA_USAGE_COOKIE; + const originalOmniCookie = process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE; + delete process.env.OLLAMA_USAGE_COOKIE; + delete process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE; + + let called = false; + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + called = true; + return new Response("unexpected", { status: 500 }); + }; + + try { + const result = (await usage.getUsageForProvider({ + id: "ollama-cloud-no-cookie", + provider: "ollama-cloud", + apiKey: "ollama-chat-key", + })) as { message?: string }; + + assert.equal(called, false, "settings scrape must not run without a cookie"); + assert.match(result.message ?? "", /Ollama Cloud/); + assert.match(result.message ?? "", /OLLAMA_USAGE_COOKIE/); + } finally { + globalThis.fetch = originalFetch; + if (originalCookie === undefined) delete process.env.OLLAMA_USAGE_COOKIE; + else process.env.OLLAMA_USAGE_COOKIE = originalCookie; + if (originalOmniCookie === undefined) delete process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE; + else process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE = originalOmniCookie; + } +}); + +test("getUsageForProvider scrapes Ollama Cloud settings quota", async () => { + const originalFetch = globalThis.fetch; + const originalCookie = process.env.OLLAMA_USAGE_COOKIE; + const originalOmniCookie = process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE; + delete process.env.OLLAMA_USAGE_COOKIE; + process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE = "__Secure-session=test-cookie"; + + let requestUrl = ""; + let requestHeaders: Headers | null = null; + let redirectMode: RequestRedirect | undefined; + + globalThis.fetch = async (input, init) => { + requestUrl = String(input); + requestHeaders = new Headers(init?.headers as HeadersInit | undefined); + redirectMode = init?.redirect; + return new Response( + [ + 'pro', + '
', + '', + '
', + '', + ].join(""), + { status: 200, headers: { "content-type": "text/html" } } + ); + }; + + try { + const result = (await usage.getUsageForProvider({ + id: "ollama-cloud-settings", + provider: "ollama-cloud", + apiKey: "ollama-chat-key", + })) as { + plan?: string | null; + quotas?: Record; + }; + + assert.equal(requestUrl, "https://ollama.com/settings"); + assert.equal(requestHeaders?.get("Cookie"), "__Secure-session=test-cookie"); + assert.equal(redirectMode, "manual"); + assert.equal(result.plan, "Ollama Cloud pro"); + assert.deepEqual(Object.keys(result.quotas ?? {}), ["session", "weekly"]); + assert.equal(result.quotas!.session.used, 34); + assert.equal(result.quotas!.session.remainingPercentage, 66); + assert.equal(result.quotas!.weekly.used, 67); + assert.equal(result.quotas!.weekly.remainingPercentage, 33); + } finally { + globalThis.fetch = originalFetch; + if (originalCookie === undefined) delete process.env.OLLAMA_USAGE_COOKIE; + else process.env.OLLAMA_USAGE_COOKIE = originalCookie; + if (originalOmniCookie === undefined) delete process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE; + else process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE = originalOmniCookie; + } +}); + +test("getUsageForProvider keeps Ollama Cloud reset times aligned to usage tracks", async () => { + const originalFetch = globalThis.fetch; + const originalCookie = process.env.OLLAMA_USAGE_COOKIE; + const originalOmniCookie = process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE; + delete process.env.OLLAMA_USAGE_COOKIE; + process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE = "test-cookie"; + + globalThis.fetch = async () => + new Response( + [ + '', + '
', + '', + "
", + '
', + '', + '', + "
", + ].join(""), + { status: 200, headers: { "content-type": "text/html" } } + ); + + try { + const result = (await usage.getUsageForProvider({ + id: "ollama-cloud-aligned-times", + provider: "ollama-cloud", + apiKey: "ollama-chat-key", + })) as { + quotas?: Record; + }; + + assert.equal(result.quotas!.session.used, 34); + assert.equal(result.quotas!.session.resetAt, "2026-06-22T15:00:00.000Z"); + assert.equal(result.quotas!.weekly.used, 67); + assert.equal(result.quotas!.weekly.resetAt, "2026-06-29T15:00:00.000Z"); + } finally { + globalThis.fetch = originalFetch; + if (originalCookie === undefined) delete process.env.OLLAMA_USAGE_COOKIE; + else process.env.OLLAMA_USAGE_COOKIE = originalCookie; + if (originalOmniCookie === undefined) delete process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE; + else process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE = originalOmniCookie; + } +}); + +test("getUsageForProvider reports expired Ollama Cloud cookies on redirect", async () => { + const originalFetch = globalThis.fetch; + const originalCookie = process.env.OLLAMA_USAGE_COOKIE; + process.env.OLLAMA_USAGE_COOKIE = "expired-cookie"; + + globalThis.fetch = async () => + new Response("", { + status: 302, + headers: { location: "/signin" }, + }); + + try { + const result = (await usage.getUsageForProvider({ + id: "ollama-cloud-redirect", + provider: "ollama-cloud", + apiKey: "ollama-chat-key", + })) as { message?: string }; + + assert.match(result.message ?? "", /authentication expired/i); + } finally { + globalThis.fetch = originalFetch; + if (originalCookie === undefined) delete process.env.OLLAMA_USAGE_COOKIE; + else process.env.OLLAMA_USAGE_COOKIE = originalCookie; + } +}); diff --git a/tests/unit/openapi-coverage.test.ts b/tests/unit/openapi-coverage.test.ts index d7cd781b44..be5c8425f4 100644 --- a/tests/unit/openapi-coverage.test.ts +++ b/tests/unit/openapi-coverage.test.ts @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; -import yaml from "js-yaml"; +import * as yaml from "js-yaml"; const ROOT = process.cwd(); const API_ROOT = path.join(ROOT, "src", "app", "api"); diff --git a/tests/unit/openapi-security-tiers.test.ts b/tests/unit/openapi-security-tiers.test.ts index 810955d82c..beab4d4815 100644 --- a/tests/unit/openapi-security-tiers.test.ts +++ b/tests/unit/openapi-security-tiers.test.ts @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; -import yaml from "js-yaml"; +import * as yaml from "js-yaml"; const ROOT = process.cwd(); const OPENAPI_PATH = path.join(ROOT, "docs", "reference", "openapi.yaml"); diff --git a/tests/unit/openapi-ws-endpoint.test.ts b/tests/unit/openapi-ws-endpoint.test.ts index a6d4efc055..46a917bade 100644 --- a/tests/unit/openapi-ws-endpoint.test.ts +++ b/tests/unit/openapi-ws-endpoint.test.ts @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import yaml from "js-yaml"; +import * as yaml from "js-yaml"; // Regression guard: the OpenAI-compatible chat WebSocket endpoint (/api/v1/ws) // must be documented in openapi.yaml so it shows up on the dashboard's diff --git a/tests/unit/opencode-executor.test.ts b/tests/unit/opencode-executor.test.ts index f57c9ef4b8..1cee807258 100644 --- a/tests/unit/opencode-executor.test.ts +++ b/tests/unit/opencode-executor.test.ts @@ -77,6 +77,23 @@ describe("OpencodeExecutor", () => { assert.equal(model.name, "DeepSeek V4 Flash Free"); assert.equal(model.supportsReasoning, true); }); + + it("exposes DeepSeek V4 Pro effort variants on opencode-go only", () => { + const goModels = PROVIDER_MODELS["opencode-go"] || []; + const zenModels = PROVIDER_MODELS["opencode-zen"] || []; + const variants = ["low", "medium", "high", "max"].map((level) => `deepseek-v4-pro-${level}`); + for (const variant of variants) { + const model = goModels.find((m) => m.id === variant); + assert.ok(model, `${variant} should be in opencode-go model list`); + assert.equal(model?.supportsReasoning, true); + assert.equal( + zenModels.some((m) => m.id === variant), + false, + `${variant} should not be exposed on opencode-zen` + ); + } + }); + it("routes opencode zen default models to chat completions", async () => { const minimaxResult = await zenExecutor.execute(createInput("minimax-m2.5-free")); assert.equal(minimaxResult.url, "https://opencode.ai/zen/v1/chat/completions"); @@ -526,6 +543,62 @@ describe("OpencodeExecutor", () => { ); }); }); + + describe("DeepSeek V4 Pro reasoning-effort variants", () => { + function baseBody(model) { + return { + model, + stream: false, + messages: [{ role: "user", content: "ok" }], + max_tokens: 16, + }; + } + + const levels = ["low", "medium", "high", "max"]; + for (const level of levels) { + it(`maps deepseek-v4-pro-${level} to base id + reasoning_effort=${level}`, () => { + const variant = `deepseek-v4-pro-${level}`; + const out = goExecutor.transformRequest(variant, baseBody(variant), false, { + apiKey: "test-key", + }); + assert.equal(out.model, "deepseek-v4-pro"); + assert.equal(out.reasoning_effort, level); + assert.ok(!String(out.model).endsWith(`-${level}`)); + }); + } + + it("preserves explicit reasoning_effort over the variant suffix", () => { + const body = baseBody("deepseek-v4-pro-high") as Record; + body.reasoning_effort = "max"; + const out = goExecutor.transformRequest("deepseek-v4-pro-high", body, false, { + apiKey: "test-key", + }); + assert.equal(out.reasoning_effort, "max"); + assert.equal(out.model, "deepseek-v4-pro"); + }); + + it("leaves the base id (no suffix) untouched", () => { + const out = goExecutor.transformRequest( + "deepseek-v4-pro", + baseBody("deepseek-v4-pro"), + false, + { apiKey: "test-key" } + ); + assert.equal(out.model, "deepseek-v4-pro"); + assert.equal(out.reasoning_effort, undefined); + }); + + it("does not rewrite unrelated models with matching suffixes", () => { + const out = goExecutor.transformRequest( + "some-other-model-high", + baseBody("some-other-model-high"), + false, + { apiKey: "test-key" } + ); + assert.equal(out.model, "some-other-model-high"); + assert.equal(out.reasoning_effort, undefined); + }); + }); }); describe("DefaultExecutor", () => { diff --git a/tests/unit/opencode-go-usage.test.ts b/tests/unit/opencode-go-usage.test.ts index efabd38099..64e6357800 100644 --- a/tests/unit/opencode-go-usage.test.ts +++ b/tests/unit/opencode-go-usage.test.ts @@ -28,6 +28,7 @@ test("getUsageForProvider returns helpful message when opencode-go has no apiKey assert.equal(called, false, "quota fetch must not run without an API key"); assert.match(result.message ?? "", /OpenCode Go/); + assert.match(result.message ?? "", /OPENCODE_GO_WORKSPACE_ID/); } finally { globalThis.fetch = originalFetch; } @@ -104,7 +105,7 @@ test("getUsageForProvider exposes OpenCode Go 5h, weekly, and monthly quotas", a }; assert.equal(requestUrl, "https://api.z.ai/api/monitor/usage/quota/limit"); - assert.equal(requestHeaders?.get("Authorization"), "opencode-go-key"); + assert.equal(requestHeaders?.get("Authorization"), "Bearer opencode-go-key"); assert.equal(requestHeaders?.get("Content-Type"), "application/json"); assert.equal(result.plan, "OpenCode Go Pro"); assert.deepEqual(Object.keys(result.quotas ?? {}), ["session", "weekly", "mcp_monthly"]); @@ -136,6 +137,155 @@ test("getUsageForProvider exposes OpenCode Go 5h, weekly, and monthly quotas", a } }); +test("getUsageForProvider ignores out-of-range OpenCode Go reset timestamps", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ + code: 200, + success: true, + data: { + level: "pro", + limits: [ + { + type: "TOKENS_LIMIT", + unit: 3, + number: 5, + percentage: 25, + nextResetTime: Number.MAX_VALUE, + }, + ], + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + + try { + const result = (await usage.getUsageForProvider({ + id: "opencode-go-huge-reset", + provider: "opencode-go", + apiKey: "opencode-go-key", + })) as { quotas?: Record }; + + assert.equal(result.quotas!.session.resetAt, null); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("getUsageForProvider scrapes OpenCode Go dashboard quota when workspace cookie is configured", async () => { + const originalFetch = globalThis.fetch; + const originalWorkspace = process.env.OPENCODE_GO_WORKSPACE_ID; + const originalCookie = process.env.OPENCODE_GO_AUTH_COOKIE; + let requestUrl = ""; + let requestHeaders: Headers | null = null; + + process.env.OPENCODE_GO_WORKSPACE_ID = "workspace-123"; + process.env.OPENCODE_GO_AUTH_COOKIE = "auth-cookie-value"; + + globalThis.fetch = async (input, init) => { + requestUrl = String(input); + requestHeaders = new Headers(init?.headers as HeadersInit | undefined); + return new Response( + [ + '
', + 'Rolling Usage', + '25%', + 'Resets in 1 hour 30 minutes', + "
", + '
', + 'Weekly Usage', + '50%', + 'Resets in 2 days', + "
", + '
', + 'Monthly Usage', + '10%', + 'Resets in 10 days', + "
", + ].join(""), + { status: 200, headers: { "content-type": "text/html" } } + ); + }; + + try { + const result = (await usage.getUsageForProvider({ + id: "opencode-go-dashboard", + provider: "opencode-go", + apiKey: "opencode-go-key", + })) as { + plan?: string | null; + quotas?: Record; + }; + + assert.equal(requestUrl, "https://opencode.ai/workspace/workspace-123/go"); + assert.equal(requestHeaders?.get("Cookie"), "auth=auth-cookie-value"); + assert.equal(result.plan, "OpenCode Go"); + assert.deepEqual(Object.keys(result.quotas ?? {}), ["session", "weekly", "mcp_monthly"]); + assert.equal(result.quotas!.session.used, 3); + assert.equal(result.quotas!.session.total, 12); + assert.equal(result.quotas!.session.remainingPercentage, 75); + assert.equal(result.quotas!.weekly.used, 15); + assert.equal(result.quotas!.weekly.remainingPercentage, 50); + assert.equal(result.quotas!.mcp_monthly.used, 6); + assert.equal(result.quotas!.mcp_monthly.remainingPercentage, 90); + } finally { + globalThis.fetch = originalFetch; + if (originalWorkspace === undefined) delete process.env.OPENCODE_GO_WORKSPACE_ID; + else process.env.OPENCODE_GO_WORKSPACE_ID = originalWorkspace; + if (originalCookie === undefined) delete process.env.OPENCODE_GO_AUTH_COOKIE; + else process.env.OPENCODE_GO_AUTH_COOKIE = originalCookie; + } +}); + +// Regression: React SSR wraps the reset-time text in hydration comment markers +// (). The reset string must be fully sanitized (complete +// removal, not just the two literal markers) so the reset time still parses — and so no +// partial "Resets in 1 hour 30 minutes', + "", + ].join(""), + { status: 200, headers: { "content-type": "text/html" } } + ); + + try { + const result = (await usage.getUsageForProvider({ + id: "opencode-go-dashboard", + provider: "opencode-go", + apiKey: "opencode-go-key", + })) as { + quotas?: Record; + }; + + // session quota resolved → the comment-wrapped reset time was sanitized and parsed + assert.ok( + result.quotas?.session, + "session quota should resolve from comment-wrapped reset-time" + ); + assert.equal(result.quotas!.session.remainingPercentage, 75); + } finally { + globalThis.fetch = originalFetch; + if (originalWorkspace === undefined) delete process.env.OPENCODE_GO_WORKSPACE_ID; + else process.env.OPENCODE_GO_WORKSPACE_ID = originalWorkspace; + if (originalCookie === undefined) delete process.env.OPENCODE_GO_AUTH_COOKIE; + else process.env.OPENCODE_GO_AUTH_COOKIE = originalCookie; + } +}); + test("getUsageForProvider returns message for invalid OpenCode Go API keys", async () => { const originalFetch = globalThis.fetch; globalThis.fetch = async () => new Response("nope", { status: 401 }); @@ -148,15 +298,34 @@ test("getUsageForProvider returns message for invalid OpenCode Go API keys", asy })) as { message: string }; assert.equal( result.message, - "OpenCode Go does not expose a public quota API. Chat requests still work. " + - "Set OMNIROUTE_OPENCODE_GO_QUOTA_URL to a working endpoint, or follow " + - "https://github.com/anomalyco/opencode/issues/16017 for upstream status." + "OpenCode Go API key is valid for chat/models but cannot read quota from the Z.AI quota API. " + + "Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping." ); } finally { globalThis.fetch = originalFetch; } }); +test("getUsageForProvider returns message when OpenCode Go quota fetch fails", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + throw new Error("network offline"); + }; + + try { + const result = (await usage.getUsageForProvider({ + id: "opencode-go-network-error", + provider: "opencode-go", + apiKey: "opencode-go-key", + })) as { message: string }; + + assert.match(result.message, /OpenCode Go quota API error:/); + assert.match(result.message, /network offline/); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("getUsageForProvider returns message when OpenCode Go quota API returns 200 with auth error in body", async () => { const originalFetch = globalThis.fetch; globalThis.fetch = async () => @@ -173,9 +342,8 @@ test("getUsageForProvider returns message when OpenCode Go quota API returns 200 })) as { message: string }; assert.equal( result.message, - "OpenCode Go does not expose a public quota API. Chat requests still work. " + - "Set OMNIROUTE_OPENCODE_GO_QUOTA_URL to a working endpoint, or follow " + - "https://github.com/anomalyco/opencode/issues/16017 for upstream status." + "OpenCode Go API key is valid for chat/models but cannot read quota from the Z.AI quota API. " + + "Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping." ); } finally { globalThis.fetch = originalFetch; diff --git a/tests/unit/provider-limits-ui.test.ts b/tests/unit/provider-limits-ui.test.ts index fb9741fe72..240dc6918c 100644 --- a/tests/unit/provider-limits-ui.test.ts +++ b/tests/unit/provider-limits-ui.test.ts @@ -205,6 +205,41 @@ test("GLM quota rows are ordered by session, weekly, then monthly", () => { ); }); +test("hidden provider models are filtered from per-model quota rows", () => { + const quotas = providerLimitUtils.parseQuotaData("antigravity", { + quotas: { + "gpt-oss-120b-medium": { used: 2, total: 100, remainingPercentage: 98 }, + "gemini-3.5-pro": { used: 10, total: 100, remainingPercentage: 90 }, + credits: { remaining: 42 }, + }, + }); + const hidden = providerLimitUtils.collectHiddenQuotaModelIds("antigravity", { + models: [{ id: "antigravity/gpt-oss-120b-medium", isHidden: true }], + modelCompatOverrides: [{ id: "gemini-3.5-flash", isDeleted: true }], + }); + const visible = providerLimitUtils.filterHiddenModelQuotas("antigravity", quotas, hidden); + + assert.deepEqual( + visible.map((quota) => quota.modelKey || quota.name), + ["gemini-3.5-pro", "credits"] + ); +}); + +test("hidden quota filtering keeps non-model provider quota rows", () => { + const quotas = [ + { name: "weekly", used: 2, total: 100 }, + { name: "credits", isCredits: true, remaining: 10 }, + ]; + const hidden = providerLimitUtils.collectHiddenQuotaModelIds("antigravity", { + modelCompatOverrides: [{ id: "weekly", isHidden: true }], + }); + + assert.deepEqual( + providerLimitUtils.filterHiddenModelQuotas("antigravity", quotas, hidden), + quotas + ); +}); + test("dashboard i18n keys used by OrFallback helpers exist in en.json", () => { const enPath = path.resolve("src/i18n/messages/en.json"); const messages = JSON.parse(readFileSync(enPath, "utf8")); diff --git a/tests/unit/provider-models-config.test.ts b/tests/unit/provider-models-config.test.ts index d95b50c1ac..be6acd59ab 100644 --- a/tests/unit/provider-models-config.test.ts +++ b/tests/unit/provider-models-config.test.ts @@ -78,8 +78,9 @@ test("Reka registry exposes preset models", () => { assert.equal(PROVIDER_ID_TO_ALIAS.reka, "reka"); assert.equal(getDefaultModel("reka"), "reka-flash-3"); - assert.deepEqual(ids, ["reka-flash-3", "reka-edge-2603"]); + assert.deepEqual(ids, ["reka-flash-3", "reka-flash", "reka-edge-2603"]); assert.equal(isValidModel("reka", "reka-edge-2603"), true); + assert.equal(isValidModel("reka", "reka-flash"), true); }); test("GitHub Copilot registry reflects the current supported model lineup", () => { diff --git a/tests/unit/provider-models-route.test.ts b/tests/unit/provider-models-route.test.ts index aaea614f61..a860d21284 100644 --- a/tests/unit/provider-models-route.test.ts +++ b/tests/unit/provider-models-route.test.ts @@ -1509,7 +1509,7 @@ test("provider models route always returns the Reka preset catalog", async () => assert.equal(body.source, "local_catalog"); assert.deepEqual( body.models.map((model) => model.id), - ["reka-flash-3", "reka-edge-2603"] + ["reka-flash-3", "reka-flash", "reka-edge-2603"] ); }); @@ -1532,7 +1532,7 @@ test("provider models route returns Reka local catalog without an API key", asyn assert.equal(body.source, "local_catalog"); assert.deepEqual( body.models.map((model) => model.id), - ["reka-flash-3", "reka-edge-2603"] + ["reka-flash-3", "reka-flash", "reka-edge-2603"] ); }); diff --git a/tests/unit/provider-nodes-validate-modelid.test.ts b/tests/unit/provider-nodes-validate-modelid.test.ts new file mode 100644 index 0000000000..3329d43fd3 --- /dev/null +++ b/tests/unit/provider-nodes-validate-modelid.test.ts @@ -0,0 +1,159 @@ +// Tests for optional modelId fallback on /api/provider-nodes/validate. +// Ports decolua/9router#315 (Doan Minh Tu). +import test from "node:test"; +import assert from "node:assert/strict"; +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-validate-modelid-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providerNodesValidateRoute = await import( + "../../src/app/api/provider-nodes/validate/route.ts" +); + +const originalFetch = globalThis.fetch; + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.afterEach(async () => { + globalThis.fetch = originalFetch; + await resetStorage(); +}); + +test.after(() => { + globalThis.fetch = originalFetch; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +type FetchCall = { url: string; init: any }; + +function installFetchSequence(responses: Array<() => Response>) { + const calls: FetchCall[] = []; + let i = 0; + globalThis.fetch = async (url: any, init: any = {}) => { + calls.push({ url: String(url), init }); + const factory = responses[i++] ?? responses[responses.length - 1]; + return factory(); + }; + return calls; +} + +function validate(body: Record) { + return providerNodesValidateRoute.POST( + new Request("http://localhost/api/provider-nodes/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + ); +} + +test("openai-compatible: modelId fallback to /chat/completions succeeds when /models 404", async () => { + const calls = installFetchSequence([ + () => new Response("not found", { status: 404 }), + () => new Response(JSON.stringify({ id: "ok" }), { status: 200 }), + ]); + + const res = await validate({ + baseUrl: "https://proxy.example.com/v1", + apiKey: "sk-test", + type: "openai-compatible", + modelId: "my-model", + }); + + assert.equal(res.status, 200); + const data = (await res.json()) as any; + assert.equal(data.valid, true); + assert.equal(data.method, "chat"); + assert.equal(calls.length, 2); + assert.equal(calls[0].url, "https://proxy.example.com/v1/models"); + assert.equal(calls[1].url, "https://proxy.example.com/v1/chat/completions"); + assert.equal(calls[1].init.method, "POST"); + const sent = JSON.parse(calls[1].init.body as string); + assert.equal(sent.model, "my-model"); + assert.equal(sent.max_tokens, 1); + assert.deepEqual(sent.messages, [{ role: "user", content: "ping" }]); +}); + +test("openai-compatible: 401 from /models skips chat fallback even when modelId set", async () => { + const calls = installFetchSequence([() => new Response("nope", { status: 401 })]); + + const res = await validate({ + baseUrl: "https://proxy.example.com/v1", + apiKey: "sk-test", + type: "openai-compatible", + modelId: "my-model", + }); + + assert.equal(res.status, 200); + const data = (await res.json()) as any; + assert.equal(data.valid, false); + assert.equal(calls.length, 1); + assert.match(String(data.error), /unauthorized/i); +}); + +test("openai-compatible: chat fallback failure returns descriptive error", async () => { + installFetchSequence([ + () => new Response("not found", { status: 404 }), + () => new Response("bad model", { status: 400 }), + ]); + + const res = await validate({ + baseUrl: "https://proxy.example.com/v1", + apiKey: "sk-test", + type: "openai-compatible", + modelId: "bad-model", + }); + + assert.equal(res.status, 200); + const data = (await res.json()) as any; + assert.equal(data.valid, false); + assert.equal(data.method, "chat"); + assert.match(String(data.error), /invalid model|bad request/i); +}); + +test("openai-compatible: without modelId, 404 from /models returns helpful hint", async () => { + installFetchSequence([() => new Response("nope", { status: 404 })]); + + const res = await validate({ + baseUrl: "https://proxy.example.com/v1", + apiKey: "sk-test", + type: "openai-compatible", + }); + + assert.equal(res.status, 200); + const data = (await res.json()) as any; + assert.equal(data.valid, false); + assert.match(String(data.error), /models.*endpoint.*not found|model id/i); +}); + +test("anthropic-compatible: modelId fallback to /chat/completions on 404", async () => { + const calls = installFetchSequence([ + () => new Response("not found", { status: 404 }), + () => new Response(JSON.stringify({ id: "ok" }), { status: 200 }), + ]); + + const res = await validate({ + baseUrl: "https://proxy.example.com/v1", + apiKey: "sk-anthropic", + type: "anthropic-compatible", + modelId: "claude-3-opus", + }); + + assert.equal(res.status, 200); + const data = (await res.json()) as any; + assert.equal(data.valid, true); + assert.equal(data.method, "chat"); + assert.equal(calls.length, 2); + assert.equal(calls[1].url, "https://proxy.example.com/v1/chat/completions"); + assert.equal(calls[1].init.headers["x-api-key"], "sk-anthropic"); + assert.equal(calls[1].init.headers["anthropic-version"], "2023-06-01"); +}); diff --git a/tests/unit/provider-specific-data-schema.test.ts b/tests/unit/provider-specific-data-schema.test.ts index 6df8c155d8..f71f581d45 100644 --- a/tests/unit/provider-specific-data-schema.test.ts +++ b/tests/unit/provider-specific-data-schema.test.ts @@ -175,3 +175,43 @@ test("provider schemas reject oversized OpenRouter preset values", () => { assert.equal(created.success, false); assert.equal(updated.success, false); }); + +test("provider schemas accept quota scraping provider-specific strings", () => { + const created = createProviderSchema.safeParse({ + provider: "opencode-go", + apiKey: "token", + name: "OpenCode Go", + providerSpecificData: { + opencodeGoWorkspaceId: "workspace-123", + opencodeGoAuthCookie: "auth=cookie-value", + }, + }); + const updated = updateProviderConnectionSchema.safeParse({ + providerSpecificData: { + ollamaCloudUsageCookie: "__Secure-session=cookie-value", + }, + }); + + assert.equal(created.success, true); + assert.equal(updated.success, true); +}); + +test("provider schemas reject malformed quota scraping provider-specific values", () => { + const created = createProviderSchema.safeParse({ + provider: "opencode-go", + apiKey: "token", + name: "OpenCode Go", + providerSpecificData: { + opencodeGoWorkspaceId: 123, + opencodeGoAuthCookie: "x".repeat(10001), + }, + }); + const updated = updateProviderConnectionSchema.safeParse({ + providerSpecificData: { + ollamaCloudUsageCookie: 123, + }, + }); + + assert.equal(created.success, false); + assert.equal(updated.success, false); +}); diff --git a/tests/unit/providers-page-utils.test.ts b/tests/unit/providers-page-utils.test.ts index 7c088ccccc..9cb86ac610 100644 --- a/tests/unit/providers-page-utils.test.ts +++ b/tests/unit/providers-page-utils.test.ts @@ -1001,3 +1001,51 @@ test("model search filter is case-insensitive and partial-match", () => { ); assert.equal(byPartial.length, 1, "partial model id 'minimax' should match 'minimax-m3'"); }); + +// #4613: buildCompatibleProviderGroups partitions provider nodes into the +// openai-compatible / anthropic-compatible / claude-code-compatible buckets the +// providers page renders. The memoization in page.tsx wraps this pure helper, so +// guarding the partition logic here is the regression that matters (Rule #18). +test("buildCompatibleProviderGroups partitions nodes by type + claude-code prefix", () => { + const labels = { + openaiCompatibleName: "OpenAI-compatible", + anthropicCompatibleName: "Anthropic-compatible", + claudeCodeCompatibleName: "Claude Code-compatible", + }; + + const groups = providerPageUtils.buildCompatibleProviderGroups( + [ + { id: "my-oai", name: "My OAI", type: "openai-compatible", apiType: "responses" }, + { id: "my-anthropic", name: "My Claude", type: "anthropic-compatible" }, + { id: "anthropic-compatible-cc-acme", name: "Acme CC", type: "anthropic-compatible" }, + { id: "ignored-node", name: "Ignored", type: "gemini-cli" }, + // name omitted → falls back to the provided label + { id: "anon-oai", type: "openai-compatible" }, + ], + labels + ); + + assert.deepEqual( + groups.openai.map((p) => p.id), + ["my-oai", "anon-oai"], + "openai-compatible nodes land in the openai bucket" + ); + assert.equal(groups.openai[0].apiType, "responses", "apiType is preserved"); + assert.equal( + groups.openai[1].name, + labels.openaiCompatibleName, + "missing name falls back to the openai-compatible label" + ); + + assert.deepEqual( + groups.anthropic.map((p) => p.id), + ["my-anthropic"], + "plain anthropic-compatible nodes land in the anthropic bucket" + ); + + assert.deepEqual( + groups.claudeCode.map((p) => p.id), + ["anthropic-compatible-cc-acme"], + "anthropic-compatible nodes with the cc- prefix land in the claudeCode bucket" + ); +}); diff --git a/tests/unit/request-defaults-store-session.test.ts b/tests/unit/request-defaults-store-session.test.ts index 19f9b1925d..23090810be 100644 --- a/tests/unit/request-defaults-store-session.test.ts +++ b/tests/unit/request-defaults-store-session.test.ts @@ -6,6 +6,7 @@ const { ensureOpenAIStoreSessionFallback, getClaudeCodeCompatibleRequestDefaults, normalizeProviderSpecificData, + sanitizeProviderSpecificDataForResponse, } = await import("../../src/lib/providers/requestDefaults.ts"); test("buildOpenAIStoreSessionId normalizes external and generated session ids", () => { @@ -108,3 +109,19 @@ test("normalizeProviderSpecificData trims OpenRouter preset and clears empty val assert.equal(ignored?.preset, undefined); assert.equal(ignored?.tag, "primary"); }); + +test("sanitizeProviderSpecificDataForResponse removes quota scraping cookies", () => { + const sanitized = sanitizeProviderSpecificDataForResponse({ + opencodeGoWorkspaceId: "workspace-123", + opencodeGoAuthCookie: "auth-cookie", + ollamaCloudUsageCookie: "ollama-cookie", + usageCookie: "fallback-cookie", + consoleApiKey: "console-key", + tag: "primary", + }); + + assert.deepEqual(sanitized, { + opencodeGoWorkspaceId: "workspace-123", + tag: "primary", + }); +}); diff --git a/tests/unit/resilience-provider-cooldown-api-3556.test.ts b/tests/unit/resilience-provider-cooldown-api-3556.test.ts index 7de0718772..88b08a07c3 100644 --- a/tests/unit/resilience-provider-cooldown-api-3556.test.ts +++ b/tests/unit/resilience-provider-cooldown-api-3556.test.ts @@ -41,6 +41,16 @@ describe("providerCooldown in updateResilienceSchema", () => { }); assert.equal(result.success, false, "Schema should reject unknown keys"); }); + + it("rejects providerCooldown max below min", () => { + const result = updateResilienceSchema.safeParse({ + providerCooldown: { + minRetryCooldownMs: 120000, + maxRetryCooldownMs: 30000, + }, + }); + assert.equal(result.success, false, "Schema should reject contradictory cooldown bounds"); + }); }); describe("providerCooldown roundtrip through mergeResilienceSettings", () => { diff --git a/tests/unit/resilience-stream-recovery-feature-flags.test.ts b/tests/unit/resilience-stream-recovery-feature-flags.test.ts new file mode 100644 index 0000000000..431c3951f3 --- /dev/null +++ b/tests/unit/resilience-stream-recovery-feature-flags.test.ts @@ -0,0 +1,42 @@ +import { test, 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"; + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-stream-recovery-flags-")); +process.env.DATA_DIR = tmpDir; + +const core = await import("../../src/lib/db/core.ts"); +const { setFeatureFlagOverride, clearAllFeatureFlagOverrides } = + await import("../../src/lib/db/featureFlags.ts"); +const { resolveResilienceSettings } = await import("../../src/lib/resilience/settings.ts"); + +after(() => { + core.resetDbInstance(); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +test("stream recovery feature flags seed resilience defaults", () => { + clearAllFeatureFlagOverrides(); + setFeatureFlagOverride("STREAM_RECOVERY_ENABLED", "true"); + setFeatureFlagOverride("STREAM_RECOVERY_MIDSTREAM_ENABLED", "true"); + + const resolved = resolveResilienceSettings({}); + + assert.equal(resolved.streamRecovery.enabled, true); + assert.equal(resolved.streamRecovery.continueMidStream, true); +}); + +test("stored stream recovery settings override feature flag defaults", () => { + clearAllFeatureFlagOverrides(); + setFeatureFlagOverride("STREAM_RECOVERY_ENABLED", "true"); + setFeatureFlagOverride("STREAM_RECOVERY_MIDSTREAM_ENABLED", "true"); + + const resolved = resolveResilienceSettings({ + resilienceSettings: { streamRecovery: { enabled: false, continueMidStream: false } }, + }); + + assert.equal(resolved.streamRecovery.enabled, false); + assert.equal(resolved.streamRecovery.continueMidStream, false); +}); diff --git a/tests/unit/services/combo-metrics-memory.test.ts b/tests/unit/services/combo-metrics-memory.test.ts index bc6b2390f9..c877065ee0 100644 --- a/tests/unit/services/combo-metrics-memory.test.ts +++ b/tests/unit/services/combo-metrics-memory.test.ts @@ -224,6 +224,30 @@ test("eviction: shadow metrics respect their own MAX_METRICS_ENTRIES cap", () => ); }); +test("eviction: shadow overflow does not delete production metrics with the same name", () => { + resetAllComboMetrics(); + const MAX = 500; + + recordComboRequest("shared-combo", "gpt-4", { success: true, latencyMs: 10 }); + + for (let i = 0; i < MAX; i++) { + recordComboShadowRequest(i === 0 ? "shared-combo" : "shadow-fill-" + i, "gpt-4", { + success: true, + latencyMs: 10, + }); + } + + recordComboShadowRequest("shadow-after-capacity", "gpt-4", { + success: true, + latencyMs: 50, + }); + + const metrics = getComboMetrics("shared-combo"); + assert.ok(metrics, "production metrics must survive shadow-only eviction"); + assert.equal(metrics.productionTraffic, true); + assert.equal(metrics.totalRequests, 1); +}); + test("eviction: recordComboIntent respects MAX_METRICS_ENTRIES cap", () => { resetAllComboMetrics(); const MAX = 500; diff --git a/tests/unit/services/providerCooldownTracker.test.ts b/tests/unit/services/providerCooldownTracker.test.ts index 335575ee86..8d3f88f35f 100644 --- a/tests/unit/services/providerCooldownTracker.test.ts +++ b/tests/unit/services/providerCooldownTracker.test.ts @@ -7,6 +7,7 @@ import { recordProviderSuccess, clearCooldownState, getCooldownEntryCount, + cleanupExpiredCooldownEntries, } from "../../../open-sse/services/providerCooldownTracker.ts"; import { resolveResilienceSettings, @@ -140,6 +141,33 @@ test("cooldown respects maxRetryCooldownMs cap", () => { assert.ok(remaining <= 20000, "cooldown capped at maxRetryCooldownMs"); }); +test("cleanup keeps entries for configured long maxRetryCooldownMs", () => { + const settings = makeSettings(5000, 60 * 60 * 1000); + const originalNow = Date.now; + + try { + let fakeNow = Date.now(); + Date.now = () => fakeNow; + + recordProviderCooldown("openai", "conn-1", settings); + assert.equal(getCooldownEntryCount(), 1); + + fakeNow += 31 * 60 * 1000; + cleanupExpiredCooldownEntries(); + assert.equal( + getCooldownEntryCount(), + 1, + "cleanup must not evict entries before configured maxRetryCooldownMs" + ); + + fakeNow += 30 * 60 * 1000; + cleanupExpiredCooldownEntries(); + assert.equal(getCooldownEntryCount(), 0); + } finally { + Date.now = originalNow; + } +}); + test("different connections have independent cooldowns", () => { const settings = makeSettings(); recordProviderCooldown("openai", "conn-1", settings); diff --git a/tests/unit/settings-api.test.ts b/tests/unit/settings-api.test.ts index 4c2c8ecc8f..8ff0dd80a1 100644 --- a/tests/unit/settings-api.test.ts +++ b/tests/unit/settings-api.test.ts @@ -209,6 +209,35 @@ describe("Settings API - persisted preferences", () => { assert.equal(settings.responsesPreviousResponseIdMode, "strip"); }); + test("GET /api/settings returns Cache-Control: no-store (ported from upstream #951)", async () => { + const response = await harness.settingsRoute.GET( + await makeManagementSessionRequest("http://localhost/api/settings", { + method: "GET", + }) + ); + assert.equal(response.status, 200); + assert.equal( + response.headers.get("Cache-Control"), + "no-store", + "GET /api/settings must return Cache-Control: no-store so persisted settings stay fresh after refresh/restart" + ); + }); + + test("PATCH /api/settings returns Cache-Control: no-store (ported from upstream #951)", async () => { + const response = await harness.settingsRoute.PATCH( + await makeManagementSessionRequest("http://localhost/api/settings", { + method: "PATCH", + body: { debugMode: true }, + }) + ); + assert.equal(response.status, 200); + assert.equal( + response.headers.get("Cache-Control"), + "no-store", + "PATCH /api/settings must return Cache-Control: no-store" + ); + }); + test("PUT /api/settings reuses the PATCH update flow", async () => { const response = await harness.settingsRoute.PUT( await makeManagementSessionRequest("http://localhost/api/settings", { diff --git a/tests/unit/stream-prompt-tokens-zero-upstream.test.ts b/tests/unit/stream-prompt-tokens-zero-upstream.test.ts new file mode 100644 index 0000000000..dc579dc2b0 --- /dev/null +++ b/tests/unit/stream-prompt-tokens-zero-upstream.test.ts @@ -0,0 +1,114 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +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-stream-zero-prompt-")); +process.env.DATA_DIR = TEST_DATA_DIR; +const core = await import("../../src/lib/db/core.ts"); + +const { createSSEStream } = await import("../../open-sse/utils/stream.ts"); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); + +const textEncoder = new TextEncoder(); + +async function readTransformed(chunks, options) { + const source = new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(textEncoder.encode(chunk)); + } + controller.close(); + }, + }); + + return new Response(source.pipeThrough(createSSEStream(options))).text(); +} + +test("createSSEStream passthrough estimates input tokens when upstream reports prompt_tokens=0 (Ollama Cloud pattern)", async () => { + let onCompletePayload = null; + const body = { messages: [{ role: "user", content: "Write a 500-line research report" }] }; + const text = await readTransformed( + [ + `data: ${JSON.stringify({ + id: "chatcmpl_ollama", + object: "chat.completion.chunk", + created: 1, + model: "minimax-m3", + system_fingerprint: "fp_ollama", + choices: [{ index: 0, delta: { content: "Here" } }], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_ollama", + object: "chat.completion.chunk", + created: 1, + model: "minimax-m3", + system_fingerprint: "fp_ollama", + choices: [{ index: 0, delta: { content: " is" } }], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_ollama", + object: "chat.completion.chunk", + created: 1, + model: "minimax-m3", + system_fingerprint: "fp_ollama", + choices: [{ index: 0, delta: { content: " the" } }], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_ollama", + object: "chat.completion.chunk", + created: 1, + model: "minimax-m3", + system_fingerprint: "fp_ollama", + choices: [{ index: 0, delta: { content: " response" } }], + })}\n\n`, + // Ollama Cloud returns usage with prompt_tokens: 0 — it doesn't count input tokens + `data: ${JSON.stringify({ + id: "chatcmpl_ollama", + object: "chat.completion.chunk", + created: 1, + model: "minimax-m3", + system_fingerprint: "fp_ollama", + choices: [], + usage: { prompt_tokens: 0, completion_tokens: 10, total_tokens: 0 }, + })}\n\n`, + `data: [DONE]\n\n`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.OPENAI, + provider: "ollamacloud", + model: "minimax-m3", + body, + onComplete(payload) { + onCompletePayload = payload; + }, + } + ); + + // The fix should estimate input tokens from the request body instead of showing 0 + assert.equal( + onCompletePayload.responseBody.usage.completion_tokens, + 10, + "completion tokens should be unchanged" + ); + assert.ok( + onCompletePayload.responseBody.usage.prompt_tokens > 0, + `prompt_tokens should be estimated (> 0), got ${onCompletePayload.responseBody.usage.prompt_tokens}` + ); + assert.equal( + onCompletePayload.responseBody.usage.total_tokens, + onCompletePayload.responseBody.usage.prompt_tokens + 10, + "total_tokens should equal prompt_tokens + completion_tokens" + ); +}); + +test.after(() => { + core.resetDbInstance(); + if (fs.existsSync(TEST_DATA_DIR)) { + for (const entry of fs.readdirSync(TEST_DATA_DIR)) { + fs.rmSync(path.join(TEST_DATA_DIR, entry), { recursive: true, force: true }); + } + } +}); diff --git a/tests/unit/translator-claude-to-gemini.test.ts b/tests/unit/translator-claude-to-gemini.test.ts index 70d63f595a..39c48d42b4 100644 --- a/tests/unit/translator-claude-to-gemini.test.ts +++ b/tests/unit/translator-claude-to-gemini.test.ts @@ -121,6 +121,19 @@ test("Claude -> Gemini clamps maxOutputTokens to the model cap", () => { assert.equal(result.generationConfig.maxOutputTokens, 65536); }); +test("Claude -> Gemini preserves requested maxOutputTokens when the model cap is unknown", () => { + const result = claudeToGeminiRequest( + "gemini-2.5-pro", + { + messages: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], + max_tokens: 32000, + }, + false + ); + + assert.equal(result.generationConfig.maxOutputTokens, 32000); +}); + test("Claude -> Gemini converts text and base64 images to Gemini parts", () => { const result = claudeToGeminiRequest( "gemini-2.5-flash", diff --git a/tests/unit/translator-openai-to-claude.test.ts b/tests/unit/translator-openai-to-claude.test.ts index 6b18b85681..89e49121aa 100644 --- a/tests/unit/translator-openai-to-claude.test.ts +++ b/tests/unit/translator-openai-to-claude.test.ts @@ -256,7 +256,9 @@ test("OpenAI -> Claude maps tool_choice and injects response_format instructions }); test("OpenAI -> Claude turns reasoning settings into thinking budgets and expands max tokens", () => { - // `claude-4-sonnet` is a fixture that doesn't match any spec → default cap = 8192. + // `claude-4-sonnet` is a fixture that doesn't match any spec. Unknown caps + // should not get an implicit default; the translator only preserves the + // response room + thinking budget relationship. // fitThinkingToMaxTokens floors response room at MIN_RESPONSE_ROOM (1024) // and targets max_tokens = responseRoom + budget capped at modelCap. const effortResult = openaiToClaudeRequest( @@ -270,7 +272,7 @@ test("OpenAI -> Claude turns reasoning settings into thinking budgets and expand ); assert.deepEqual(effortResult.thinking, { type: "enabled", budget_tokens: 1024 }); - // responseRoom=max(10,1024)=1024; target=min(1024+1024, 8192)=2048 + // responseRoom=max(10,1024)=1024; target=1024+1024=2048 assert.equal(effortResult.max_tokens, 2048); const explicitThinkingResult = openaiToClaudeRequest( @@ -288,10 +290,25 @@ test("OpenAI -> Claude turns reasoning settings into thinking budgets and expand budget_tokens: 2000, max_tokens: 3000, }); - // responseRoom=max(1000,1024)=1024; target=min(1024+2000, 8192)=3024 + // responseRoom=max(1000,1024)=1024; target=1024+2000=3024 assert.equal(explicitThinkingResult.max_tokens, 3024); }); +test("OpenAI -> Claude does not cap unknown models to a fallback maxOutputTokens", () => { + const result = openaiToClaudeRequest( + "claude-4-sonnet", + { + messages: [{ role: "user", content: "Reason about something hard" }], + max_tokens: 32000, + reasoning_effort: "high", + }, + false + ); + + assert.equal(result.max_tokens, 163072); + assert.deepEqual(result.thinking, { type: "enabled", budget_tokens: 131072 }); +}); + test("OpenAI -> Claude preserves xhigh only for Claude models that expose it", () => { const { xhighModel, standardModel } = getClaudeEffortFixtures(); const preserved = openaiToClaudeRequest( diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts index b775371229..8f75dba4c7 100644 --- a/tests/unit/translator-openai-to-gemini.test.ts +++ b/tests/unit/translator-openai-to-gemini.test.ts @@ -18,6 +18,8 @@ const { clearGeminiThoughtSignatures } = await import("../../open-sse/services/geminiThoughtSignatureStore.ts"); type UnknownRecord = Record; +type GeminiRequestWithConfig = { generationConfig: UnknownRecord }; +type GeminiRequestWithSystem = { systemInstruction: { role?: unknown; parts?: unknown } }; test.beforeEach(() => { clearGeminiThoughtSignatures(); @@ -76,6 +78,28 @@ test("OpenAI -> Gemini helper converts text, images and files into Gemini parts" assert.deepEqual(convertOpenAIContentToParts("raw text"), [{ text: "raw text" }]); }); +test("OpenAI -> Gemini does not inject default maxOutputTokens for unknown caps", () => { + const withoutRequestLimit = openaiToGeminiRequest( + "gemini-2.5-pro", + { messages: [{ role: "user", content: "Hello" }] }, + false + ); + assert.equal( + (withoutRequestLimit as GeminiRequestWithConfig).generationConfig.maxOutputTokens, + undefined + ); + + const withRequestLimit = openaiToGeminiRequest( + "gemini-2.5-pro", + { messages: [{ role: "user", content: "Hello" }], max_tokens: 32000 }, + false + ); + assert.equal( + (withRequestLimit as GeminiRequestWithConfig).generationConfig.maxOutputTokens, + 32000 + ); +}); + test("OpenAI -> Gemini helper cleans complex JSON Schema structures for Gemini compatibility", () => { const cleaned = cleanJSONSchemaForAntigravity({ type: "object", @@ -237,11 +261,9 @@ test("OpenAI -> Gemini request maps messages, merged system instructions, tools false ); - assert.equal((result as any).systemInstruction.role, "system"); - assert.deepEqual((result as any).systemInstruction.parts, [ - { text: "Rule A" }, - { text: "Rule B" }, - ]); + const systemInstruction = (result as GeminiRequestWithSystem).systemInstruction; + assert.equal(systemInstruction.role, "system"); + assert.deepEqual(systemInstruction.parts, [{ text: "Rule A" }, { text: "Rule B" }]); assert.equal(result.contents[0].role, "user"); assert.deepEqual(result.contents[0].parts, [ { text: "What is the weather?" }, @@ -270,12 +292,13 @@ test("OpenAI -> Gemini request maps messages, merged system instructions, tools response: { result: { temp: 20 } }, }); - assert.equal((result as any).generationConfig.maxOutputTokens, 2222); - assert.equal((result as any).generationConfig.temperature, 0.3); - assert.equal((result as any).generationConfig.topP, 0.9); - assert.deepEqual((result as any).generationConfig.stopSequences, ["DONE"]); - assert.equal((result as any).generationConfig.responseMimeType, "application/json"); - const responseSchema = (result as any).generationConfig.responseSchema as { + const generationConfig = (result as GeminiRequestWithConfig).generationConfig; + assert.equal(generationConfig.maxOutputTokens, 2222); + assert.equal(generationConfig.temperature, 0.3); + assert.equal(generationConfig.topP, 0.9); + assert.deepEqual(generationConfig.stopSequences, ["DONE"]); + assert.equal(generationConfig.responseMimeType, "application/json"); + const responseSchema = generationConfig.responseSchema as { properties: { answer: { type: string; enum?: string[] } }; }; assert.equal(responseSchema.properties.answer.type, "string"); @@ -690,17 +713,19 @@ test("OpenAI -> Antigravity Gemini omits signature-less historical tool calls an "signature-less historical call MUST be emitted as native functionCall (bypass applied)" ); assert.equal( - modelTurn?.parts.some((part) => part.thoughtSignature === "skip_thought_signature_validator") ?? false, + modelTurn?.parts.some((part) => part.thoughtSignature === "skip_thought_signature_validator") ?? + false, true, "the bypass sentinel must be injected as thoughtSignature" ); const toolTurn = result.request.contents.find( - (content) => - content.role === "user" && - content.parts.some((part) => part.functionResponse) + (content) => content.role === "user" && content.parts.some((part) => part.functionResponse) + ); + assert.ok( + toolTurn, + "expected signature-less tool response to be preserved as native functionResponse (bypass applied)" ); - assert.ok(toolTurn, "expected signature-less tool response to be preserved as native functionResponse (bypass applied)"); assert.equal( toolTurn.parts.some((part) => part.functionResponse), true, diff --git a/tests/unit/ui/OpenClawToolCard-manual-config-fallback.test.tsx b/tests/unit/ui/OpenClawToolCard-manual-config-fallback.test.tsx new file mode 100644 index 0000000000..4c5ea89ef3 --- /dev/null +++ b/tests/unit/ui/OpenClawToolCard-manual-config-fallback.test.tsx @@ -0,0 +1,160 @@ +// @vitest-environment jsdom +// +// Regression test: when the Open Claw CLI is not detected locally (typical of +// remote OmniRoute deployments where the CLI lives on the user's laptop, not +// on the server), the card must still surface a "Manual Config" button so the +// user can copy the settings.json snippet and paste it into the CLI on their +// local machine. Before this fix the Manual Config button only rendered when +// `cliReady === true`, which made the card useless for remote deployments +// (upstream report: decolua/9router#579, port of decolua/9router#615). +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string, values?: Record) => { + const messages: Record = { + cliNotInstalled: "{tool} CLI not detected locally", + cliNotRunnable: "{tool} CLI installed but not runnable", + installCliPrompt: + "Manual configuration is still available if OmniRoute is deployed on a remote server.", + cliFoundFailedHealthcheck: "{tool} CLI was found but failed runtime healthcheck{reason}.", + manualConfig: "Manual Config", + checkingCli: "Checking {tool}...", + openClawManualConfiguration: "Open Claw Manual Configuration", + "toolDescriptions.openclaw": "Open Claw CLI", + }; + const raw = messages[key] ?? key; + if (!values) return raw; + return Object.entries(values).reduce( + (acc, [k, v]) => acc.replaceAll(`{${k}}`, String(v ?? "")), + raw + ); + }, + useLocale: () => "en", +})); + +vi.mock("next/image", () => ({ + default: () => , +})); + +vi.mock("@/app/(dashboard)/dashboard/cli-code/components/CliStatusBadge", () => ({ + default: () => , +})); + +// Surface ManualConfigModal as a marker so we can assert it gets rendered with +// isOpen=true after clicking the new Manual Config button. +vi.mock("@/shared/components", async () => { + const React = await import("react"); + return { + Card: ({ children }: { children: React.ReactNode }) =>
{children}
, + Button: ({ + children, + onClick, + }: { + children: React.ReactNode; + onClick?: () => void; + }) => ( + + ), + ModelSelectModal: () => null, + ManualConfigModal: ({ isOpen, title }: { isOpen: boolean; title?: string }) => + isOpen ?
{title}
: null, + }; +}); + +// ── Fetch stub ──────────────────────────────────────────────────────────────── + +beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + // Return: CLI not installed (the broken-on-remote case from upstream #579). + globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/cli-tools/openclaw-settings")) { + return new Response(JSON.stringify({ installed: false }), { status: 200 }); + } + if (url.includes("/api/models/alias")) { + return new Response(JSON.stringify({ aliases: {} }), { status: 200 }); + } + if (url.includes("/api/cli-tools/backups")) { + return new Response(JSON.stringify({ backups: [] }), { status: 200 }); + } + return new Response("{}", { status: 200 }); + }) as unknown as typeof fetch; +}); + +const containers: HTMLElement[] = []; + +afterEach(() => { + while (containers.length > 0) { + containers.pop()?.remove(); + } + document.body.innerHTML = ""; + vi.restoreAllMocks(); +}); + +// ── Import under test (after mocks) ─────────────────────────────────────────── + +const { default: OpenClawToolCard } = await import( + "@/app/(dashboard)/dashboard/cli-code/components/OpenClawToolCard" +); + +async function renderExpanded() { + const container = document.createElement("div"); + document.body.appendChild(container); + containers.push(container); + const root = createRoot(container); + + await act(async () => { + root.render( + {}} + activeProviders={[]} + baseUrl="http://localhost:20128" + hasActiveProviders={false} + apiKeys={[]} + cloudEnabled={false} + batchStatus={null} + lastConfiguredAt={null} + /> + ); + }); + // Allow microtasks for the fetch() promise + state update to flush. + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + return container; +} + +describe("OpenClawToolCard — manual-config CTA when CLI is not detected", () => { + it("renders the Manual Config button when CLI is not installed", async () => { + const container = await renderExpanded(); + const buttons = Array.from(container.querySelectorAll("button")); + const labels = buttons.map((b) => b.textContent ?? ""); + expect(labels.some((l) => l.includes("Manual Config"))).toBe(true); + }); + + it("opens the ManualConfigModal when the Manual Config button is clicked", async () => { + const container = await renderExpanded(); + const manualBtn = Array.from(container.querySelectorAll("button")).find((b) => + (b.textContent ?? "").includes("Manual Config") + ); + expect(manualBtn).toBeTruthy(); + + await act(async () => { + manualBtn!.click(); + }); + + expect(container.querySelector("[data-testid='manual-config-modal']")).not.toBeNull(); + }); +}); diff --git a/tests/unit/ui/add-api-key-modal-free-models.test.tsx b/tests/unit/ui/add-api-key-modal-free-models.test.tsx index e6082e7812..c366485a9e 100644 --- a/tests/unit/ui/add-api-key-modal-free-models.test.tsx +++ b/tests/unit/ui/add-api-key-modal-free-models.test.tsx @@ -8,9 +8,8 @@ vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key, })); -const { default: AddApiKeyModal } = await import( - "../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal" -); +const { default: AddApiKeyModal } = + await import("../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal"); const FREE_TOGGLE = 'button[role="switch"][aria-label="importFreeModelsOnlyLabel"]'; @@ -35,10 +34,7 @@ function render(props: Record) { } function setInputValue(input: HTMLInputElement, value: string) { - const setter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - "value" - )!.set!; + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; act(() => { setter.call(input, value); input.dispatchEvent(new Event("input", { bubbles: true })); @@ -108,3 +104,58 @@ describe("AddApiKeyModal — import only free models", () => { expect(payload.providerSpecificData?.importFreeModelsOnly).toBe(true); }); }); + +describe("AddApiKeyModal — quota scraping fields", () => { + it("saves OpenCode Go workspace and auth cookie in providerSpecificData", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const el = render({ provider: "opencode-go", providerName: "OpenCode Go", onSave }); + + const nameInput = el.querySelector('input[placeholder="productionKey"]')!; + const apiKeyInput = el.querySelector('input[type="password"]')!; + const workspaceInput = el.querySelector( + 'input[name="opencodeGoWorkspaceId"]' + )!; + const cookieInput = el.querySelector('input[name="opencodeGoAuthCookie"]')!; + setInputValue(nameInput, "OpenCode Go"); + setInputValue(apiKeyInput, "sk-opencode-go-test"); + setInputValue(workspaceInput, "workspace-123"); + setInputValue(cookieInput, "auth=opencode-cookie"); + + const saveBtn = Array.from(el.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "save" + )!; + act(() => { + saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitFor(() => onSave.mock.calls.length > 0); + const payload = onSave.mock.calls[0][0]; + expect(payload.providerSpecificData?.opencodeGoWorkspaceId).toBe("workspace-123"); + expect(payload.providerSpecificData?.opencodeGoAuthCookie).toBe("auth=opencode-cookie"); + }); + + it("saves Ollama Cloud usage cookie in providerSpecificData", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const el = render({ provider: "ollama-cloud", providerName: "Ollama Cloud", onSave }); + + const nameInput = el.querySelector('input[placeholder="productionKey"]')!; + const apiKeyInput = el.querySelector('input[type="password"]')!; + const cookieInput = el.querySelector('input[name="ollamaCloudUsageCookie"]')!; + setInputValue(nameInput, "Ollama Cloud"); + setInputValue(apiKeyInput, "ollama-key"); + setInputValue(cookieInput, "__Secure-session=ollama-cookie"); + + const saveBtn = Array.from(el.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "save" + )!; + act(() => { + saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitFor(() => onSave.mock.calls.length > 0); + const payload = onSave.mock.calls[0][0]; + expect(payload.providerSpecificData?.ollamaCloudUsageCookie).toBe( + "__Secure-session=ollama-cookie" + ); + }); +}); diff --git a/tests/unit/ui/edit-connection-modal-free-models.test.tsx b/tests/unit/ui/edit-connection-modal-free-models.test.tsx index 617a57d66e..757b0cff84 100644 --- a/tests/unit/ui/edit-connection-modal-free-models.test.tsx +++ b/tests/unit/ui/edit-connection-modal-free-models.test.tsx @@ -11,9 +11,8 @@ vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key, })); -const { default: EditConnectionModal } = await import( - "../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal" -); +const { default: EditConnectionModal } = + await import("../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal"); const FREE_TOGGLE = 'button[role="switch"][aria-label="importFreeModelsOnlyLabel"]'; @@ -38,6 +37,14 @@ function render(props: Record) { return el; } +function setInputValue(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; + act(() => { + setter.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + async function waitFor(fn: () => boolean, timeoutMs = 2000) { const start = Date.now(); while (!fn()) { @@ -51,7 +58,9 @@ beforeEach(() => { vi.clearAllMocks(); vi.stubGlobal( "fetch", - vi.fn(() => Promise.resolve({ ok: true, json: async () => ({}), text: async () => "" } as Response)) + vi.fn(() => + Promise.resolve({ ok: true, json: async () => ({}), text: async () => "" } as Response) + ) ); vi.stubGlobal("localStorage", { getItem: () => null, @@ -159,3 +168,68 @@ describe("EditConnectionModal — import only free models", () => { expect(onResyncModels).toHaveBeenCalledWith("conn-4"); }); }); + +describe("EditConnectionModal — quota scraping fields", () => { + it("saves OpenCode Go workspace and replacement auth cookie", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const el = render({ + providerId: "opencode-go", + connection: { + id: "conn-opencode-go", + provider: "opencode-go", + name: "OpenCode Go", + authType: "apikey", + providerSpecificData: { workspaceId: "workspace-existing" }, + }, + onSave, + }); + + const workspaceInput = el.querySelector( + 'input[name="opencodeGoWorkspaceId"]' + )!; + const cookieInput = el.querySelector('input[name="opencodeGoAuthCookie"]')!; + expect(workspaceInput.value).toBe("workspace-existing"); + setInputValue(workspaceInput, "workspace-updated"); + setInputValue(cookieInput, "auth=opencode-cookie"); + + const saveBtn = Array.from(el.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "save" + )!; + act(() => { + saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitFor(() => onSave.mock.calls.length > 0); + const payload = onSave.mock.calls[0][0]; + expect(payload.providerSpecificData?.opencodeGoWorkspaceId).toBe("workspace-updated"); + expect(payload.providerSpecificData?.opencodeGoAuthCookie).toBe("auth=opencode-cookie"); + }); + + it("omits Ollama Cloud usage cookie when the edit field is left blank", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const el = render({ + providerId: "ollama-cloud", + connection: { + id: "conn-ollama-cloud", + provider: "ollama-cloud", + name: "Ollama Cloud", + authType: "apikey", + providerSpecificData: {}, + }, + onSave, + }); + + expect(el.querySelector('input[name="ollamaCloudUsageCookie"]')).toBeTruthy(); + + const saveBtn = Array.from(el.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "save" + )!; + act(() => { + saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitFor(() => onSave.mock.calls.length > 0); + const payload = onSave.mock.calls[0][0]; + expect("ollamaCloudUsageCookie" in payload.providerSpecificData).toBe(false); + }); +}); diff --git a/tests/unit/ui/home-provider-topology-section-4606.test.tsx b/tests/unit/ui/home-provider-topology-section-4606.test.tsx new file mode 100644 index 0000000000..2cd2d3ccf2 --- /dev/null +++ b/tests/unit/ui/home-provider-topology-section-4606.test.tsx @@ -0,0 +1,66 @@ +// @vitest-environment jsdom +// +// #4606: the provider-topology card was extracted into HomeProviderTopologySection +// and its activity fetch gated behind widget visibility in HomePageClient +// (`appearanceSettingsLoaded && showProviderTopologyOnHome`). This guards the +// extracted section: it renders the topology card and feeds live active-requests +// through selectActiveRequests into ProviderTopology (Rule #18 for the change). +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); +vi.mock("next/dynamic", () => ({ + default: () => (props: Record) => ( +
+ ), +})); +vi.mock("@/shared/components", () => ({ + Card: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); +const liveRequestsMock = vi.fn(() => ({ activeRequests: [] as unknown[] })); +vi.mock("@/hooks/useLiveDashboard", () => ({ + useLiveRequests: () => liveRequestsMock(), +})); + +const { HomeProviderTopologySection } = await import( + "../../../src/app/(dashboard)/dashboard/HomeProviderTopologySection" +); + +let container: HTMLDivElement; +let root: ReturnType; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); +}); + +it("renders the topology card and forwards providers to ProviderTopology", () => { + act(() => { + root.render( + + ); + }); + + expect(container.querySelector("[data-testid='card']")).not.toBeNull(); + const topology = container.querySelector("[data-testid='provider-topology']"); + expect(topology).not.toBeNull(); + expect(topology?.getAttribute("data-providers")).toBe("2"); +}); diff --git a/tests/unit/ui/home-topology-hidden-4596.test.tsx b/tests/unit/ui/home-topology-hidden-4596.test.tsx new file mode 100644 index 0000000000..cdb3a64662 --- /dev/null +++ b/tests/unit/ui/home-topology-hidden-4596.test.tsx @@ -0,0 +1,68 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useLiveRequests } from "../../../src/hooks/useLiveDashboard"; + +function LiveRequestsHarness({ enabled }: { enabled: boolean }) { + useLiveRequests({ enabled }); + return null; +} + +describe("home topology hidden networking (#4596)", () => { + const websocketMock = vi.fn(); + let root: ReturnType | null = null; + let container: HTMLDivElement | null = null; + + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + websocketMock.mockClear(); + vi.stubGlobal( + "WebSocket", + class WebSocketMock { + static OPEN = 1; + readyState = 0; + onopen: (() => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + + constructor(url: string) { + websocketMock(url); + } + + send() {} + close() {} + } + ); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root?.unmount(); + }); + container?.remove(); + root = null; + container = null; + vi.unstubAllGlobals(); + }); + + it("does NOT open a WebSocket when the topology section is disabled", () => { + act(() => { + root!.render(); + }); + expect(websocketMock).not.toHaveBeenCalled(); + }); + + it("opens a WebSocket when the topology section is enabled", () => { + act(() => { + root!.render(); + }); + expect(websocketMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/ui/provider-quota-widget-auto-refresh-label-4611.test.tsx b/tests/unit/ui/provider-quota-widget-auto-refresh-label-4611.test.tsx new file mode 100644 index 0000000000..c5835b711e --- /dev/null +++ b/tests/unit/ui/provider-quota-widget-auto-refresh-label-4611.test.tsx @@ -0,0 +1,72 @@ +// @vitest-environment jsdom +// +// #4611: the auto-refresh countdown was extracted into its own +// `AutoRefreshButtonLabel` child so the per-second `setNow` tick re-renders only +// the label instead of the whole `ProviderQuotaWidget`. This guards the extracted +// child's three observable label states (Rule #18 for the maintainer-reviewed change). +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { AutoRefreshButtonLabel } from "../../../src/app/(dashboard)/home/ProviderQuotaWidget"; + +const tr = (_key: string, fallback: string) => fallback; + +let container: HTMLDivElement; +let root: ReturnType; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +describe("AutoRefreshButtonLabel (#4611)", () => { + it("shows 'Refreshing' while a refresh-all is in flight", () => { + act(() => { + root.render( + + ); + }); + expect(container.textContent).toBe("Refreshing"); + }); + + it("shows the static 'Refresh All' label when auto-refresh is disabled", () => { + act(() => { + root.render( + + ); + }); + expect(container.textContent).toBe("Refresh All"); + }); + + it("shows the auto-refreshing countdown when an interval is configured", () => { + act(() => { + root.render( + + ); + }); + expect(container.textContent).toContain("Auto-refreshing"); + }); +}); diff --git a/tests/unit/upstream-retry-hints-toggle.test.ts b/tests/unit/upstream-retry-hints-toggle.test.ts new file mode 100644 index 0000000000..3ecd417d46 --- /dev/null +++ b/tests/unit/upstream-retry-hints-toggle.test.ts @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { checkFallbackError, getProviderProfile } from "../../open-sse/services/accountFallback.ts"; +import { RateLimitReason } from "../../open-sse/config/constants.ts"; + +const ANTIGRAVITY_RESET_TEXT = + "Individual quota reached. Contact your administrator to enable overages. Resets in 164h27m24s."; +const RESET_164H = (164 * 3600 + 27 * 60 + 24) * 1000; + +function antigravityProfile(useUpstreamRetryHints: boolean) { + return { + ...getProviderProfile("antigravity"), + baseCooldownMs: 125, + transientCooldown: 125, + rateLimitCooldown: useUpstreamRetryHints ? 0 : 125, + useUpstreamRetryHints, + }; +} + +test("checkFallbackError ignores body reset text when upstream retry hints are disabled", () => { + const result = checkFallbackError( + 429, + ANTIGRAVITY_RESET_TEXT, + 0, + "gemini-3-flash-agent", + "antigravity", + null, + antigravityProfile(false) + ); + + assert.equal(result.shouldFallback, true); + assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED); + assert.equal(result.usedUpstreamRetryHint, false); + assert.equal(result.cooldownMs, 125); +}); + +test("checkFallbackError honors body reset text when upstream retry hints are enabled", () => { + const result = checkFallbackError( + 429, + ANTIGRAVITY_RESET_TEXT, + 0, + "gemini-3-flash-agent", + "antigravity", + null, + antigravityProfile(true) + ); + + assert.equal(result.shouldFallback, true); + assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED); + assert.equal(result.usedUpstreamRetryHint, true); + assert.equal(result.cooldownMs, RESET_164H); +}); diff --git a/tests/unit/usage-pending-sweep.test.ts b/tests/unit/usage-pending-sweep.test.ts index af7ed8b085..d3de475b16 100644 --- a/tests/unit/usage-pending-sweep.test.ts +++ b/tests/unit/usage-pending-sweep.test.ts @@ -1,52 +1,114 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -const { - trackPendingRequest, - getPendingById, - getPendingRequests, - sweepStalePendingRequests, - clearPendingRequests, -} = await import("../../src/lib/usage/usageHistory.ts"); - -test("sweepStalePendingRequests evicts orphaned pending details and self-heals counts", () => { - clearPendingRequests(); - - // One request that will be treated as orphaned (never finalized), one fresh. - const staleId = trackPendingRequest("gpt-x", "openai", "conn-stale", true); - const freshId = trackPendingRequest("gpt-x", "openai", "conn-fresh", true); - - assert.ok(staleId && freshId, "both started requests should produce ids"); - assert.equal(getPendingById().size, 2); - assert.equal(getPendingRequests().byModel["gpt-x (openai)"], 2); - - // Age the stale entry well beyond the max age. - const stale = getPendingById().get(staleId); - assert.ok(stale, "stale detail should exist"); - stale.startedAt = Date.now() - 60 * 60 * 1000; // 1 hour ago - - const removed = sweepStalePendingRequests(Date.now(), 15 * 60 * 1000); - - assert.equal(removed, 1, "exactly one orphaned entry should be swept"); - assert.equal(getPendingById().size, 1, "only the fresh entry should remain"); - assert.ok(getPendingById().has(freshId), "fresh entry must survive"); - - // Counts must reflect the eviction (decremented, not left dangling). - assert.equal(getPendingRequests().byModel["gpt-x (openai)"], 1); - assert.equal(getPendingRequests().byAccount["conn-stale"], undefined); - assert.equal(getPendingRequests().byAccount["conn-fresh"]["gpt-x (openai)"], 1); - - clearPendingRequests(); -}); - -test("sweepStalePendingRequests is a no-op when nothing is stale", () => { - clearPendingRequests(); - trackPendingRequest("m", "p", "c1", true); - trackPendingRequest("m", "p", "c2", true); - - const removed = sweepStalePendingRequests(Date.now(), 15 * 60 * 1000); - - assert.equal(removed, 0); - assert.equal(getPendingById().size, 2); - clearPendingRequests(); -}); +import test from "node:test"; +import assert from "node:assert/strict"; + +const { + trackPendingRequest, + getPendingById, + getPendingRequests, + sweepStalePendingRequests, + getMaxPendingRequestAgeMs, + clearPendingRequests, +} = await import("../../src/lib/usage/usageHistory.ts"); + +const MINUTE_MS = 60 * 1000; +const HOUR_MS = 60 * MINUTE_MS; + +test("sweepStalePendingRequests evicts orphaned pending details and self-heals counts", () => { + clearPendingRequests(); + + // One request that will be treated as orphaned (never finalized), one fresh. + const staleId = trackPendingRequest("gpt-x", "openai", "conn-stale", true); + const freshId = trackPendingRequest("gpt-x", "openai", "conn-fresh", true); + + assert.ok(staleId && freshId, "both started requests should produce ids"); + assert.equal(getPendingById().size, 2); + assert.equal(getPendingRequests().byModel["gpt-x (openai)"], 2); + + // Age the stale entry well beyond the max age. + const stale = getPendingById().get(staleId); + assert.ok(stale, "stale detail should exist"); + stale.startedAt = Date.now() - 2 * HOUR_MS; + + const removed = sweepStalePendingRequests(Date.now(), HOUR_MS); + + assert.equal(removed, 1, "exactly one orphaned entry should be swept"); + assert.equal(getPendingById().size, 1, "only the fresh entry should remain"); + assert.ok(getPendingById().has(freshId), "fresh entry must survive"); + + // Counts must reflect the eviction (decremented, not left dangling). + assert.equal(getPendingRequests().byModel["gpt-x (openai)"], 1); + assert.equal(getPendingRequests().byAccount["conn-stale"], undefined); + assert.equal(getPendingRequests().byAccount["conn-fresh"]["gpt-x (openai)"], 1); + + clearPendingRequests(); +}); + +test("sweepStalePendingRequests is a no-op when nothing is stale", () => { + clearPendingRequests(); + trackPendingRequest("m", "p", "c1", true); + trackPendingRequest("m", "p", "c2", true); + + const removed = sweepStalePendingRequests(Date.now(), HOUR_MS); + + assert.equal(removed, 0); + assert.equal(getPendingById().size, 2); + clearPendingRequests(); +}); + +test("sweepStalePendingRequests defaults to a one hour max pending age", () => { + clearPendingRequests(); + + const staleId = trackPendingRequest("m", "p", "old", true); + const recentId = trackPendingRequest("m", "p", "recent", true); + assert.ok(staleId && recentId); + + const now = Date.now(); + const stale = getPendingById().get(staleId); + const recent = getPendingById().get(recentId); + assert.ok(stale && recent); + + stale.startedAt = now - 61 * MINUTE_MS; + recent.startedAt = now - 59 * MINUTE_MS; + + const removed = sweepStalePendingRequests(now); + + assert.equal(removed, 1); + assert.equal(getPendingById().has(staleId), false); + assert.equal(getPendingById().has(recentId), true); + clearPendingRequests(); +}); + +test("pending sweep max age can be overridden through environment", () => { + clearPendingRequests(); + const previous = process.env.MAX_PENDING_REQUEST_AGE_MS; + process.env.MAX_PENDING_REQUEST_AGE_MS = String(2 * HOUR_MS); + + try { + const requestId = trackPendingRequest("m", "p", "custom-age", true); + assert.ok(requestId); + + const detail = getPendingById().get(requestId); + assert.ok(detail); + detail.startedAt = Date.now() - 90 * MINUTE_MS; + + assert.equal(getMaxPendingRequestAgeMs(), 2 * HOUR_MS); + assert.equal(sweepStalePendingRequests(Date.now()), 0); + assert.equal(getPendingById().has(requestId), true); + } finally { + if (previous === undefined) delete process.env.MAX_PENDING_REQUEST_AGE_MS; + else process.env.MAX_PENDING_REQUEST_AGE_MS = previous; + clearPendingRequests(); + } +}); + +test("invalid pending sweep max age falls back to one hour", () => { + const previous = process.env.MAX_PENDING_REQUEST_AGE_MS; + process.env.MAX_PENDING_REQUEST_AGE_MS = "not-a-number"; + + try { + assert.equal(getMaxPendingRequestAgeMs(), HOUR_MS); + } finally { + if (previous === undefined) delete process.env.MAX_PENDING_REQUEST_AGE_MS; + else process.env.MAX_PENDING_REQUEST_AGE_MS = previous; + } +}); diff --git a/tests/unit/v1-models-by-id-4674.test.ts b/tests/unit/v1-models-by-id-4674.test.ts new file mode 100644 index 0000000000..4af0bb7193 --- /dev/null +++ b/tests/unit/v1-models-by-id-4674.test.ts @@ -0,0 +1,77 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + findModelById, + handleGetModelById, +} from "@/app/api/v1/models/modelById"; + +// #4674 — GET /v1/models/{model} previously had no route handler, so the request +// fell through to the Next.js catch-all and returned the HTML dashboard instead of +// JSON, breaking Claude Code's model-validation probe. These tests pin the JSON +// contract for both the found and not-found paths. + +const CATALOG = [ + { id: "claude/claude-sonnet-4-6", object: "model", owned_by: "claude" }, + { id: "cgpt-web/gpt-5.5", object: "model", owned_by: "chatgpt-web" }, + { id: "gpt-5", object: "model", owned_by: "openai" }, +]; + +function listResponse() { + return Response.json({ object: "list", data: CATALOG }); +} + +test("findModelById returns the exact-id match", () => { + const found = findModelById(CATALOG, "claude/claude-sonnet-4-6"); + assert.ok(found); + assert.equal(found.id, "claude/claude-sonnet-4-6"); + assert.equal(found.object, "model"); +}); + +test("findModelById handles provider-prefixed ids containing a slash", () => { + const found = findModelById(CATALOG, "cgpt-web/gpt-5.5"); + assert.ok(found); + assert.equal(found.id, "cgpt-web/gpt-5.5"); +}); + +test("findModelById returns null for an unknown model", () => { + assert.equal(findModelById(CATALOG, "does-not-exist"), null); +}); + +test("findModelById tolerates a non-array catalog", () => { + assert.equal(findModelById(undefined, "gpt-5"), null); + assert.equal(findModelById(null, "gpt-5"), null); +}); + +test("handleGetModelById returns 200 JSON for an existing model", async () => { + const req = new Request("http://localhost:20128/v1/models/gpt-5"); + const res = await handleGetModelById(req, "gpt-5", listResponse); + assert.equal(res.status, 200); + assert.match(res.headers.get("content-type") || "", /application\/json/); + const body = await res.json(); + assert.equal(body.id, "gpt-5"); + assert.equal(body.object, "model"); +}); + +test("handleGetModelById returns 404 JSON (not HTML) for an unknown model", async () => { + const req = new Request("http://localhost:20128/v1/models/ghost"); + const res = await handleGetModelById(req, "ghost", listResponse); + assert.equal(res.status, 404); + // The whole point of #4674: never serve the HTML dashboard here. + const ct = res.headers.get("content-type") || ""; + assert.match(ct, /application\/json/); + assert.doesNotMatch(ct, /text\/html/); + const body = await res.json(); + assert.equal(body.error.code, "model_not_found"); + assert.match(body.error.message, /ghost/); +}); + +test("handleGetModelById propagates an upstream auth/error response unchanged", async () => { + const req = new Request("http://localhost:20128/v1/models/gpt-5"); + const rejection = async () => + Response.json({ error: { message: "unauthorized" } }, { status: 401 }); + const res = await handleGetModelById(req, "gpt-5", rejection); + assert.equal(res.status, 401); + const body = await res.json(); + assert.equal(body.error.message, "unauthorized"); +}); diff --git a/tests/unit/validate-release-green.test.ts b/tests/unit/validate-release-green.test.ts new file mode 100644 index 0000000000..0712a6747c --- /dev/null +++ b/tests/unit/validate-release-green.test.ts @@ -0,0 +1,74 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Pure helpers of the release-green validator (Solution C). The orchestration is +// guarded behind a direct-run check, so importing the module here is side-effect-free. +const mod = await import("../../scripts/quality/validate-release-green.mjs"); +const { + firstFailureLine, + eslintCounts, + parseEslintJson, + parseCognitiveCount, + isDrift, + computeVerdict, +} = mod; + +test("eslintCounts sums errors + warnings across files", () => { + const parsed = [ + { errorCount: 2, warningCount: 5 }, + { errorCount: 0, warningCount: 3 }, + {}, + ]; + assert.deepEqual(eslintCounts(parsed), { errors: 2, warnings: 8 }); +}); + +test("parseEslintJson tolerates a leading non-JSON banner", () => { + const out = "npm warn something\n[{\"errorCount\":0,\"warningCount\":1}]"; + assert.deepEqual(parseEslintJson(out), [{ errorCount: 0, warningCount: 1 }]); + assert.equal(parseEslintJson("no json here"), null); +}); + +test("parseCognitiveCount reads the gate's count (en + pt)", () => { + assert.equal(parseCognitiveCount("[cognitive-complexity] 797 function(s) exceed the threshold (15)."), 797); + assert.equal(parseCognitiveCount("[cognitive-complexity] REGRESSÃO — 801 violações > baseline 797"), 801); + assert.equal(parseCognitiveCount("no number"), null); +}); + +test("isDrift flags only growth past the committed baseline (down-direction ratchets)", () => { + assert.equal(isDrift(3900, 3867), true); // grew → drift + assert.equal(isDrift(3867, 3867), false); // equal → ok + assert.equal(isDrift(3800, 3867), false); // improved → ok + assert.equal(isDrift(10, null), false); // no baseline → never drift + assert.equal(isDrift(null, 10), false); // unparsed → never drift +}); + +test("firstFailureLine surfaces the meaningful failure, not boilerplate", () => { + const out = [ + "> omniroute@3.8.34 typecheck:core", + "src/x.ts(10,5): error TS2322: Type 'string' is not assignable to 'number'.", + "done", + ].join("\n"); + assert.match(firstFailureLine(out), /error TS2322/); +}); + +test("computeVerdict: releaseGreen iff zero HARD failures (drift never blocks)", () => { + const onlyDrift = computeVerdict([ + { kind: "hard", ok: true }, + { kind: "drift", ok: false }, + ]); + assert.equal(onlyDrift.releaseGreen, true); + assert.equal(onlyDrift.drift.length, 1); + + const hardFail = computeVerdict([ + { kind: "hard", ok: false }, + { kind: "drift", ok: false }, + ]); + assert.equal(hardFail.releaseGreen, false); + assert.equal(hardFail.hardFailures.length, 1); + + const allGreen = computeVerdict([ + { kind: "hard", ok: true }, + { kind: "drift", ok: true }, + ]); + assert.equal(allGreen.releaseGreen, true); +}); diff --git a/tests/unit/virtual-auto-combo.test.ts b/tests/unit/virtual-auto-combo.test.ts index 2113cd7f2c..598c8f502f 100644 --- a/tests/unit/virtual-auto-combo.test.ts +++ b/tests/unit/virtual-auto-combo.test.ts @@ -202,16 +202,23 @@ test("createVirtualAutoCombo includes no-auth OpenCode Free without provider_con test("createVirtualAutoCombo includes all chat-capable no-auth providers without connections", async () => { const combo: VirtualComboResult = await virtualFactory.createVirtualAutoCombo("fast"); - const byProvider = new Map(combo.models.map((model) => [model.providerId, model])); + // Each noAuth provider should have multiple models (not just the first) + const ddgwModels = combo.models.filter((m) => m.providerId === "duckduckgo-web"); + assert.ok(ddgwModels.length >= 1, "duckduckgo-web should have at least one model"); + assert.ok(ddgwModels.every((m) => m.connectionId === "noauth"), "all ddgw models should use noauth connection"); + assert.ok(ddgwModels.some((m) => m.model.startsWith("ddgw/")), "ddgw models should have correct prefix"); + + const tllmModels = combo.models.filter((m) => m.providerId === "theoldllm"); + assert.ok(tllmModels.length >= 1, "theoldllm should have at least one model"); + assert.ok(tllmModels.every((m) => m.connectionId === "noauth"), "all tllm models should use noauth connection"); + assert.ok(tllmModels.some((m) => m.model === "tllm/GPT_5_4"), "tllm should include GPT_5_4"); + + const chipotleModels = combo.models.filter((m) => m.providerId === "chipotle"); + assert.ok(chipotleModels.length >= 1, "chipotle should have at least one model"); + assert.ok(chipotleModels.every((m) => m.connectionId === "noauth"), "all chipotle models should use noauth connection"); - assert.equal(byProvider.get("duckduckgo-web")?.connectionId, "noauth"); - assert.equal(byProvider.get("duckduckgo-web")?.model, "ddgw/gpt-4o-mini"); - assert.equal(byProvider.get("theoldllm")?.connectionId, "noauth"); - assert.equal(byProvider.get("theoldllm")?.model, "tllm/GPT_5_4"); - assert.equal(byProvider.get("chipotle")?.connectionId, "noauth"); - assert.equal(byProvider.get("chipotle")?.model, "pepper/pepper-1"); assert.equal( - byProvider.has("veoaifree-web"), + combo.models.some((model) => model.providerId === "veoaifree-web"), false, "video-only no-auth providers must not be inserted into chat auto-combos" ); diff --git a/tests/unit/web-cookie-providers-new.test.ts b/tests/unit/web-cookie-providers-new.test.ts index b6ff83b790..2baa240f33 100644 --- a/tests/unit/web-cookie-providers-new.test.ts +++ b/tests/unit/web-cookie-providers-new.test.ts @@ -127,7 +127,10 @@ test("v0 Vercel Web executor is registered", () => { test("Kimi Web executor is registered", () => { assert.ok(hasSpecializedExecutor("kimi-web")); - assert.ok(hasSpecializedExecutor("kimi")); + // #4699: the `kimi` API-key provider must NOT be routed through KimiWebExecutor + // (Bug 2) — it correctly falls through to DefaultExecutor. Only the explicit + // kimi-web alias keeps the specialized web executor. + assert.equal(hasSpecializedExecutor("kimi"), false); const executor = getExecutor("kimi-web"); assert.ok(executor instanceof KimiWebExecutor); }); @@ -390,9 +393,7 @@ test("Poe Web: sends p-b cookie in header", async () => { // ── Venice Web Execution Tests ─────────────────────────────────────────────── test("Venice Web: streaming passes through SSE", async () => { - const sseData = [ - 'data: {"choices":[{"delta":{"content":"Hello"}}]}', - ]; + const sseData = ['data: {"choices":[{"delta":{"content":"Hello"}}]}']; const restore = mockFetchCapture(200, mockSSEStream(sseData)); try { const executor = new VeniceWebExecutor(); @@ -422,9 +423,7 @@ test("Venice Web: error response returns error result", async () => { // ── v0 Vercel Web Execution Tests ──────────────────────────────────────────── test("v0 Vercel Web: streaming passes through SSE", async () => { - const sseData = [ - 'data: {"choices":[{"delta":{"content":"function hello() {}"}}]}', - ]; + const sseData = ['data: {"choices":[{"delta":{"content":"function hello() {}"}}]}']; const restore = mockFetchCapture(200, mockSSEStream(sseData)); try { const executor = new V0VercelWebExecutor(); @@ -454,9 +453,7 @@ test("v0 Vercel Web: error response returns error result", async () => { // ── Kimi Web Execution Tests ───────────────────────────────────────────────── test("Kimi Web: streaming passes through SSE", async () => { - const sseData = [ - 'data: {"choices":[{"delta":{"content":"你好"}}]}', - ]; + const sseData = ['data: {"choices":[{"delta":{"content":"你好"}}]}']; const restore = mockFetchCapture(200, mockSSEStream(sseData)); try { const executor = new KimiWebExecutor(); @@ -486,9 +483,7 @@ test("Kimi Web: error response returns error result", async () => { // ── Doubao Web Execution Tests ─────────────────────────────────────────────── test("Doubao Web: streaming passes through SSE", async () => { - const sseData = [ - 'data: {"choices":[{"delta":{"content":"你好世界"}}]}', - ]; + const sseData = ['data: {"choices":[{"delta":{"content":"你好世界"}}]}']; const restore = mockFetchCapture(200, mockSSEStream(sseData)); try { const executor = new DoubaoWebExecutor();