Release v3.6.0 (#1109)
Some checks are pending
CI / Integration Tests (push) Blocked by required conditions
CI / Security Tests (push) Blocked by required conditions
CI / i18n Validation (push) Blocked by required conditions
CI / Lint (push) Waiting to run
CI / Build language matrix (push) Waiting to run
CI / PR Test Policy (push) Waiting to run
CI / Advanced Security Scans (push) Waiting to run
CI / Build (push) Waiting to run
CI / Build-1 (push) Waiting to run
CI / Unit Tests (push) Blocked by required conditions
CI / Unit Tests-1 (push) Blocked by required conditions
CI / Coverage (push) Blocked by required conditions
CI / SonarQube (push) Blocked by required conditions
CI / PR Coverage Comment (push) Blocked by required conditions
CI / E2E Tests (1/4) (push) Blocked by required conditions
CI / E2E Tests (2/4) (push) Blocked by required conditions
CI / E2E Tests (3/4) (push) Blocked by required conditions
CI / E2E Tests (4/4) (push) Blocked by required conditions
CI / CI Dashboard (push) Blocked by required conditions
Publish to Docker Hub / Build and Push Docker (multi-arch) (push) Waiting to run

* chore: create release/v3.6.0 branch

* fix: add row count limits to prevent DB bloat and handle HTML error responses (#1104)

Integrated into release/v3.6.0

* fix combo smoke test payload for thinking models (#1105)

Integrated into release/v3.6.0

* fix: improve Android/Termux ARM64 support for better-sqlite3 (#1107)

Integrated into release/v3.6.0

* chore: finalize CHANGELOG and sync versions for v3.6.0

* fix(tests): align CI tests with v3.6.0 changes

- compliance: match new cleanupExpiredLogs return shape (trimmed/maxRows)
- model-sync: accept masked email in account field
- e2e: allow 401/403/307 for auth-protected /api/providers endpoint

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com>
Co-authored-by: Randi <55005611+rdself@users.noreply.github.com>
Co-authored-by: Suhayli <73960279+Suhay1i@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza 2026-04-10 09:56:42 -03:00 committed by GitHub
parent 4cd43f9c93
commit 37cc63e493
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 953 additions and 100 deletions

View file

@ -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. `<!DOCTYPE html>`) 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

View file

@ -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,

View file

@ -1,6 +1,6 @@
{
"name": "omniroute-desktop",
"version": "3.5.9",
"version": "3.6.0",
"description": "OmniRoute Desktop Application",
"main": "main.js",
"author": {

View file

@ -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

View file

@ -2017,21 +2017,31 @@ export async function handleChatCore({
try {
responseBody = rawBody ? JSON.parse(rawBody) : {};
} catch {
const isHtmlResponse =
rawBody && typeof rawBody === "string" && /^\s*(?:\s|<!|<[a-zA-Z]|<\?xml)/.test(rawBody);
appendRequestLog({
model,
provider,
connectionId,
status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`,
}).catch(() => {});
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);
}
}

View file

@ -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",

4
package-lock.json generated
View file

@ -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": [

View file

@ -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": {

View file

@ -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}`);
}

View file

@ -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",
});

View file

@ -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<string, string>;
compatByProtocol?: CompatByProtocolMap;
isHidden?: boolean;
};
type CompatModelRow = {
@ -72,6 +73,7 @@ type CompatModelRow = {
supportedEndpoints?: string[];
normalizeToolCallId?: boolean;
preserveOpenAIDeveloperRole?: boolean;
isHidden?: boolean;
upstreamHeaders?: Record<string, string>;
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, unknown>) => string) & {
has?: (key: string) => boolean;
},
key: string,
fallback: string,
values?: Record<string, unknown>
): 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<string, string>;
compatDisabled?: boolean;
onToggleHidden?: (modelId: string, hidden: boolean) => Promise<void>;
togglingHidden?: boolean;
}
interface PassthroughModelsSectionProps {
@ -327,6 +368,11 @@ interface PassthroughModelsSectionProps {
}
) => Promise<void>;
compatSavingModelId?: string;
isModelHidden: (modelId: string) => boolean;
onToggleHidden: (modelId: string, hidden: boolean) => Promise<void>;
onBulkToggleHidden: (modelIds: string[], hidden: boolean) => Promise<void>;
bulkTogglePending?: boolean;
togglingModelId?: string | null;
}
interface CustomModelsSectionProps {
@ -366,10 +412,16 @@ interface CompatibleModelsSectionProps {
normalizeToolCallId?: boolean;
preserveDeveloperRole?: boolean;
preserveOpenAIDeveloperRole?: boolean;
isHidden?: boolean;
}
) => Promise<void>;
compatSavingModelId?: string;
onModelsChanged?: () => void;
isModelHidden: (modelId: string) => boolean;
onToggleHidden: (modelId: string, hidden: boolean) => Promise<void>;
onBulkToggleHidden: (modelIds: string[], hidden: boolean) => Promise<void>;
bulkTogglePending?: boolean;
togglingModelId?: string | null;
}
interface CooldownTimerProps {
@ -860,6 +912,9 @@ export default function ProviderDetailPage() {
const [compatSavingModelId, setCompatSavingModelId] = useState<string | null>(null);
const [modelFilter, setModelFilter] = useState("");
const [togglingModelId, setTogglingModelId] = useState<string | null>(null);
const [bulkVisibilityAction, setBulkVisibilityAction] = useState<"select" | "deselect" | null>(
null
);
const [applyingCodexAuthId, setApplyingCodexAuthId] = useState<string | null>(null);
const [exportingCodexAuthId, setExportingCodexAuthId] = useState<string | null>(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<void> => {
const handleToggleModelHidden = async (
providerKey: string,
modelId: string,
hidden: boolean
): Promise<void> => {
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<void> => {
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 && (
<button
@ -2046,6 +2139,15 @@ export default function ProviderDetailPage() {
compatSavingModelId={compatSavingModelId}
onModelsChanged={fetchProviderModelMeta}
allowImport={compatibleSupportsModelImport}
isModelHidden={effectiveModelHidden}
onToggleHidden={(modelId, hidden) =>
handleToggleModelHidden(providerStorageAlias, modelId, hidden)
}
onBulkToggleHidden={(modelIds, hidden) =>
handleBulkToggleModelHidden(providerStorageAlias, modelIds, hidden)
}
bulkTogglePending={bulkVisibilityAction !== null}
togglingModelId={togglingModelId}
/>
</div>
);
@ -2083,6 +2185,15 @@ export default function ProviderDetailPage() {
getUpstreamHeadersRecord={getUpstreamHeadersRecordForModel}
saveModelCompatFlags={saveModelCompatFlags}
compatSavingModelId={compatSavingModelId}
isModelHidden={effectiveModelHidden}
onToggleHidden={(modelId, hidden) =>
handleToggleModelHidden(providerStorageAlias, modelId, hidden)
}
onBulkToggleHidden={(modelIds, hidden) =>
handleBulkToggleModelHidden(providerStorageAlias, modelIds, hidden)
}
bulkTogglePending={bulkVisibilityAction !== null}
togglingModelId={togglingModelId}
/>
</div>
);
@ -2115,31 +2226,43 @@ export default function ProviderDetailPage() {
</div>
);
}
const modelsWithVisibility = models.map((model) => ({
...model,
isHidden: effectiveModelHidden(model.id),
}));
const filteredModels = modelFilter
? models.filter((m) => m.id.toLowerCase().includes(modelFilter.toLowerCase()))
: models;
const activeCount = models.filter((m) => !m.isHidden).length;
? modelsWithVisibility.filter((m) => m.id.toLowerCase().includes(modelFilter.toLowerCase()))
: modelsWithVisibility;
const activeCount = modelsWithVisibility.filter((m) => !m.isHidden).length;
const hiddenFilteredCount = filteredModels.filter((m) => m.isHidden).length;
const visibleFilteredCount = filteredModels.length - hiddenFilteredCount;
return (
<div>
{importButton}
{models.length > 0 && (
<div className="flex items-center gap-2 mb-3">
<div className="relative flex-1 max-w-sm">
<span className="material-symbols-outlined absolute left-2 top-1/2 -translate-y-1/2 text-[15px] text-text-muted pointer-events-none">
search
</span>
<input
type="text"
value={modelFilter}
onChange={(e) => setModelFilter(e.target.value)}
placeholder={t("filterModels") || "Filter models…"}
className="w-full pl-7 pr-3 py-1.5 text-xs rounded-lg border border-border bg-sidebar/50 focus:outline-none focus:ring-1 focus:ring-primary text-text-main placeholder:text-text-muted"
/>
</div>
<span className="text-xs text-text-muted whitespace-nowrap">
{activeCount}/{models.length} {t("modelsActive") || "active"}
</span>
</div>
{modelsWithVisibility.length > 0 && (
<ModelVisibilityToolbar
t={t}
filterValue={modelFilter}
onFilterChange={setModelFilter}
activeCount={activeCount}
totalCount={modelsWithVisibility.length}
onSelectAll={() =>
handleBulkToggleModelHidden(
providerId,
filteredModels.map((model) => model.id),
false
)
}
onDeselectAll={() =>
handleBulkToggleModelHidden(
providerId,
filteredModels.map((model) => model.id),
true
)
}
selectAllDisabled={hiddenFilteredCount === 0 || bulkVisibilityAction !== null}
deselectAllDisabled={visibleFilteredCount === 0 || bulkVisibilityAction !== null}
/>
)}
<div className="flex flex-wrap gap-3">
{filteredModels.map((model) => {
@ -2157,14 +2280,18 @@ export default function ProviderDetailPage() {
getUpstreamHeadersRecord={(p) => getUpstreamHeadersRecordForModel(model.id, p)}
saveModelCompatFlags={saveModelCompatFlags}
compatDisabled={compatSavingModelId === model.id}
onToggleHidden={handleToggleModelHidden}
onToggleHidden={(modelId, hidden) =>
handleToggleModelHidden(providerId, modelId, hidden)
}
togglingHidden={togglingModelId === model.id}
/>
);
})}
{filteredModels.length === 0 && modelFilter && (
<p className="text-sm text-text-muted py-2">
{t("noModelsMatch") || `No models match "${modelFilter}"`}
{providerText(t, "noModelsMatch", `No models match "${modelFilter}"`, {
filter: modelFilter,
})}
</p>
)}
</div>
@ -2493,7 +2620,7 @@ export default function ProviderDetailPage() {
setProxyTarget({
level: "key",
id: conn.id,
label: conn.name || conn.email || conn.id,
label: pickMaskedDisplayValue([conn.name, conn.email], conn.id),
})
}
hasProxy={!!connProxyMap[conn.id]?.proxy}
@ -2602,7 +2729,7 @@ export default function ProviderDetailPage() {
setProxyTarget({
level: "key",
id: conn.id,
label: conn.name || conn.email || conn.id,
label: pickMaskedDisplayValue([conn.name, conn.email], conn.id),
})
}
hasProxy={!!connProxyMap[conn.id]?.proxy}
@ -2981,7 +3108,11 @@ function ModelRow({
onClick={() => onToggleHidden(model.id, !isHidden)}
disabled={togglingHidden}
className="rounded p-0.5 text-text-muted hover:bg-sidebar hover:text-primary disabled:opacity-40 disabled:cursor-not-allowed"
title={isHidden ? (t("showModel") || "Show model") : (t("hideModel") || "Hide model")}
title={
isHidden
? providerText(t, "showModel", "Show model")
: providerText(t, "hideModel", "Hide model")
}
>
<span className="material-symbols-outlined text-sm">
{isHidden ? "visibility_off" : "visibility"}
@ -3020,6 +3151,71 @@ ModelRow.propTypes = {
compatDisabled: PropTypes.bool,
};
function ModelVisibilityToolbar({
t,
filterValue,
onFilterChange,
activeCount,
totalCount,
onSelectAll,
onDeselectAll,
selectAllDisabled,
deselectAllDisabled,
}: {
t: ((key: string, values?: Record<string, unknown>) => string) & {
has?: (key: string) => boolean;
};
filterValue: string;
onFilterChange: (value: string) => void;
activeCount: number;
totalCount: number;
onSelectAll: () => void;
onDeselectAll: () => void;
selectAllDisabled?: boolean;
deselectAllDisabled?: boolean;
}) {
return (
<div className="mb-3 flex flex-wrap items-center gap-2">
<div className="relative min-w-[220px] flex-1">
<span className="material-symbols-outlined pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-[15px] text-text-muted">
search
</span>
<input
type="text"
value={filterValue}
onChange={(e) => onFilterChange(e.target.value)}
placeholder={providerText(t, "filterModels", "Filter models…")}
className="w-full rounded-lg border border-border bg-sidebar/50 py-1.5 pl-7 pr-3 text-xs text-text-main placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<button
onClick={onSelectAll}
disabled={selectAllDisabled}
className="flex items-center gap-1.5 rounded-lg border border-border bg-transparent px-2.5 py-1 text-[12px] text-text-main disabled:cursor-not-allowed disabled:opacity-50"
title={providerText(t, "selectAllModels", "Select all")}
>
<span className="material-symbols-outlined text-[16px]">done_all</span>
<span>{providerText(t, "selectAllModels", "Select all")}</span>
</button>
<button
onClick={onDeselectAll}
disabled={deselectAllDisabled}
className="flex items-center gap-1.5 rounded-lg border border-border bg-transparent px-2.5 py-1 text-[12px] text-text-main disabled:cursor-not-allowed disabled:opacity-50"
title={providerText(t, "deselectAllModels", "Deselect all")}
>
<span className="material-symbols-outlined text-[16px]">remove_done</span>
<span>{providerText(t, "deselectAllModels", "Deselect all")}</span>
</button>
<span className="whitespace-nowrap text-xs text-text-muted">
{providerText(t, "modelsActiveCount", "{active}/{total} active", {
active: activeCount,
total: totalCount,
})}
</span>
</div>
);
}
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 && (
<div className="flex flex-col gap-3">
{allModels.map(({ modelId, fullModel, alias }) => (
<ModelVisibilityToolbar
t={t}
filterValue={modelFilter}
onFilterChange={setModelFilter}
activeCount={activeCount}
totalCount={allModels.length}
onSelectAll={() =>
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 }) => (
<PassthroughModelRow
key={fullModel as string}
modelId={modelId}
fullModel={fullModel}
isHidden={isHidden}
copied={copied}
onCopy={onCopy}
onDeleteAlias={() => 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 && (
<p className="py-2 text-sm text-text-muted">
{providerText(t, "noModelsMatch", `No models match "${modelFilter}"`, {
filter: modelFilter,
})}
</p>
)}
</div>
)}
</div>
@ -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 (
<div className="flex gap-0 rounded-lg border border-border p-3 hover:bg-sidebar/50">
<div
className={`flex gap-0 rounded-lg border border-border p-3 transition-opacity hover:bg-sidebar/50 ${
isHidden ? "opacity-50" : ""
}`}
>
<div className="flex min-w-0 flex-1 items-start gap-3">
<span className="material-symbols-outlined shrink-0 text-base text-text-muted">
<span
className="material-symbols-outlined shrink-0 text-base text-text-muted"
style={{ color: isHidden ? "var(--color-text-muted)" : undefined }}
>
smart_toy
</span>
<div className="min-w-0 flex-1">
@ -3184,6 +3440,22 @@ function PassthroughModelRow({
</div>
</div>
<div className="flex shrink-0 items-center gap-1 self-start">
{onToggleHidden && (
<button
onClick={() => onToggleHidden(modelId, !isHidden)}
disabled={togglingHidden}
className="rounded p-0.5 text-text-muted hover:bg-sidebar hover:text-primary disabled:cursor-not-allowed disabled:opacity-40"
title={
isHidden
? providerText(t, "showModel", "Show model")
: providerText(t, "hideModel", "Hide model")
}
>
<span className="material-symbols-outlined text-sm">
{isHidden ? "visibility_off" : "visibility"}
</span>
</button>
)}
<ModelCompatPopover
t={t}
effectiveModelNormalize={(p) => 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<string, string>) =>
@ -3935,11 +4224,33 @@ function CompatibleModelsSection({
{allModels.length > 0 && (
<div className="flex flex-col gap-3">
{allModels.map(({ modelId, alias }) => (
<ModelVisibilityToolbar
t={t}
filterValue={modelFilter}
onFilterChange={setModelFilter}
activeCount={activeCount}
totalCount={allModels.length}
onSelectAll={() =>
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 }) => (
<PassthroughModelRow
key={`${providerStorageAlias}:${modelId}`}
modelId={modelId}
fullModel={`${providerDisplayAlias}/${modelId}`}
isHidden={isHidden}
copied={copied}
onCopy={onCopy}
onDeleteAlias={() => 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 && (
<p className="py-2 text-sm text-text-muted">
{providerText(t, "noModelsMatch", `No models match "${modelFilter}"`, {
filter: modelFilter,
})}
</p>
)}
</div>
)}
</div>
@ -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<string[]>([]);
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 && (
<div className="bg-sidebar/50 p-3 rounded-lg">
<p className="text-sm text-text-muted mb-1">{t("email")}</p>
<p className="font-medium" title={connection.email}>
{maskEmail(connection.email)}
</p>
<div className="flex items-center gap-2">
<p className="font-medium" title={showEmail ? connection.email : undefined}>
{showEmail ? connection.email : maskEmail(connection.email)}
</p>
<button
type="button"
onClick={() => setShowEmail((current) => !current)}
className="rounded p-1 text-text-muted hover:bg-sidebar hover:text-primary"
title={showEmail ? "Hide email" : "Show email"}
>
<span className="material-symbols-outlined text-sm">
{showEmail ? "visibility_off" : "visibility"}
</span>
</button>
</div>
</div>
)}
{isOAuth && (

View file

@ -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() {
<div>
<p className="text-sm font-medium text-text-main">Log retention policy</p>
<p className="text-xs text-text-muted">
Request logs follow <code>CALL_LOG_RETENTION_DAYS</code>. Application and audit logs
follow <code>APP_LOG_RETENTION_DAYS</code>.
Request logs retain up to <code>CALL_LOGS_TABLE_MAX_ROWS</code> rows (default:
100,000). Proxy logs retain up to <code>PROXY_LOGS_TABLE_MAX_ROWS</code> rows. Older
entries auto-deleted.
</p>
</div>
<div className="flex items-center gap-2">
@ -383,6 +388,9 @@ export default function SystemStorageTab() {
<Badge variant="default" size="sm">
App {storageHealth.retentionDays.app}d
</Badge>
<Badge variant="default" size="sm">
{storageHealth.tableMaxRows?.callLogs?.toLocaleString() || "100K"} rows
</Badge>
</div>
</div>
</div>

View file

@ -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() {
</div>
<div className="min-w-0">
<div className="text-[13px] font-semibold text-text-main truncate">
{conn.name || conn.displayName || conn.email || config.label}
{pickMaskedDisplayValue(
[conn.name, conn.displayName, conn.email],
config.label
)}
</div>
<div className="flex items-center gap-1.5 mt-1 min-h-5">
<span

View file

@ -18,6 +18,21 @@ import { isAuthenticated } from "@/shared/utils/apiAuth";
import { providerModelMutationSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
function normalizeRequestedModelIds(
searchParams: URLSearchParams,
body: Record<string, unknown>
): 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=<id>
* List custom models (all providers if no provider param)
@ -226,6 +241,85 @@ export async function PUT(request) {
}
}
/**
* PATCH /api/provider-models?provider=<id>&modelId=<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<string, unknown>)
: {};
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=<id>&model=<modelId>
*/

View file

@ -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) {

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -1,5 +1,9 @@
type JsonRecord = Record<string, unknown>;
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,
};
}

View file

@ -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,
};
}

View file

@ -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;
}

View file

@ -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)`
: "")

View file

@ -28,6 +28,7 @@ import {
serializePayloadForStorage,
} from "../logPayloads";
import { getCallLogMaxEntries, getCallLogRetentionDays } from "../logEnv";
import { pickMaskedDisplayValue } from "@/shared/utils/maskEmail";
type JsonRecord = Record<string, unknown>;
@ -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.

View file

@ -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<string | null | undefined>,
fallback = ""
): string {
for (const value of values) {
const masked = maskEmailLikeValue(value);
if (masked) return masked;
}
return fallback;
}

View file

@ -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());
}
});
});

View file

@ -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);
});

View file

@ -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");

View file

@ -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,
});
});

View file

@ -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);
});

View file

@ -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");
});
});

View file

@ -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 () => {

View file

@ -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");
});