mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-04-28 06:19:46 +00:00
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
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:
parent
4cd43f9c93
commit
37cc63e493
32 changed files with 953 additions and 100 deletions
14
CHANGELOG.md
14
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. `<!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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "omniroute-desktop",
|
||||
"version": "3.5.9",
|
||||
"version": "3.6.0",
|
||||
"description": "OmniRoute Desktop Application",
|
||||
"main": "main.js",
|
||||
"author": {
|
||||
|
|
|
|||
4
llm.txt
4
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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
4
package-lock.json
generated
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)`
|
||||
: "")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
108
tests/unit/provider-models-management-route.test.mjs
Normal file
108
tests/unit/provider-models-management-route.test.mjs
Normal 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");
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue