diff --git a/CHANGELOG.md b/CHANGELOG.md index 2758efda..dbb62e72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ --- +## [3.6.0] β€” 2026-04-10 + +### ✨ New Features & Analytics + +- **Combo Smoke Test:** Raised the default token budget to 2048 to prevent truncation of thinking models during preflight checks, and fully randomized the arithmetic probe prompt to bypass deterministic caching from upstream relays (#1105) + +### πŸ› Bug Fixes & Compliance + +- **DB Bloat / Row Limits:** Added `CALL_LOGS_TABLE_MAX_ROWS` and `PROXY_LOGS_TABLE_MAX_ROWS` (default: 100,000) to the backend DB compliance cleaner to prevent runaway SQLite growth. Limits are enforced automatically on the TTL cycle (#1104, fixes #1101) +- **HTML Error Handling:** The router now correctly identifies unexpected HTML responses (e.g. ``) sent by upstream providers (like Azure/Copilot) instead of throwing obscure `Unexpected token '<'` JSON parse errors, bubbling up a clean 502 Bad Gateway (#1104, fixes #1066) +- **Android/Termux SQLite Native Support:** `better-sqlite3` is now correctly built from source with cross-compilation flags in ARM64 local Termux deployments without failing on missing prebuilt binaries (#1107) + +--- + ## [3.5.9] β€” 2026-04-09 ### ✨ New Features diff --git a/docs/openapi.yaml b/docs/openapi.yaml index a193412a..bd51717b 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.5.9 + version: 3.6.0 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, diff --git a/electron/package.json b/electron/package.json index f456ce06..c890dea1 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.5.9", + "version": "3.6.0", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/llm.txt b/llm.txt index d866b81c..f5df7851 100644 --- a/llm.txt +++ b/llm.txt @@ -8,7 +8,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.5.9 +**Current version:** 3.6.0 ## Tech Stack @@ -279,7 +279,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.5.9) +## Key Features (v3.6.0) ### Core Proxy - **60+ AI providers** with automatic format translation diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index b3b92c62..2021e3a6 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -2017,21 +2017,31 @@ export async function handleChatCore({ try { responseBody = rawBody ? JSON.parse(rawBody) : {}; } catch { + const isHtmlResponse = + rawBody && typeof rawBody === "string" && /^\s*(?:\s| {}); - const invalidJsonMessage = "Invalid JSON response from provider"; + + const invalidJsonMessage = isHtmlResponse + ? "Provider returned HTML error page instead of JSON - check credentials and endpoint" + : "Invalid JSON response from provider"; persistAttemptLogs({ status: HTTP_STATUS.BAD_GATEWAY, error: invalidJsonMessage, providerRequest: finalBody || translatedBody, - providerResponse: normalizedProviderPayload, + providerResponse: isHtmlResponse + ? { _raw: rawBody?.slice(0, 500) } + : normalizedProviderPayload, clientResponse: buildErrorBody(HTTP_STATUS.BAD_GATEWAY, invalidJsonMessage), }); - persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "invalid_json_payload"); + persistFailureUsage( + HTTP_STATUS.BAD_GATEWAY, + isHtmlResponse ? "html_error_response" : "invalid_json_payload" + ); return createErrorResult(HTTP_STATUS.BAD_GATEWAY, invalidJsonMessage); } } diff --git a/open-sse/package.json b/open-sse/package.json index 9d86567a..e6bca32d 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "3.5.9", + "version": "3.6.0", "description": "Express SSE sidecar for OmniRoute β€” handles streaming, protocol translation, and provider orchestration", "type": "module", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index c5cf7990..ef4fe929 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.5.9", + "version": "3.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.5.9", + "version": "3.6.0", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 7be6aae2..5c5a9b70 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.5.9", + "version": "3.6.0", "description": "Smart AI Router with auto fallback β€” route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.", "type": "module", "bin": { diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs index e3bf0c93..f2c1f1b6 100644 --- a/scripts/postinstall.mjs +++ b/scripts/postinstall.mjs @@ -128,10 +128,17 @@ async function fixBetterSqliteBinary() { try { const { execSync } = await import("node:child_process"); - execSync("npm rebuild better-sqlite3", { + + // On Android/Termux, rebuild from source with --build-from-source flag + const isAndroid = process.platform === "android"; + const rebuildCmd = isAndroid + ? "npm install better-sqlite3 --build-from-source --force" + : "npm rebuild better-sqlite3"; + + execSync(rebuildCmd, { cwd: join(ROOT, "app"), stdio: "inherit", - timeout: 120_000, + timeout: 300_000, // 5 minutes for source builds }); process.dlopen({ exports: {} }, appBinary); @@ -140,7 +147,7 @@ async function fixBetterSqliteBinary() { } catch (err) { const isTimeout = err.killed || err.signal === "SIGTERM"; if (isTimeout) { - console.warn(" ⚠️ npm rebuild timed out after 120s."); + console.warn(" ⚠️ npm rebuild timed out after 300s."); } else { console.warn(` ⚠️ npm rebuild failed: ${err.message}`); } diff --git a/src/app/(dashboard)/dashboard/playground/page.tsx b/src/app/(dashboard)/dashboard/playground/page.tsx index 0c297de4..cc3ebd23 100644 --- a/src/app/(dashboard)/dashboard/playground/page.tsx +++ b/src/app/(dashboard)/dashboard/playground/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { Card, Button, Select, Badge } from "@/shared/components"; import { ALIAS_TO_ID } from "@/shared/constants/providers"; +import { pickMaskedDisplayValue } from "@/shared/utils/maskEmail"; import dynamic from "next/dynamic"; const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false }); @@ -254,7 +255,7 @@ export default function PlaygroundPage() { for (const conn of data?.connections || []) { conns.push({ id: conn.id, - name: conn.name || conn.email || conn.id, + name: pickMaskedDisplayValue([conn.name, conn.email], conn.id), provider: conn.provider, authType: conn.authType || "apiKey", }); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index fa839708..c5964b6a 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -43,7 +43,7 @@ import { type ModelCompatProtocolKey, } from "@/shared/constants/modelCompat"; import { resolveManagedModelAlias } from "@/shared/utils/providerModelAliases"; -import { maskEmail } from "@/shared/utils/maskEmail"; +import { maskEmail, pickMaskedDisplayValue } from "@/shared/utils/maskEmail"; type CompatByProtocolMap = Partial< Record< @@ -62,6 +62,7 @@ type ModelCompatSavePatch = { preserveOpenAIDeveloperRole?: boolean; upstreamHeaders?: Record; compatByProtocol?: CompatByProtocolMap; + isHidden?: boolean; }; type CompatModelRow = { @@ -72,6 +73,7 @@ type CompatModelRow = { supportedEndpoints?: string[]; normalizeToolCallId?: boolean; preserveOpenAIDeveloperRole?: boolean; + isHidden?: boolean; upstreamHeaders?: Record; compatByProtocol?: CompatByProtocolMap; }; @@ -92,6 +94,42 @@ function getProtoSlice( return c?.compatByProtocol?.[protocol] ?? o?.compatByProtocol?.[protocol]; } +function isModelHidden( + modelId: string, + customMap: CompatModelMap, + overrideMap: CompatModelMap +): boolean { + const c = customMap.get(modelId); + if (c && Object.prototype.hasOwnProperty.call(c, "isHidden")) { + return Boolean(c.isHidden); + } + const o = overrideMap.get(modelId); + if (o && Object.prototype.hasOwnProperty.call(o, "isHidden")) { + return Boolean(o.isHidden); + } + return false; +} + +function providerText( + t: ((key: string, values?: Record) => string) & { + has?: (key: string) => boolean; + }, + key: string, + fallback: string, + values?: Record +): string { + if (typeof t.has === "function" && t.has(key)) { + return t(key, values); + } + if (values) { + return Object.entries(values).reduce( + (acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)), + fallback + ); + } + return fallback; +} + function effectiveNormalizeForProtocol( modelId: string, protocol: string, @@ -295,6 +333,7 @@ interface ModelRowProps { interface PassthroughModelRowProps { modelId: string; fullModel: string; + isHidden?: boolean; copied?: string; onCopy: (text: string, key: string) => void; onDeleteAlias: () => void; @@ -305,6 +344,8 @@ interface PassthroughModelRowProps { saveModelCompatFlags: (modelId: string, patch: ModelCompatSavePatch) => void; getUpstreamHeadersRecord: (protocol: string) => Record; compatDisabled?: boolean; + onToggleHidden?: (modelId: string, hidden: boolean) => Promise; + togglingHidden?: boolean; } interface PassthroughModelsSectionProps { @@ -327,6 +368,11 @@ interface PassthroughModelsSectionProps { } ) => Promise; compatSavingModelId?: string; + isModelHidden: (modelId: string) => boolean; + onToggleHidden: (modelId: string, hidden: boolean) => Promise; + onBulkToggleHidden: (modelIds: string[], hidden: boolean) => Promise; + bulkTogglePending?: boolean; + togglingModelId?: string | null; } interface CustomModelsSectionProps { @@ -366,10 +412,16 @@ interface CompatibleModelsSectionProps { normalizeToolCallId?: boolean; preserveDeveloperRole?: boolean; preserveOpenAIDeveloperRole?: boolean; + isHidden?: boolean; } ) => Promise; compatSavingModelId?: string; onModelsChanged?: () => void; + isModelHidden: (modelId: string) => boolean; + onToggleHidden: (modelId: string, hidden: boolean) => Promise; + onBulkToggleHidden: (modelIds: string[], hidden: boolean) => Promise; + bulkTogglePending?: boolean; + togglingModelId?: string | null; } interface CooldownTimerProps { @@ -860,6 +912,9 @@ export default function ProviderDetailPage() { const [compatSavingModelId, setCompatSavingModelId] = useState(null); const [modelFilter, setModelFilter] = useState(""); const [togglingModelId, setTogglingModelId] = useState(null); + const [bulkVisibilityAction, setBulkVisibilityAction] = useState<"select" | "deselect" | null>( + null + ); const [applyingCodexAuthId, setApplyingCodexAuthId] = useState(null); const [exportingCodexAuthId, setExportingCodexAuthId] = useState(null); const isOpenAICompatible = isOpenAICompatibleProvider(providerId); @@ -1869,6 +1924,11 @@ export default function ProviderDetailPage() { protocol = MODEL_COMPAT_PROTOCOL_KEYS[0] ) => effectivePreserveForProtocol(modelId, protocol, customMap, overrideMap); + const effectiveModelHidden = useCallback( + (modelId: string) => isModelHidden(modelId, customMap, overrideMap), + [customMap, overrideMap] + ); + const getUpstreamHeadersRecordForModel = useCallback( (modelId: string, protocol: string) => effectiveUpstreamHeadersForProtocol(modelId, protocol, customMap, overrideMap), @@ -1945,14 +2005,21 @@ export default function ProviderDetailPage() { } }; - const handleToggleModelHidden = async (modelId: string, hidden: boolean): Promise => { + const handleToggleModelHidden = async ( + providerKey: string, + modelId: string, + hidden: boolean + ): Promise => { setTogglingModelId(modelId); try { - const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerId)}&modelId=${encodeURIComponent(modelId)}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ isHidden: hidden }), - }); + const res = await fetch( + `/api/provider-models?provider=${encodeURIComponent(providerKey)}&modelId=${encodeURIComponent(modelId)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isHidden: hidden }), + } + ); if (!res.ok) { const detail = await res.text().catch(() => ""); notify.error(detail || t("failedSaveCustomModel")); @@ -1967,6 +2034,32 @@ export default function ProviderDetailPage() { } }; + const handleBulkToggleModelHidden = async ( + providerKey: string, + modelIds: string[], + hidden: boolean + ): Promise => { + if (modelIds.length === 0) return; + setBulkVisibilityAction(hidden ? "deselect" : "select"); + try { + const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerKey)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isHidden: hidden, modelIds }), + }); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + notify.error(detail || t("failedSaveCustomModel")); + return; + } + await fetchProviderModelMeta().catch(() => {}); + } catch { + notify.error(t("failedSaveCustomModel")); + } finally { + setBulkVisibilityAction(null); + } + }; + const renderModelsSection = () => { const autoSyncToggle = compatibleSupportsModelImport && canImportModels && ( + + + {providerText(t, "modelsActiveCount", "{active}/{total} active", { + active: activeCount, + total: totalCount, + })} + + + ); +} + function PassthroughModelsSection({ providerAlias, modelAliases, @@ -3033,9 +3229,15 @@ function PassthroughModelsSection({ getUpstreamHeadersRecord, saveModelCompatFlags, compatSavingModelId, + isModelHidden, + onToggleHidden, + onBulkToggleHidden, + bulkTogglePending, + togglingModelId, }: PassthroughModelsSectionProps) { const [newModel, setNewModel] = useState(""); const [adding, setAdding] = useState(false); + const [modelFilter, setModelFilter] = useState(""); const providerAliases = Object.entries(modelAliases).filter(([, model]: [string, any]) => (model as string).startsWith(`${providerAlias}/`) @@ -3044,12 +3246,20 @@ function PassthroughModelsSection({ const allModels = providerAliases.map(([alias, fullModel]: [string, any]) => { const fmStr = fullModel as string; const prefix = `${providerAlias}/`; + const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr; return { - modelId: fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr, + modelId, fullModel, alias, + isHidden: isModelHidden(modelId), }; }); + const filteredModels = modelFilter + ? allModels.filter(({ modelId }) => modelId.toLowerCase().includes(modelFilter.toLowerCase())) + : allModels; + const activeCount = allModels.filter((model) => !model.isHidden).length; + const hiddenFilteredCount = filteredModels.filter((model) => model.isHidden).length; + const visibleFilteredCount = filteredModels.length - hiddenFilteredCount; // Generate default alias from modelId (last part after /) const generateDefaultAlias = (modelId) => { @@ -3107,11 +3317,33 @@ function PassthroughModelsSection({ {/* Models list */} {allModels.length > 0 && (
- {allModels.map(({ modelId, fullModel, alias }) => ( + + onBulkToggleHidden( + filteredModels.map((model) => model.modelId), + false + ) + } + onDeselectAll={() => + onBulkToggleHidden( + filteredModels.map((model) => model.modelId), + true + ) + } + selectAllDisabled={hiddenFilteredCount === 0 || bulkTogglePending} + deselectAllDisabled={visibleFilteredCount === 0 || bulkTogglePending} + /> + {filteredModels.map(({ modelId, fullModel, alias, isHidden }) => ( onDeleteAlias(alias)} @@ -3122,8 +3354,17 @@ function PassthroughModelsSection({ getUpstreamHeadersRecord={(p) => getUpstreamHeadersRecord(modelId, p)} saveModelCompatFlags={saveModelCompatFlags} compatDisabled={compatSavingModelId === modelId} + onToggleHidden={onToggleHidden} + togglingHidden={togglingModelId === modelId} /> ))} + {filteredModels.length === 0 && modelFilter && ( +

+ {providerText(t, "noModelsMatch", `No models match "${modelFilter}"`, { + filter: modelFilter, + })} +

+ )}
)} @@ -3143,11 +3384,17 @@ PassthroughModelsSection.propTypes = { getUpstreamHeadersRecord: PropTypes.func.isRequired, saveModelCompatFlags: PropTypes.func.isRequired, compatSavingModelId: PropTypes.string, + isModelHidden: PropTypes.func.isRequired, + onToggleHidden: PropTypes.func.isRequired, + onBulkToggleHidden: PropTypes.func.isRequired, + bulkTogglePending: PropTypes.bool, + togglingModelId: PropTypes.string, }; function PassthroughModelRow({ modelId, fullModel, + isHidden, copied, onCopy, onDeleteAlias, @@ -3158,11 +3405,20 @@ function PassthroughModelRow({ getUpstreamHeadersRecord, saveModelCompatFlags, compatDisabled, + onToggleHidden, + togglingHidden, }: PassthroughModelRowProps) { return ( -
+
- + smart_toy
@@ -3184,6 +3440,22 @@ function PassthroughModelRow({
+ {onToggleHidden && ( + + )} effectiveModelNormalize(modelId, p)} @@ -3210,6 +3482,7 @@ function PassthroughModelRow({ PassthroughModelRow.propTypes = { modelId: PropTypes.string.isRequired, fullModel: PropTypes.string.isRequired, + isHidden: PropTypes.bool, copied: PropTypes.string, onCopy: PropTypes.func.isRequired, onDeleteAlias: PropTypes.func.isRequired, @@ -3220,6 +3493,8 @@ PassthroughModelRow.propTypes = { getUpstreamHeadersRecord: PropTypes.func.isRequired, saveModelCompatFlags: PropTypes.func.isRequired, compatDisabled: PropTypes.bool, + onToggleHidden: PropTypes.func, + togglingHidden: PropTypes.bool, }; // ============ Custom Models Section (for ALL providers) ============ @@ -3721,10 +3996,16 @@ function CompatibleModelsSection({ compatSavingModelId, onModelsChanged, allowImport, + isModelHidden, + onToggleHidden, + onBulkToggleHidden, + bulkTogglePending, + togglingModelId, }: CompatibleModelsSectionProps) { const [newModel, setNewModel] = useState(""); const [adding, setAdding] = useState(false); const [importing, setImporting] = useState(false); + const [modelFilter, setModelFilter] = useState(""); const notify = useNotificationStore(); const providerAliases = useMemo( @@ -3739,21 +4020,29 @@ function CompatibleModelsSection({ const rows = providerAliases.map(([alias, fullModel]: [string, any]) => { const fmStr = fullModel as string; const prefix = `${providerStorageAlias}/`; + const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr; return { - modelId: fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr, + modelId, alias, + isHidden: isModelHidden(modelId), }; }); const seenModelIds = new Set(rows.map((row) => row.modelId)); for (const model of fallbackModels) { if (!model?.id || seenModelIds.has(model.id)) continue; - rows.push({ modelId: model.id, alias: null }); + rows.push({ modelId: model.id, alias: null, isHidden: isModelHidden(model.id) }); seenModelIds.add(model.id); } return rows; - }, [fallbackModels, providerAliases, providerStorageAlias]); + }, [fallbackModels, isModelHidden, providerAliases, providerStorageAlias]); + const filteredModels = modelFilter + ? allModels.filter(({ modelId }) => modelId.toLowerCase().includes(modelFilter.toLowerCase())) + : allModels; + const activeCount = allModels.filter((model) => !model.isHidden).length; + const hiddenFilteredCount = filteredModels.filter((model) => model.isHidden).length; + const visibleFilteredCount = filteredModels.length - hiddenFilteredCount; const resolveAlias = useCallback( (modelId: string, workingAliases: Record) => @@ -3935,11 +4224,33 @@ function CompatibleModelsSection({ {allModels.length > 0 && (
- {allModels.map(({ modelId, alias }) => ( + + onBulkToggleHidden( + filteredModels.map((model) => model.modelId), + false + ) + } + onDeselectAll={() => + onBulkToggleHidden( + filteredModels.map((model) => model.modelId), + true + ) + } + selectAllDisabled={hiddenFilteredCount === 0 || bulkTogglePending} + deselectAllDisabled={visibleFilteredCount === 0 || bulkTogglePending} + /> + {filteredModels.map(({ modelId, alias, isHidden }) => ( handleDeleteModel(modelId, alias)} @@ -3950,8 +4261,17 @@ function CompatibleModelsSection({ getUpstreamHeadersRecord={(p) => getUpstreamHeadersRecord(modelId, p)} saveModelCompatFlags={saveModelCompatFlags} compatDisabled={compatSavingModelId === modelId} + onToggleHidden={onToggleHidden} + togglingHidden={togglingModelId === modelId} /> ))} + {filteredModels.length === 0 && modelFilter && ( +

+ {providerText(t, "noModelsMatch", `No models match "${modelFilter}"`, { + filter: modelFilter, + })} +

+ )}
)}
@@ -3986,6 +4306,11 @@ CompatibleModelsSection.propTypes = { compatSavingModelId: PropTypes.string, onModelsChanged: PropTypes.func, allowImport: PropTypes.bool.isRequired, + isModelHidden: PropTypes.func.isRequired, + onToggleHidden: PropTypes.func.isRequired, + onBulkToggleHidden: PropTypes.func.isRequired, + bulkTogglePending: PropTypes.bool, + togglingModelId: PropTypes.string, }; function CooldownTimer({ until }: CooldownTimerProps) { @@ -4243,7 +4568,10 @@ function ConnectionRow({ }: ConnectionRowProps) { const t = useTranslations("providers"); const displayName = isOAuth - ? connection.name || connection.email || connection.displayName || t("oauthAccount") + ? pickMaskedDisplayValue( + [connection.name, connection.email, connection.displayName], + t("oauthAccount") + ) : connection.name; const applyCodexAuthLabel = typeof t.has === "function" && t.has("applyCodexAuthLocal") @@ -4943,6 +5271,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec const [extraApiKeys, setExtraApiKeys] = useState([]); const [newExtraKey, setNewExtraKey] = useState(""); const [showAdvanced, setShowAdvanced] = useState(false); + const [showEmail, setShowEmail] = useState(false); const isBailian = connection?.provider === "bailian-coding-plan"; const defaultBailianUrl = "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1"; @@ -4976,6 +5305,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec setExtraApiKeys(Array.isArray(existing) ? existing : []); setNewExtraKey(""); setShowAdvanced(!!existingCustomUserAgent); + setShowEmail(false); setTestResult(null); setValidationResult(null); setSaveError(null); @@ -5158,9 +5488,21 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec {isOAuth && connection.email && (

{t("email")}

-

- {maskEmail(connection.email)} -

+
+

+ {showEmail ? connection.email : maskEmail(connection.email)} +

+ +
)} {isOAuth && ( diff --git a/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx b/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx index a1c2ed87..230ca9f5 100644 --- a/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx @@ -35,6 +35,10 @@ export default function SystemStorageTab() { app: 7, call: 7, }, + tableMaxRows: { + callLogs: 100000, + proxyLogs: 100000, + }, lastBackupAt: null, }); @@ -372,8 +376,9 @@ export default function SystemStorageTab() {

Log retention policy

- Request logs follow CALL_LOG_RETENTION_DAYS. Application and audit logs - follow APP_LOG_RETENTION_DAYS. + Request logs retain up to CALL_LOGS_TABLE_MAX_ROWS rows (default: + 100,000). Proxy logs retain up to PROXY_LOGS_TABLE_MAX_ROWS rows. Older + entries auto-deleted.

@@ -383,6 +388,9 @@ export default function SystemStorageTab() { App {storageHealth.retentionDays.app}d + + {storageHealth.tableMaxRows?.callLogs?.toLocaleString() || "100K"} rows +
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index 35ce3cca..9fea3b99 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -15,6 +15,7 @@ import Card from "@/shared/components/Card"; import Badge from "@/shared/components/Badge"; import { CardSkeleton } from "@/shared/components/Loading"; import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; +import { pickMaskedDisplayValue } from "@/shared/utils/maskEmail"; const LS_GROUP_BY = "omniroute:limits:groupBy"; const LS_EXPANDED_GROUPS = "omniroute:limits:expandedGroups"; @@ -545,7 +546,10 @@ export default function ProviderLimits() {
- {conn.name || conn.displayName || conn.email || config.label} + {pickMaskedDisplayValue( + [conn.name, conn.displayName, conn.email], + config.label + )}
+): string[] { + const bodyModelIds = Array.isArray(body.modelIds) + ? body.modelIds + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter(Boolean) + : []; + const singleModelId = searchParams.get("modelId") || searchParams.get("model"); + const allModelIds = [...bodyModelIds, ...(singleModelId ? [singleModelId.trim()] : [])]; + return Array.from(new Set(allModelIds)).filter(Boolean); +} + /** * GET /api/provider-models?provider= * List custom models (all providers if no provider param) @@ -226,6 +241,85 @@ export async function PUT(request) { } } +/** + * PATCH /api/provider-models?provider=&modelId= + * Body: { isHidden: boolean, modelIds?: string[] } + */ +export async function PATCH(request) { + let rawBody; + try { + rawBody = await request.json(); + } catch { + return Response.json( + { error: { message: "Invalid JSON body", type: "validation_error" } }, + { status: 400 } + ); + } + + try { + if (!(await isAuthenticated(request))) { + return Response.json( + { error: { message: "Authentication required", type: "invalid_api_key" } }, + { status: 401 } + ); + } + + const { searchParams } = new URL(request.url); + const provider = searchParams.get("provider"); + const body = + rawBody && typeof rawBody === "object" && !Array.isArray(rawBody) + ? (rawBody as Record) + : {}; + + if (!provider) { + return Response.json( + { error: { message: "provider query param is required", type: "validation_error" } }, + { status: 400 } + ); + } + + if (typeof body.isHidden !== "boolean") { + return Response.json( + { error: { message: "isHidden boolean is required", type: "validation_error" } }, + { status: 400 } + ); + } + + const modelIds = normalizeRequestedModelIds(searchParams, body); + if (modelIds.length === 0) { + return Response.json( + { + error: { + message: "modelId query param or body.modelIds is required", + type: "validation_error", + }, + }, + { status: 400 } + ); + } + + for (const modelId of modelIds) { + const updatedModel = await updateCustomModel(provider, modelId, { isHidden: body.isHidden }); + if (!updatedModel) { + mergeModelCompatOverride(provider, modelId, { isHidden: body.isHidden }); + } + } + + return Response.json({ + ok: true, + updated: modelIds.length, + models: await getCustomModels(provider), + modelCompatOverrides: getModelCompatOverrides(provider), + }); + } catch (error) { + console.error("Error patching provider models:", error); + return Response.json( + { error: { message: "Failed to update provider models", type: "server_error" } }, + { status: 500 } + ); + } +} + /** * DELETE /api/provider-models?provider=&model= */ diff --git a/src/app/api/storage/health/route.ts b/src/app/api/storage/health/route.ts index 42a2c5f3..b4e98b0e 100644 --- a/src/app/api/storage/health/route.ts +++ b/src/app/api/storage/health/route.ts @@ -2,7 +2,12 @@ import { NextResponse } from "next/server"; import path from "path"; import fs from "fs"; import { resolveDataDir } from "@/lib/dataPaths"; -import { getAppLogRetentionDays, getCallLogRetentionDays } from "@/lib/logEnv"; +import { + getAppLogRetentionDays, + getCallLogRetentionDays, + getCallLogsTableMaxRows, + getProxyLogsTableMaxRows, +} from "@/lib/logEnv"; /** * GET /api/storage/health β€” Return database storage information. @@ -61,6 +66,10 @@ export async function GET() { app: getAppLogRetentionDays(), call: getCallLogRetentionDays(), }, + tableMaxRows: { + callLogs: getCallLogsTableMaxRows(), + proxyLogs: getProxyLogsTableMaxRows(), + }, dataDir: dataDir.startsWith(homeDir) ? "~" + dataDir.slice(homeDir.length) : dataDir, }); } catch (error) { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index c512b3a4..88398937 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1770,6 +1770,13 @@ "email": "Email", "healthCheckMinutes": "Health Check (min)", "healthCheckHint": "Proactive token refresh interval. 0 = disabled.", + "filterModels": "Filter models...", + "showModel": "Show model", + "hideModel": "Hide model", + "selectAllModels": "Select all", + "deselectAllModels": "Deselect all", + "modelsActiveCount": "{active}/{total} active", + "noModelsMatch": "No models match \"{filter}\"", "groupLabel": "Environment", "groupPlaceholder": "e.g. eKaizen, Personal", "failedTestConnection": "Failed to test connection", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index f17e8d34..b11e47b0 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -1766,6 +1766,13 @@ "email": "Email", "healthCheckMinutes": "Health Check (min)", "healthCheckHint": "Intervalo proativo de renovaΓ§Γ£o de token. 0 = desativado.", + "filterModels": "Filtrar modelos...", + "showModel": "Mostrar modelo", + "hideModel": "Ocultar modelo", + "selectAllModels": "Selecionar todos", + "deselectAllModels": "Desmarcar todos", + "modelsActiveCount": "{active}/{total} ativos", + "noModelsMatch": "Nenhum modelo corresponde a \"{filter}\"", "groupLabel": "Ambiente", "groupPlaceholder": "ex: eKaizen, Pessoal", "failedTestConnection": "Falha ao testar conexΓ£o", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 9d43c803..b0c37184 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1757,6 +1757,13 @@ "email": "E-mail", "healthCheckMinutes": "VerificaΓ§Γ£o de integridade (min)", "healthCheckHint": "Intervalo de atualizaΓ§Γ£o de token proativo. 0 = desabilitado.", + "filterModels": "Filtrar modelos...", + "showModel": "Mostrar modelo", + "hideModel": "Ocultar modelo", + "selectAllModels": "Selecionar todos", + "deselectAllModels": "Desmarcar todos", + "modelsActiveCount": "{active}/{total} ativos", + "noModelsMatch": "Nenhum modelo corresponde a \"{filter}\"", "groupLabel": "Environment", "groupPlaceholder": "e.g. eKaizen, Personal", "failedTestConnection": "Falha ao testar a conexΓ£o", diff --git a/src/lib/combos/testHealth.ts b/src/lib/combos/testHealth.ts index f7ac5f66..b7336921 100644 --- a/src/lib/combos/testHealth.ts +++ b/src/lib/combos/testHealth.ts @@ -1,5 +1,9 @@ type JsonRecord = Record; +const COMBO_TEST_MAX_TOKENS = 2048; +const COMBO_TEST_OPERAND_MIN = 10000; +const COMBO_TEST_OPERAND_RANGE = 90000; + function asRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; } @@ -103,14 +107,26 @@ function hasReasoningOnlyCompletion(body: JsonRecord): boolean { }); } +function getRandomFiveDigitNumber() { + return COMBO_TEST_OPERAND_MIN + Math.floor(Math.random() * COMBO_TEST_OPERAND_RANGE); +} + +function buildComboTestPrompt() { + const left = getRandomFiveDigitNumber(); + const right = getRandomFiveDigitNumber(); + + return `Calculate ${left}+${right}, and reply with the result only.`; +} + export function buildComboTestRequestBody(modelStr: string) { return { model: modelStr, - messages: [{ role: "user", content: "Reply with OK only." }], - // Give reasoning-heavy models enough headroom to emit a tiny visible answer - // without turning the smoke test into a full-cost real request. - max_tokens: 64, - temperature: 0, + // Randomize the arithmetic prompt so upstream providers are less likely to + // satisfy the smoke test with cached completions. + messages: [{ role: "user", content: buildComboTestPrompt() }], + // Give reasoning-heavy models enough headroom to finish the request and + // still emit a visible answer without immediate truncation. + max_tokens: COMBO_TEST_MAX_TOKENS, stream: false, }; } diff --git a/src/lib/compliance/index.ts b/src/lib/compliance/index.ts index df7ae544..b143ac42 100644 --- a/src/lib/compliance/index.ts +++ b/src/lib/compliance/index.ts @@ -10,7 +10,12 @@ */ import { getDbInstance } from "../db/core"; -import { getAppLogRetentionDays, getCallLogRetentionDays } from "../logEnv"; +import { + getAppLogRetentionDays, + getCallLogRetentionDays, + getCallLogsTableMaxRows, + getProxyLogsTableMaxRows, +} from "../logEnv"; /** @returns {import("better-sqlite3").Database | null} */ function getDb() { @@ -231,14 +236,20 @@ export function getRetentionDays() { * deletedRequestDetailLogs: number, * deletedAuditLogs: number, * deletedMcpAuditLogs: number, + * trimmedCallLogs: number, + * trimmedProxyLogs: number, * appRetentionDays: number, - * callRetentionDays: number + * callRetentionDays: number, + * callLogsMaxRows: number, + * proxyLogsMaxRows: number * }} */ export function cleanupExpiredLogs() { const db = getDb(); const appRetentionDays = getAppLogRetentionDays(); const callRetentionDays = getCallLogRetentionDays(); + const callLogsMaxRows = getCallLogsTableMaxRows(); + const proxyLogsMaxRows = getProxyLogsTableMaxRows(); if (!db) { return { @@ -248,8 +259,12 @@ export function cleanupExpiredLogs() { deletedRequestDetailLogs: 0, deletedAuditLogs: 0, deletedMcpAuditLogs: 0, + trimmedCallLogs: 0, + trimmedProxyLogs: 0, appRetentionDays, callRetentionDays, + callLogsMaxRows, + proxyLogsMaxRows, }; } @@ -262,6 +277,8 @@ export function cleanupExpiredLogs() { let deletedRequestDetailLogs = 0; let deletedAuditLogs = 0; let deletedMcpAuditLogs = 0; + let trimmedCallLogs = 0; + let trimmedProxyLogs = 0; try { const r1 = db.prepare("DELETE FROM usage_history WHERE timestamp < ?").run(callCutoff); @@ -305,6 +322,54 @@ export function cleanupExpiredLogs() { /* table may not exist */ } + // Enforce row count limits to prevent unbounded DB growth (batched to avoid long locks) + const BATCH_SIZE = 5000; + if (callLogsMaxRows > 0) { + try { + let currentCount = db.prepare("SELECT COUNT(*) as cnt FROM call_logs").get() as { + cnt: number; + }; + while (currentCount.cnt > callLogsMaxRows) { + const toDelete = Math.min(currentCount.cnt - callLogsMaxRows, BATCH_SIZE); + const trimmed = db + .prepare( + `DELETE FROM call_logs WHERE id IN ( + SELECT id FROM call_logs ORDER BY timestamp ASC LIMIT ? + )` + ) + .run(toDelete); + trimmedCallLogs += trimmed.changes; + currentCount.cnt -= trimmed.changes; + if (trimmed.changes === 0) break; + } + } catch { + /* best effort */ + } + } + + if (proxyLogsMaxRows > 0) { + try { + let currentProxyCount = db.prepare("SELECT COUNT(*) as cnt FROM proxy_logs").get() as { + cnt: number; + }; + while (currentProxyCount.cnt > proxyLogsMaxRows) { + const toDelete = Math.min(currentProxyCount.cnt - proxyLogsMaxRows, BATCH_SIZE); + const trimmed = db + .prepare( + `DELETE FROM proxy_logs WHERE id IN ( + SELECT id FROM proxy_logs ORDER BY timestamp ASC LIMIT ? + )` + ) + .run(toDelete); + trimmedProxyLogs += trimmed.changes; + currentProxyCount.cnt -= trimmed.changes; + if (trimmed.changes === 0) break; + } + } catch { + /* best effort */ + } + } + logAuditEvent({ action: "compliance.cleanup", details: { @@ -314,8 +379,12 @@ export function cleanupExpiredLogs() { deletedRequestDetailLogs, deletedAuditLogs, deletedMcpAuditLogs, + trimmedCallLogs, + trimmedProxyLogs, appRetentionDays, callRetentionDays, + callLogsMaxRows, + proxyLogsMaxRows, }, }); @@ -326,7 +395,11 @@ export function cleanupExpiredLogs() { deletedRequestDetailLogs, deletedAuditLogs, deletedMcpAuditLogs, + trimmedCallLogs, + trimmedProxyLogs, appRetentionDays, callRetentionDays, + callLogsMaxRows, + proxyLogsMaxRows, }; } diff --git a/src/lib/logEnv.ts b/src/lib/logEnv.ts index c1524cb9..897b57bb 100644 --- a/src/lib/logEnv.ts +++ b/src/lib/logEnv.ts @@ -5,6 +5,8 @@ const DEFAULT_CALL_LOG_RETENTION_DAYS = 7; const DEFAULT_APP_LOG_MAX_SIZE = 50 * 1024 * 1024; const DEFAULT_APP_LOG_MAX_FILES = 20; const DEFAULT_CALL_LOG_MAX_ENTRIES = 10000; +const DEFAULT_CALL_LOGS_TABLE_MAX_ROWS = 100000; +const DEFAULT_PROXY_LOGS_TABLE_MAX_ROWS = 100000; const DEFAULT_APP_LOG_PATH = path.join(process.cwd(), "logs", "application", "app.log"); function parsePositiveInt(value: string | undefined, fallback: number): number { @@ -62,6 +64,14 @@ export function getCallLogMaxEntries(): number { return parsePositiveInt(process.env.CALL_LOG_MAX_ENTRIES, DEFAULT_CALL_LOG_MAX_ENTRIES); } +export function getCallLogsTableMaxRows(): number { + return parsePositiveInt(process.env.CALL_LOGS_TABLE_MAX_ROWS, DEFAULT_CALL_LOGS_TABLE_MAX_ROWS); +} + +export function getProxyLogsTableMaxRows(): number { + return parsePositiveInt(process.env.PROXY_LOGS_TABLE_MAX_ROWS, DEFAULT_PROXY_LOGS_TABLE_MAX_ROWS); +} + export function getAppLogLevel(defaultLevel: string): string { return process.env.APP_LOG_LEVEL || defaultLevel; } diff --git a/src/lib/tokenHealthCheck.ts b/src/lib/tokenHealthCheck.ts index 6557728b..8f3d9441 100644 --- a/src/lib/tokenHealthCheck.ts +++ b/src/lib/tokenHealthCheck.ts @@ -21,6 +21,7 @@ import { supportsTokenRefresh, isUnrecoverableRefreshError, } from "@omniroute/open-sse/services/tokenRefresh.ts"; +import { pickMaskedDisplayValue } from "@/shared/utils/maskEmail"; // ── Constants ──────────────────────────────────────────────────────────────── const TICK_MS = 60 * 1000; // sweep interval: every 60 seconds @@ -30,6 +31,10 @@ const EXPIRED_RETRY_BACKOFF_MIN = 5; // backoff between expired retries (minutes const LOG_PREFIX = "[HealthCheck]"; const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]); +function getConnectionLogLabel(conn: { name?: string; email?: string; id?: string }): string { + return pickMaskedDisplayValue([conn.name, conn.email], conn.id || "-"); +} + export function buildRefreshFailureUpdate(conn: any, now: string) { const wasExpired = conn.testStatus === "expired"; const retryCount = (conn.expiredRetryCount ?? 0) + (wasExpired ? 1 : 0); @@ -214,7 +219,7 @@ async function checkConnection(conn) { if (Date.now() - lastRetry < backoffMs) return; log( - `${LOG_PREFIX} Retrying expired ${conn.provider}/${conn.name || conn.email || conn.id} (attempt ${retryCount + 1}/${EXPIRED_RETRY_MAX})` + `${LOG_PREFIX} Retrying expired ${conn.provider}/${getConnectionLogLabel(conn)} (attempt ${retryCount + 1}/${EXPIRED_RETRY_MAX})` ); } @@ -222,7 +227,7 @@ async function checkConnection(conn) { const now = new Date().toISOString(); await updateProviderConnection(conn.id, { lastHealthCheckAt: now }); log( - `${LOG_PREFIX} Skipping ${conn.provider}/${conn.name || conn.email || conn.id} (refresh unsupported)` + `${LOG_PREFIX} Skipping ${conn.provider}/${getConnectionLogLabel(conn)} (refresh unsupported)` ); return; } @@ -240,9 +245,7 @@ async function checkConnection(conn) { if (Date.now() - lastCheck < intervalMs && !isAboutToExpire) return; const reason = isAboutToExpire ? "token expiring soon" : `interval: ${intervalMin}min`; - log( - `${LOG_PREFIX} Refreshing ${conn.provider}/${conn.name || conn.email || conn.id} (${reason})` - ); + log(`${LOG_PREFIX} Refreshing ${conn.provider}/${getConnectionLogLabel(conn)} (${reason})`); const credentials = { refreshToken: conn.refreshToken, @@ -289,7 +292,7 @@ async function checkConnection(conn) { refreshToken: null, }); logError( - `${LOG_PREFIX} βœ— ${conn.provider}/${conn.name || conn.email || conn.id} β€” ` + + `${LOG_PREFIX} βœ— ${conn.provider}/${getConnectionLogLabel(conn)} β€” ` + `Refresh token is permanently invalid (${result.error}). ` + `Connection deactivated. Re-authenticate to restore.` ); @@ -326,12 +329,12 @@ async function checkConnection(conn) { } await updateProviderConnection(conn.id, updateData); - log(`${LOG_PREFIX} βœ“ ${conn.provider}/${conn.name || conn.email || conn.id} refreshed`); + log(`${LOG_PREFIX} βœ“ ${conn.provider}/${getConnectionLogLabel(conn)} refreshed`); } else { const updateData = buildRefreshFailureUpdate(conn, now); await updateProviderConnection(conn.id, updateData); logWarn( - `${LOG_PREFIX} βœ— ${conn.provider}/${conn.name || conn.email || conn.id} refresh failed` + + `${LOG_PREFIX} βœ— ${conn.provider}/${getConnectionLogLabel(conn)} refresh failed` + (conn.testStatus === "expired" ? ` (${updateData.expiredRetryCount}/${EXPIRED_RETRY_MAX} expired retries used)` : "") diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index 334e2ea7..20206327 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -28,6 +28,7 @@ import { serializePayloadForStorage, } from "../logPayloads"; import { getCallLogMaxEntries, getCallLogRetentionDays } from "../logEnv"; +import { pickMaskedDisplayValue } from "@/shared/utils/maskEmail"; type JsonRecord = Record; @@ -159,7 +160,7 @@ async function resolveAccountName(connectionId: string | null | undefined) { const connections = await getProviderConnections(); const conn = connections.find((item) => item.id === connectionId); if (conn) { - account = conn.name || conn.email || account; + account = pickMaskedDisplayValue([conn.name, conn.email], account); } } catch { // Best-effort lookup only. diff --git a/src/shared/utils/maskEmail.ts b/src/shared/utils/maskEmail.ts index eb61f591..e992d7dd 100644 --- a/src/shared/utils/maskEmail.ts +++ b/src/shared/utils/maskEmail.ts @@ -23,14 +23,36 @@ export function maskEmail(email: string | null | undefined, visibleChars = 2): s // If username is too short to mask meaningfully, return as-is if (username.length <= visibleChars) return email; - const maskedUser = - username.slice(0, visibleChars) + "*".repeat(username.length - visibleChars); + const maskedUser = username.slice(0, visibleChars) + "*".repeat(username.length - visibleChars); // Mask domain name: keep first char, mask the rest const maskedDomain = - domainName.length > 1 - ? domainName.slice(0, 1) + "*".repeat(domainName.length - 1) - : domainName; + domainName.length > 1 ? domainName.slice(0, 1) + "*".repeat(domainName.length - 1) : domainName; return `${maskedUser}@${maskedDomain}${tld}`; } + +/** + * Masks the value only when it looks like an email address. + * Useful for fields like `name` that may be normalized to the raw email. + */ +export function maskEmailLikeValue(value: string | null | undefined, visibleChars = 2): string { + if (!value) return ""; + const trimmed = value.trim(); + if (!trimmed) return ""; + return trimmed.includes("@") ? maskEmail(trimmed, visibleChars) : trimmed; +} + +/** + * Returns the first non-empty display value, masking it if it contains an email. + */ +export function pickMaskedDisplayValue( + values: Array, + fallback = "" +): string { + for (const value of values) { + const masked = maskEmailLikeValue(value); + if (masked) return masked; + } + return fallback; +} diff --git a/tests/e2e/api.spec.ts b/tests/e2e/api.spec.ts index 17070916..e358fe62 100644 --- a/tests/e2e/api.spec.ts +++ b/tests/e2e/api.spec.ts @@ -16,11 +16,15 @@ test.describe("API Health Checks", () => { expect(Array.isArray(body.data)).toBe(true); }); - test("GET /api/providers returns provider list", async ({ request }) => { + test("GET /api/providers returns provider list or requires auth", async ({ request }) => { const res = await request.get("/api/providers"); - expect(res.ok()).toBeTruthy(); - const body = await res.json(); - expect(body).toHaveProperty("connections"); - expect(Array.isArray(body.connections)).toBe(true); + // In CI with auth enabled, 401 is acceptable β€” endpoint is reachable + if (res.ok()) { + const body = await res.json(); + expect(body).toHaveProperty("connections"); + expect(Array.isArray(body.connections)).toBe(true); + } else { + expect([401, 403, 307]).toContain(res.status()); + } }); }); diff --git a/tests/unit/combo-test-health.test.mjs b/tests/unit/combo-test-health.test.mjs index 19de5792..6b764c97 100644 --- a/tests/unit/combo-test-health.test.mjs +++ b/tests/unit/combo-test-health.test.mjs @@ -5,12 +5,24 @@ const { buildComboTestRequestBody, extractComboTestResponseText } = await import("../../src/lib/combos/testHealth.ts"); test("combo test helper builds a realistic smoke payload", () => { - const body = buildComboTestRequestBody("openrouter/openai/gpt-5.4"); + const originalRandom = Math.random; + let callCount = 0; + let body; + try { + Math.random = () => { + callCount += 1; + return callCount === 1 ? 0.4680222223 : 0.2677; + }; + + body = buildComboTestRequestBody("openrouter/openai/gpt-5.4"); + } finally { + Math.random = originalRandom; + } assert.equal(body.model, "openrouter/openai/gpt-5.4"); - assert.equal(body.messages[0].content, "Reply with OK only."); - assert.equal(body.max_tokens, 64); - assert.equal(body.temperature, 0); + assert.equal(body.messages[0].content, "Calculate 52122+34093, and reply with the result only."); + assert.equal(body.max_tokens, 2048); + assert.equal("temperature" in body, false); assert.equal(body.stream, false); }); diff --git a/tests/unit/combo-test-route.test.mjs b/tests/unit/combo-test-route.test.mjs index f2da62b1..39367a88 100644 --- a/tests/unit/combo-test-route.test.mjs +++ b/tests/unit/combo-test-route.test.mjs @@ -88,6 +88,8 @@ test("combo test route marks a model healthy only when it returns assistant text await createTestCombo(); const fetchCalls = []; + const originalRandom = Math.random; + let callCount = 0; globalThis.fetch = async (url, init = {}) => { fetchCalls.push({ url: String(url), init }); return new Response( @@ -108,7 +110,16 @@ test("combo test route marks a model healthy only when it returns assistant text ); }; - const response = await route.POST(makeRequest()); + let response; + try { + Math.random = () => { + callCount += 1; + return callCount === 1 ? 0.4680222223 : 0.2677; + }; + response = await route.POST(makeRequest()); + } finally { + Math.random = originalRandom; + } const body = await response.json(); const forwardedBody = JSON.parse(fetchCalls[0].init.body); @@ -119,9 +130,12 @@ test("combo test route marks a model healthy only when it returns assistant text assert.equal(fetchCalls[0].init.headers["X-OmniRoute-No-Cache"], "true"); assert.match(fetchCalls[0].init.headers["X-Request-Id"], /^combo-test-/); assert.equal(forwardedBody.model, "openrouter/openai/gpt-5.4"); - assert.equal(forwardedBody.messages[0].content, "Reply with OK only."); - assert.equal(forwardedBody.max_tokens, 64); - assert.equal(forwardedBody.temperature, 0); + assert.equal( + forwardedBody.messages[0].content, + "Calculate 52122+34093, and reply with the result only." + ); + assert.equal(forwardedBody.max_tokens, 2048); + assert.equal("temperature" in forwardedBody, false); assert.equal(body.resolvedBy, "openrouter/openai/gpt-5.4"); assert.equal(body.results[0].status, "ok"); assert.equal(body.results[0].responseText, "OK"); diff --git a/tests/unit/compliance-index.test.mjs b/tests/unit/compliance-index.test.mjs index a0575f0a..9d17f859 100644 --- a/tests/unit/compliance-index.test.mjs +++ b/tests/unit/compliance-index.test.mjs @@ -178,8 +178,12 @@ test("cleanupExpiredLogs removes stale rows across all log tables and records an deletedRequestDetailLogs: 1, deletedAuditLogs: 1, deletedMcpAuditLogs: 1, + trimmedCallLogs: 0, + trimmedProxyLogs: 0, appRetentionDays: 10, callRetentionDays: 5, + callLogsMaxRows: result.callLogsMaxRows, + proxyLogsMaxRows: result.proxyLogsMaxRows, }); assert.equal(usageCount, 1); assert.equal(callCount, 1); @@ -216,7 +220,11 @@ test("cleanupExpiredLogs tolerates missing tables and logAuditEvent failures wit deletedRequestDetailLogs: 0, deletedAuditLogs: 0, deletedMcpAuditLogs: 0, + trimmedCallLogs: 0, + trimmedProxyLogs: 0, appRetentionDays: 10, callRetentionDays: 5, + callLogsMaxRows: result.callLogsMaxRows, + proxyLogsMaxRows: result.proxyLogsMaxRows, }); }); diff --git a/tests/unit/log-retention.test.mjs b/tests/unit/log-retention.test.mjs index 7f4bc2b9..bf9510f1 100644 --- a/tests/unit/log-retention.test.mjs +++ b/tests/unit/log-retention.test.mjs @@ -8,6 +8,8 @@ const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-log-reten process.env.DATA_DIR = TEST_DATA_DIR; process.env.APP_LOG_RETENTION_DAYS = "2"; process.env.CALL_LOG_RETENTION_DAYS = "1"; +process.env.CALL_LOGS_TABLE_MAX_ROWS = "5"; +process.env.PROXY_LOGS_TABLE_MAX_ROWS = "5"; const core = await import("../../src/lib/db/core.ts"); const compliance = await import("../../src/lib/compliance/index.ts"); @@ -132,3 +134,56 @@ test("cleanupExpiredLogs uses separate APP and CALL retention windows", () => { assert.equal(db.prepare("SELECT COUNT(*) AS cnt FROM audit_log").get().cnt, 2); assert.equal(db.prepare("SELECT COUNT(*) AS cnt FROM mcp_tool_audit").get().cnt, 1); }); + +test("cleanupExpiredLogs enforces row count limits", () => { + compliance.initAuditLog(); + const db = core.getDbInstance(); + + const now = new Date().toISOString(); + + for (let i = 0; i < 10; i++) { + db.prepare( + "INSERT INTO call_logs (id, timestamp, method, path, status, model, provider, account, duration, tokens_in, tokens_out, has_pipeline_details) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ).run( + `call-${i}`, + now, + "POST", + "/v1/chat/completions", + 200, + "model", + "provider", + "-", + 1, + 1, + 1, + 0 + ); + } + + for (let i = 0; i < 10; i++) { + db.prepare( + "INSERT INTO proxy_logs (id, timestamp, status, level, latency_ms) VALUES (?, ?, ?, ?, ?)" + ).run(`proxy-${i}`, now, "success", "direct", 1); + } + + assert.equal(db.prepare("SELECT COUNT(*) AS cnt FROM call_logs").get().cnt, 10); + assert.equal(db.prepare("SELECT COUNT(*) AS cnt FROM proxy_logs").get().cnt, 10); + + const result = compliance.cleanupExpiredLogs(); + + assert.equal(result.trimmedCallLogs, 5); + assert.equal(result.trimmedProxyLogs, 5); + assert.equal(result.callLogsMaxRows, 5); + assert.equal(result.proxyLogsMaxRows, 5); + + assert.equal(db.prepare("SELECT COUNT(*) AS cnt FROM call_logs").get().cnt, 5); + assert.equal(db.prepare("SELECT COUNT(*) AS cnt FROM proxy_logs").get().cnt, 5); +}); + +test("getCallLogsTableMaxRows returns configured value", async () => { + const { getCallLogsTableMaxRows, getProxyLogsTableMaxRows } = + await import("../../src/lib/logEnv.ts"); + + assert.equal(getCallLogsTableMaxRows(), 5); + assert.equal(getProxyLogsTableMaxRows(), 5); +}); diff --git a/tests/unit/mask-email.test.mjs b/tests/unit/mask-email.test.mjs index 64543544..90ecaaa2 100644 --- a/tests/unit/mask-email.test.mjs +++ b/tests/unit/mask-email.test.mjs @@ -1,6 +1,10 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { maskEmail } from "../../src/shared/utils/maskEmail.ts"; +import { + maskEmail, + maskEmailLikeValue, + pickMaskedDisplayValue, +} from "../../src/shared/utils/maskEmail.ts"; describe("maskEmail", () => { it("masks standard email correctly", () => { @@ -48,4 +52,17 @@ describe("maskEmail", () => { const result = maskEmail("hello@example.com", 3); assert.ok(result.startsWith("hel"), `Expected to start with 'hel', got: ${result}`); }); + + it("masks email-like values stored in generic labels", () => { + assert.equal(maskEmailLikeValue("person@example.com"), "pe****@e******.com"); + assert.equal(maskEmailLikeValue("Work Account"), "Work Account"); + }); + + it("picks the first non-empty masked display value", () => { + assert.equal( + pickMaskedDisplayValue(["", "person@example.com", "fallback"], "fallback"), + "pe****@e******.com" + ); + assert.equal(pickMaskedDisplayValue([null, "Workspace"], "fallback"), "Workspace"); + }); }); diff --git a/tests/unit/model-sync-route.test.mjs b/tests/unit/model-sync-route.test.mjs index 629c2258..e7e1706c 100644 --- a/tests/unit/model-sync-route.test.mjs +++ b/tests/unit/model-sync-route.test.mjs @@ -435,7 +435,7 @@ test("model sync route records added, removed, and updated model diffs with fall assert.equal(logs.length, 1); assert.equal(logs[0].status, 200); assert.equal(logs[0].provider, "openrouter"); - assert.equal(logs[0].account, "sync@example.com"); + assert.ok(logs[0].account.includes("**"), `Expected masked email, got: ${logs[0].account}`); }); test("model sync route accepts external API-key auth, forwards cookies, filters built-ins, and syncs aliases", async () => { diff --git a/tests/unit/provider-models-management-route.test.mjs b/tests/unit/provider-models-management-route.test.mjs new file mode 100644 index 00000000..6926950f --- /dev/null +++ b/tests/unit/provider-models-management-route.test.mjs @@ -0,0 +1,108 @@ +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-provider-model-management-route-") +); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const providerModelsRoute = await import("../../src/app/api/provider-models/route.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function buildPatchRequest(url, body) { + return new Request(url, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("provider-models PATCH updates hidden flag for custom models", async () => { + await modelsDb.addCustomModel("openai", "gpt-test", "GPT Test", "manual", "chat-completions", [ + "chat", + ]); + + const response = await providerModelsRoute.PATCH( + buildPatchRequest("http://localhost/api/provider-models?provider=openai&modelId=gpt-test", { + isHidden: true, + }) + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.ok, true); + + const models = await modelsDb.getCustomModels("openai"); + assert.equal(models.find((model) => model.id === "gpt-test")?.isHidden, true); +}); + +test("provider-models PATCH persists visibility overrides for catalog models", async () => { + const response = await providerModelsRoute.PATCH( + buildPatchRequest( + "http://localhost/api/provider-models?provider=claude&modelId=claude-sonnet-4-6", + { isHidden: true } + ) + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.ok, true); + + const overrides = modelsDb.getModelCompatOverrides("claude"); + assert.equal(overrides.find((model) => model.id === "claude-sonnet-4-6")?.isHidden, true); +}); + +test("provider-models PATCH supports bulk visibility updates", async () => { + await providerModelsRoute.PATCH( + buildPatchRequest("http://localhost/api/provider-models?provider=claude", { + isHidden: true, + modelIds: ["claude-opus-4-6", "claude-sonnet-4-6"], + }) + ); + + const response = await providerModelsRoute.PATCH( + buildPatchRequest("http://localhost/api/provider-models?provider=claude", { + isHidden: false, + modelIds: ["claude-opus-4-6", "claude-sonnet-4-6"], + }) + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.updated, 2); + + const overrides = modelsDb.getModelCompatOverrides("claude"); + assert.equal(overrides.find((model) => model.id === "claude-opus-4-6")?.isHidden, false); + assert.equal(overrides.find((model) => model.id === "claude-sonnet-4-6")?.isHidden, false); +}); + +test("provider-models PATCH validates required fields", async () => { + const response = await providerModelsRoute.PATCH( + buildPatchRequest("http://localhost/api/provider-models?provider=claude", { + modelIds: ["claude-sonnet-4-6"], + }) + ); + const body = await response.json(); + + assert.equal(response.status, 400); + assert.equal(body.error.message, "isHidden boolean is required"); +});