Compare commits

...

11 Commits

Author SHA1 Message Date
diegosouzapw
111229083c test(audio): prove synthesized chime lifecycle 2026-08-28 00:57:11 -03:00
diegosouzapw
3ba597bbd5 docs(changelog): record notification audio replacement 2026-08-28 00:57:11 -03:00
diegosouzapw
44647db82c fix(dashboard): replace unprovenanced notification audio 2026-08-28 00:57:10 -03:00
Ravi Tharuma
825f8fe425 fix(providers): honor model auto-fetch opt-in on create (#11798)
Obrigado! Validado em lote combinado (8 PRs, release/v3.8.51):

- Honra o contrato `autoFetchModels` default-off nos dois caminhos de criação de provider; zero chamadas de auto-discovery quando a flag está ausente/false.
- Roda exatamente um sync quando habilitado explicitamente (clientes API usam o sync em background server-owned; o dashboard assume quando precisa de UI de progresso).
- Testes focados verdes: `tests/unit/providers-route-model-autofetch-optin.test.ts` (node) + `useApiKeySaveSkipsFullSync.test.tsx` (vitest).
- Gates estáticos do lote OK.
2026-08-27 22:15:23 -03:00
Ravi Tharuma
cfeed516e8 fix(dashboard): make model auto-fetch order independent (#11805)
Obrigado! Validado em lote combinado (8 PRs, release/v3.8.51):

- TDD claro: implementação antiga falhava 1/5 no caso "enabled-first mixed" (postava `/sync-models?mode=sync` incorretamente); implementação corrigida passa 5/5.
- Avalia todas as conexões ativas antes de tratar o auto-fetch como habilitado, tornando o resultado independente da ordem de conexões API/DB.
- `tests/unit/ui/use-provider-models-auto-fetch.test.tsx` — verde via vitest.
- Gates estáticos do lote OK (incluindo confirmação de que o único erro de lint pré-existente no arquivo tocado apenas mudou de linha 130→137 por causa das linhas adicionadas por esta PR — sem regressão real).
2026-08-27 22:15:18 -03:00
Ravi Tharuma
d937b5229e fix(sse): preserve CLIProxyAPI wrappers per connection (#11797)
Obrigado! Validado em lote combinado (8 PRs, release/v3.8.51):

- Roteia corretamente o override `cliproxyapiMode: "claude-native"` por conexão através dos wrappers de credencial/mapeamento já existentes, evitando vazamento de credencial nativa e modelo não mapeado no caminho de passthrough.
- Regressão de wire-level cobrindo headers e body: `tests/unit/cliproxyapi-dedicated-credential-7645.test.ts` — verde, incluindo os testes irmãos `cliproxyapi-model-mapping-dispatch` e `cliproxyapi-fallback-wiring`.
- Gates estáticos do lote OK.
2026-08-27 22:15:10 -03:00
Ravi Tharuma
321935d261 fix(services): separate CLIProxyAPI health and model auth (#11811)
Obrigado! Validado em lote combinado (8 PRs, release/v3.8.51):

- Separação correta de responsabilidades: probe de saúde via `/healthz` público vs. autenticação de `/v1/models` com `settings.cliproxyapi_api_key` dedicada; `MANAGEMENT_PASSWORD` mantida estritamente no plano de gestão do CLIProxyAPI.
- Evidência RED→GREEN documentada e reproduzida: `tests/unit/services/cliproxy-health-model-auth.test.ts` — verde no lote.
- `⚠️ base-red inherited: #11449` reconhecido — não é causado por esta PR.
- Gates estáticos do lote OK.
2026-08-27 22:15:06 -03:00
Ravi Tharuma
a14a91dcfe perf(compression): avoid cloning memo misses twice (#11727)
Obrigado! Validado em lote combinado (8 PRs, release/v3.8.51):

- TDD claro: sem o fix, o teste focado falha nas asserções de contagem exata de lookup sync/async; com o fix, `tests/unit/compression/result-memo.test.ts` passa (34/34).
- Remove supressão eslint agora obsoleta (`no-unused-vars` no arquivo de teste).
- `⚠️ base-red inherited: #11449` reconhecido e verificado — não é responsabilidade desta PR (confirmado via probe-worktree do tip puro).
- Gates estáticos do lote OK.
2026-08-27 22:14:57 -03:00
Ravi Tharuma
09fa818365 fix(docker): make GHA cache exports best effort (#11806)
Obrigado! Validado em lote combinado (8 PRs, release/v3.8.51):

- Incidente real documentado com link da run do GHA (`404 BlobNotFound` no cache exporter do Azure Actions), publicação Docker Hub/GHCR já bem-sucedida.
- Regressão estática nova: inventário dos 4 escopos de cache GHA garantindo `ignore-error=true`.
- Não altera falhas de build/push — só isola falha opcional de cache.
- Gates estáticos do lote OK.
2026-08-27 22:14:53 -03:00
Ravi Tharuma
9be4fd0a0e fix(models): hide unsupported Gemini Live discovery (#11710)
Obrigado! Validado em lote combinado (8 PRs, release/v3.8.51):

- Follow-up correto de #10590/#10591 — omite modelos Gemini Live-only da descoberta dinâmica, preserva chat para modelos híbridos.
- Teste focado atualizado: `tests/unit/gemini-models-parser.test.ts` — 6/6 verde.
- Gates estáticos do lote OK (typecheck, lint sem regressão, complexity, cognitive, file-size, changelog-integrity).
2026-08-27 22:14:45 -03:00
Ravi Tharuma
a45e80af43 fix(db): skip absent compression telemetry cleanup table (#11802)
Obrigado pela correção! Validado em lote combinado (8 PRs, release/v3.8.51):

- Reprodução com imagem pública confirmada no corpo da PR (`compression_run_telemetry` ausente após fresh install).
- Reutiliza o helper `tableExists()` já existente — sem SQL cru novo.
- Testes focados: 7/7 em `tests/unit/telemetry-auto-cleanup-6848.test.ts` (verde).
- Gates estáticos do lote: typecheck, lint (228 erros pré-existentes na tip pura, confirmado via probe-worktree — zero regressão), complexity, cognitive-complexity, file-size e changelog-integrity — todos OK.
2026-08-27 22:14:42 -03:00
28 changed files with 1071 additions and 112 deletions

View File

@@ -171,7 +171,7 @@ jobs:
${{ env.IMAGE_NAME }}
${{ env.GHCR_IMAGE_NAME }}
cache-from: type=gha,scope=docker-${{ matrix.arch }}
cache-to: type=gha,scope=docker-${{ matrix.arch }},mode=max
cache-to: type=gha,scope=docker-${{ matrix.arch }},mode=max,ignore-error=true
no-cache: false
env:
DOCKER_BUILDKIT_INLINE_CACHE: 1
@@ -188,7 +188,7 @@ jobs:
${{ env.IMAGE_NAME }}
${{ env.GHCR_IMAGE_NAME }}
cache-from: type=gha,scope=docker-web-${{ matrix.arch }}
cache-to: type=gha,scope=docker-web-${{ matrix.arch }},mode=max
cache-to: type=gha,scope=docker-web-${{ matrix.arch }},mode=max,ignore-error=true
no-cache: false
env:
DOCKER_BUILDKIT_INLINE_CACHE: 1
@@ -206,7 +206,7 @@ jobs:
${{ env.IMAGE_NAME }}
${{ env.GHCR_IMAGE_NAME }}
cache-from: type=gha,scope=docker-bun-base-${{ matrix.arch }}
cache-to: type=gha,scope=docker-bun-base-${{ matrix.arch }},mode=max
cache-to: type=gha,scope=docker-bun-base-${{ matrix.arch }},mode=max,ignore-error=true
no-cache: false
env:
DOCKER_BUILDKIT_INLINE_CACHE: 1
@@ -224,7 +224,7 @@ jobs:
${{ env.IMAGE_NAME }}
${{ env.GHCR_IMAGE_NAME }}
cache-from: type=gha,scope=docker-bun-web-${{ matrix.arch }}
cache-to: type=gha,scope=docker-bun-web-${{ matrix.arch }},mode=max
cache-to: type=gha,scope=docker-bun-web-${{ matrix.arch }},mode=max,ignore-error=true
no-cache: false
env:
DOCKER_BUILDKIT_INLINE_CACHE: 1

View File

@@ -0,0 +1 @@
- Stop advertising Gemini Live-only models as supported audio endpoints until OmniRoute proxies the bidirectional Live protocol.

View File

@@ -0,0 +1 @@
- **fix(db):** Fresh installs no longer log a non-fatal `no such table: compression_run_telemetry` warning when retention cleanup runs before the lazily-created telemetry table exists ([#11802](https://github.com/diegosouzapw/OmniRoute/pull/11802)) — thanks @RaviTharuma

View File

@@ -0,0 +1 @@
- **fix(services):** embedded CLIProxyAPI lifecycle checks now use public `/healthz`, while model discovery uses the configured dedicated data-plane API key instead of the management password ([#11811](https://github.com/diegosouzapw/OmniRoute/pull/11811))

View File

@@ -0,0 +1 @@
- **fix(dashboard):** replaced the Model Lockout card's unprovenanced notification MP3 with a short, locally synthesized Web Audio chime ([#11731](https://github.com/diegosouzapw/OmniRoute/pull/11731)) — both toggles retain optional audible feedback without shipping replacement media or third-party code, and unsupported, suspended, or rejected audio contexts fail open without blocking the setting change.

View File

@@ -4539,11 +4539,6 @@
"count": 1
}
},
"tests/unit/compression/result-memo.test.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"tests/unit/compression/rtk-grouping.test.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1

View File

@@ -105,7 +105,11 @@ export async function resolveExecutorWithProxy(
"UPSTREAM_PROXY",
`${prov} routed through CLIProxyAPI (per-connection claude-native override)`
);
return getExecutor("cliproxyapi");
const [cfg, { dedicatedApiKey }] = await Promise.all([
getUpstreamProxyConfigCached(prov),
loadCliproxyapiSettings(),
]);
return resolveCliproxyapiExecutor(cfg.cliproxyapiModelMapping, dedicatedApiKey);
}
// Sibling per-connection override for Dario (#dario). Checked AFTER the

View File

@@ -4,6 +4,7 @@ import type { CompressionConfig, CompressionMode, CompressionResult } from "./ty
export const MEMO_CAP = 5_000;
const memoMap = new Map<string, CompressionResult>();
let lookupCountForTests = 0;
// Opt-IN whitelist (NOT opt-out): cache only engines proven pure + STATELESS across
// requests. Excluded on purpose: `ccr` and `session-dedup` write to the cross-request
@@ -94,6 +95,7 @@ function boundedSet(key: string, value: CompressionResult): void {
}
export function memoLookup(key: string): CompressionResult | null {
lookupCountForTests++;
const hit = memoMap.get(key);
if (!hit) return null;
// Return a clone so downstream mutation cannot corrupt the cached value.
@@ -110,4 +112,10 @@ export function memoStore(key: string, result: CompressionResult): void {
/** For tests only — clears the in-process memo store. */
export function clearMemoStore(): void {
memoMap.clear();
lookupCountForTests = 0;
}
export const resultMemoForTests = {
get lookupCount(): number {
return lookupCountForTests;
},
};

View File

@@ -332,7 +332,7 @@ function runCompression(
config: { ...options.config, memoizeCompressionResults: false },
});
memoStore(key, result);
return memoLookup(key)!;
return result;
}
if (mode === "rtk") {
return applyRtkCompression(body, {
@@ -565,7 +565,7 @@ async function runCompressionAsync(
config: { ...options.config, memoizeCompressionResults: false },
});
memoStore(key, result);
return memoLookup(key)!;
return result;
}
// Single-mode omniglyph (async-only) — resolution lives in engines/omniglyphSingleMode.ts.
if (mode === "omniglyph") return applyOmniglyphSingleMode(body, options);

Binary file not shown.

View File

@@ -1,7 +1,7 @@
// @vitest-environment jsdom
// Regression for issue #11324: adding a custom/manual model connection for a
// non-curated provider must not force a full upstream /models catalog sync
// when the caller explicitly opts out via `skipModelSync`.
// Regression for issue #11324 and the autoFetchModels opt-in contract: adding a
// connection must not force a full upstream /models catalog sync unless the
// connection explicitly enables it.
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -97,10 +97,13 @@ describe("useApiKeySave.handleSaveApiKey — full-sync opt-out (#11324)", () =>
);
const postedBody = JSON.parse((providersCall?.[1] as RequestInit).body as string);
expect(postedBody).not.toHaveProperty("skipModelSync");
expect(new Headers((providersCall?.[1] as RequestInit).headers).get("x-skip-model-sync")).toBe(
"true"
);
});
it("still auto-triggers the full /sync-models catalog fetch by default (legacy behavior preserved)", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
it("keeps the full /sync-models catalog fetch off when autoFetchModels is omitted", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => {
const url = String(input);
if (url === "/api/providers") return response(true, { connection: { id: "conn-1" } });
if (url.includes("/sync-models")) {
@@ -118,9 +121,43 @@ describe("useApiKeySave.handleSaveApiKey — full-sync opt-out (#11324)", () =>
await hookResult().handleSaveApiKey({ apiKey: "sk-test" });
});
const syncCalls = fetchMock.mock.calls.filter(([input]) =>
String(input).includes("/sync-models")
);
expect(syncCalls).toHaveLength(0);
});
it("auto-triggers one client-owned sync when autoFetchModels is true", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => {
const url = String(input);
if (url === "/api/providers") return response(true, { connection: { id: "conn-1" } });
if (url.includes("/sync-models")) {
return response(true, { syncedModels: 3, availableModelsCount: 3, models: [] });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const { hookResult, root, container } = renderApiKeySaveHook();
roots.push(root);
containers.push(container);
await act(async () => {
await hookResult().handleSaveApiKey({
apiKey: "sk-test",
providerSpecificData: { autoFetchModels: true },
});
});
const syncCalls = fetchMock.mock.calls.filter(([input]) =>
String(input).includes("/sync-models")
);
expect(syncCalls).toHaveLength(1);
const providersCall = fetchMock.mock.calls.find(
([input]) => String(input) === "/api/providers"
);
expect(new Headers((providersCall?.[1] as RequestInit).headers).get("x-skip-model-sync")).toBe(
"true"
);
});
});

View File

@@ -60,13 +60,23 @@ export function useApiKeySave({
// Issue #11324: callers that only want to add one manual model (rather than
// importing an upstream provider's entire catalog) can pass `skipModelSync: true`
// to opt out of the automatic post-save full /sync-models call. This flag is a
// client-side intent signal only — strip it before it reaches the connection
// creation payload.
// client-side intent signal only — keep it out of the persisted connection
// payload and relay it only through the non-persisted request header below.
const { skipModelSync, ...connectionFormData } = formData;
const autoFetchModels =
(
connectionFormData.providerSpecificData as
| Record<string, unknown>
| null
| undefined
)?.autoFetchModels === true;
try {
const res = await fetch("/api/providers", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
...(autoFetchModels || skipModelSync ? { "X-Skip-Model-Sync": "true" } : {}),
},
body: JSON.stringify({
provider: resolveApiKeySaveProviderId(providerId),
...connectionFormData,
@@ -82,7 +92,12 @@ export function useApiKeySave({
// Most providers sync their live catalog after connection creation. Curated-only
// providers intentionally use the registry list and must not show an import flow.
// Issue #11324: callers may also opt out explicitly via `skipModelSync`.
if (newConnection?.id && !providerUsesCuratedModelsOnly(providerId) && !skipModelSync) {
if (
newConnection?.id &&
!providerUsesCuratedModelsOnly(providerId) &&
autoFetchModels &&
!skipModelSync
) {
setShowImportModal(true);
setImportProgress({
current: 0,

View File

@@ -77,11 +77,18 @@ export function useProviderModels(providerId: string): UseProviderModelsResult {
}>;
};
if (cancelled) return;
const providerConn = connData.connections?.find(
const providerConnections = connData.connections?.filter(
(c) => (c.provider === providerId || c.id === providerId) && c.isActive !== false
);
const providerConn = providerConnections?.[0];
if (providerConn?.providerSpecificData?.autoFetchModels === true && !cancelled) {
if (
providerConn &&
providerConnections.every(
(connection) => connection.providerSpecificData?.autoFetchModels === true
) &&
!cancelled
) {
const syncRes = await fetch(
`/api/providers/${encodeURIComponent(providerConn.id)}/sync-models?mode=sync`,
{ method: "POST" }

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Button, Card, Toggle } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
@@ -23,6 +23,66 @@ const DEFAULTS: ModelLockoutSettings = {
useExponentialBackoff: true,
};
type WebkitAudioWindow = Window & {
webkitAudioContext?: typeof AudioContext;
};
function scheduleNotifyChime(context: AudioContext): void {
const oscillator = context.createOscillator();
const gain = context.createGain();
const startsAt = context.currentTime;
const endsAt = startsAt + 0.1;
// A short, locally synthesized tone avoids shipping a third-party audio asset.
oscillator.type = "sine";
oscillator.frequency.setValueAtTime(880, startsAt);
gain.gain.setValueAtTime(0.0001, startsAt);
gain.gain.exponentialRampToValueAtTime(0.045, startsAt + 0.012);
gain.gain.exponentialRampToValueAtTime(0.0001, startsAt + 0.09);
oscillator.connect(gain);
gain.connect(context.destination);
oscillator.onended = () => {
oscillator.disconnect();
gain.disconnect();
};
oscillator.start(startsAt);
oscillator.stop(endsAt);
}
function playNotifyChime(contextRef: { current: AudioContext | null }): void {
try {
if (typeof window === "undefined") return;
const AudioContextConstructor =
window.AudioContext ?? (window as WebkitAudioWindow).webkitAudioContext;
if (!AudioContextConstructor) return;
if (!contextRef.current || contextRef.current.state === "closed") {
contextRef.current = new AudioContextConstructor();
}
const context = contextRef.current;
if (context.state !== "running") {
void context
.resume()
.then(() => {
try {
scheduleNotifyChime(context);
} catch {
// Sound is optional and must never block a settings change.
}
})
.catch(() => undefined);
return;
}
scheduleNotifyChime(context);
} catch {
// Sound is optional and must never block a settings change.
}
}
function NumberField({
label,
value,
@@ -71,6 +131,7 @@ export default function ModelLockoutCard() {
const t = useTranslations("settings");
const tc = useTranslations("common");
const notify = useNotificationStore();
const notifyAudioContextRef = useRef<AudioContext | null>(null);
const [data, setData] = useState<ModelLockoutSettings>(DEFAULTS);
const [draft, setDraft] = useState<ModelLockoutSettings>(DEFAULTS);
@@ -78,6 +139,21 @@ export default function ModelLockoutCard() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(
() => () => {
const context = notifyAudioContextRef.current;
notifyAudioContextRef.current = null;
if (context && context.state !== "closed") {
try {
void context.close().catch(() => undefined);
} catch {
// Sound cleanup is optional and must never block the page from unmounting.
}
}
},
[]
);
useEffect(() => {
let mounted = true;
@@ -254,22 +330,6 @@ export default function ModelLockoutCard() {
return `${ms}ms`;
};
const notifyRef = useRef<HTMLAudioElement | null>(null);
const playNotify = useCallback(() => {
try {
if (notifyRef.current) {
notifyRef.current.pause();
notifyRef.current.currentTime = 0;
} else {
notifyRef.current = new Audio("/audio/ui-notify.mp3");
notifyRef.current.volume = 0.3;
}
void notifyRef.current.play();
} catch {
// Audio is optional.
}
}, []);
if (loading) {
return (
<Card className="p-6">
@@ -314,7 +374,7 @@ export default function ModelLockoutCard() {
checked={draft.enabled}
onChange={(checked) => {
setDraft((prev) => ({ ...prev, enabled: checked }));
playNotify();
playNotifyChime(notifyAudioContextRef);
}}
label={t("modelLockoutEnabled")}
description={t("modelLockoutEnabledDescription")}
@@ -443,7 +503,7 @@ export default function ModelLockoutCard() {
...prev,
useExponentialBackoff: checked,
}));
playNotify();
playNotifyChime(notifyAudioContextRef);
}}
label={t("modelLockoutExponentialBackoff")}
description={t("modelLockoutExponentialBackoffDescription")}

View File

@@ -48,6 +48,7 @@ import {
getModelSyncInternalBaseUrl,
} from "@/shared/services/modelSyncScheduler";
import { finalizeValidatedChatGptWebCodexSecrets } from "@omniroute/open-sse/services/chatgptWebCodexAdmin.ts";
import { isAutoFetchModelsEnabled } from "@/lib/providerModels/modelDiscovery";
import { testSingleConnection } from "./[id]/test/route";
function projectCodexAccountPoolWithRoutingQuota(
@@ -282,50 +283,56 @@ export async function POST(request: Request) {
testStatus: testStatus || "unknown",
});
// Auto-trigger model discovery for the newly created connection.
// Auto-trigger model discovery only for an explicit autoFetchModels opt-in.
// Fire-and-forget: model sync can take seconds and should NOT block the
// POST response. If it fails, we log and move on — the connection itself
// is already persisted and the user can manually trigger a sync later.
// We use a self-fetch against our own /sync-models route, forwarding the
// incoming cookies (preserves management auth) plus the internal sync
// auth header (defense in depth) and an X-Internal-Auto-Sync marker for
// log correlation.
try {
// SECURITY: use the trusted loopback/env-pinned origin, NOT
// `new URL(request.url).origin` — the latter comes from the client-
// controlled Host header, which would let a caller redirect this
// credential-bearing internal self-fetch to an arbitrary host
// (SSRF + internal-auth-header exfiltration; CodeQL js/request-forgery).
const internalOrigin = getModelSyncInternalBaseUrl();
const cookieHeader = request.headers.get("cookie") || "";
const syncHeaders: Record<string, string> = {
"Content-Type": "application/json",
"X-Internal-Auto-Sync": "true",
...(cookieHeader ? { cookie: cookieHeader } : {}),
...buildModelSyncInternalHeaders(),
};
const syncUrl = `${internalOrigin}/api/providers/${encodeURIComponent(newConnection.id)}/sync-models?mode=import`;
// Intentionally not awaited: this is async/non-blocking work.
void fetchModelSyncInternal(syncUrl, {
method: "POST",
headers: syncHeaders,
redirect: "error",
})
.then((syncRes) => {
if (!syncRes.ok) {
console.log(`[providers] Auto-sync failed for ${newConnection.id}: ${syncRes.status}`);
}
// log correlation. The dashboard skips this server-owned copy when it
// performs the same sync itself so it can render progress.
if (
isAutoFetchModelsEnabled(providerSpecificData) &&
request.headers.get("x-skip-model-sync") !== "true"
) {
try {
// SECURITY: use the trusted loopback/env-pinned origin, NOT
// `new URL(request.url).origin` — the latter comes from the client-
// controlled Host header, which would let a caller redirect this
// credential-bearing internal self-fetch to an arbitrary host
// (SSRF + internal-auth-header exfiltration; CodeQL js/request-forgery).
const internalOrigin = getModelSyncInternalBaseUrl();
const cookieHeader = request.headers.get("cookie") || "";
const syncHeaders: Record<string, string> = {
"Content-Type": "application/json",
"X-Internal-Auto-Sync": "true",
...(cookieHeader ? { cookie: cookieHeader } : {}),
...buildModelSyncInternalHeaders(),
};
const syncUrl = `${internalOrigin}/api/providers/${encodeURIComponent(newConnection.id)}/sync-models?mode=import`;
// Intentionally not awaited: this is async/non-blocking work.
void fetchModelSyncInternal(syncUrl, {
method: "POST",
headers: syncHeaders,
redirect: "error",
})
.catch((err) => {
console.log(`[providers] Auto-sync error for ${newConnection.id}:`, err?.message || err);
});
} catch (syncSetupError) {
// Defensive: if URL parsing or header construction itself throws, do
// not let it break the (already successful) POST response.
console.log(
`[providers] Auto-sync setup failed for ${newConnection.id}:`,
syncSetupError?.message || syncSetupError
);
.then((syncRes) => {
if (!syncRes.ok) {
console.log(`[providers] Auto-sync failed for ${newConnection.id}: ${syncRes.status}`);
}
})
.catch((err) => {
console.log(`[providers] Auto-sync error for ${newConnection.id}:`, err?.message || err);
});
} catch (syncSetupError) {
// Defensive: if URL parsing or header construction itself throws, do
// not let it break the (already successful) POST response.
console.log(
`[providers] Auto-sync setup failed for ${newConnection.id}:`,
syncSetupError?.message || syncSetupError
);
}
}
// Auto-test the newly created connection so `testStatus` reflects reality

View File

@@ -20,7 +20,7 @@ export async function getOrInitSupervisor(): Promise<ServiceSupervisor> {
tool: TOOL,
port: PORT,
spawnArgs: () => resolveSpawnArgs(PORT, managementKey),
healthUrl: () => `http://127.0.0.1:${PORT}/v1/models`,
healthUrl: () => `http://127.0.0.1:${PORT}/healthz`,
healthIntervalMs: 5_000,
stopTimeoutMs: 15_000,
logsBufferBytes: 5_242_880,

View File

@@ -13,6 +13,7 @@ import {
deleteAllFromTable,
deleteCallLogArtifacts,
deleteFromTableBefore,
tableExists,
type DeleteByPeriodTarget,
} from "./cleanup/usagePurge";
@@ -196,7 +197,9 @@ export async function cleanupMcpAudit(): Promise<CleanupResult> {
/**
* Clean up old config_audit_log based on retention settings.
*/
export async function cleanupConfigAudit(retentionDays = getRetentionSettings().configAudit): Promise<CleanupResult> {
export async function cleanupConfigAudit(
retentionDays = getRetentionSettings().configAudit
): Promise<CleanupResult> {
const db = getDbInstance();
const result: CleanupResult = { deleted: 0, errors: 0 };
@@ -237,7 +240,9 @@ export async function cleanupA2aEvents(): Promise<CleanupResult> {
const runResult = stmt.run(cutoffISO);
result.deleted = runResult.changes;
console.log(`[Cleanup] Deleted ${result.deleted} a2a_task_events older than ${retentionDays} days`);
console.log(
`[Cleanup] Deleted ${result.deleted} a2a_task_events older than ${retentionDays} days`
);
} catch (err: unknown) {
console.error("[Cleanup] Error cleaning a2a_task_events:", err);
result.errors++;
@@ -383,6 +388,8 @@ export async function cleanupCompressionRunTelemetry(): Promise<CleanupResult> {
const result: CleanupResult = { deleted: 0, errors: 0 };
try {
if (!tableExists("compression_run_telemetry")) return result;
const stmt = db.prepare("DELETE FROM compression_run_telemetry WHERE timestamp < ?");
const runResult = stmt.run(cutoffEpoch);
result.deleted = runResult.changes;
@@ -600,16 +607,56 @@ function isResetUsageHistoryPeriod(period: string): period is ResetUsageHistoryP
*/
const RESET_TARGETS: Array<DeleteByPeriodTarget & { resultKey: keyof ResetUsageHistoryResult }> = [
{ table: "usage_history", column: "timestamp", cutoff: "iso", resultKey: "deletedUsageHistory" },
{ table: "daily_usage_summary", column: "date", cutoff: "date", resultKey: "deletedDailySummary" },
{ table: "hourly_usage_summary", column: "date_hour", cutoff: "dateHour", resultKey: "deletedHourlySummary" },
{
table: "daily_usage_summary",
column: "date",
cutoff: "date",
resultKey: "deletedDailySummary",
},
{
table: "hourly_usage_summary",
column: "date_hour",
cutoff: "dateHour",
resultKey: "deletedHourlySummary",
},
{ table: "call_logs", column: "timestamp", cutoff: "iso", resultKey: "deletedCallLogs" },
{ table: "request_detail_logs", column: "timestamp", cutoff: "iso", resultKey: "deletedRequestDetailLogs" },
{
table: "request_detail_logs",
column: "timestamp",
cutoff: "iso",
resultKey: "deletedRequestDetailLogs",
},
{ table: "proxy_logs", column: "timestamp", cutoff: "iso", resultKey: "deletedProxyLogs" },
{ table: "relay_logs", column: "created_at", cutoff: "epochSeconds", resultKey: "deletedRelayLogs" },
{ table: "compression_analytics", column: "timestamp", cutoff: "iso", resultKey: "deletedCompressionAnalytics" },
{ table: "compression_run_telemetry", column: "timestamp", cutoff: "epochMs", resultKey: "deletedCompressionRunTelemetry" },
{ table: "routing_decisions", column: "created_at", cutoff: "iso", resultKey: "deletedRoutingDecisions" },
{ table: "quota_consumption", column: "updated_at", cutoff: "epochMs", resultKey: "deletedQuotaConsumption" },
{
table: "relay_logs",
column: "created_at",
cutoff: "epochSeconds",
resultKey: "deletedRelayLogs",
},
{
table: "compression_analytics",
column: "timestamp",
cutoff: "iso",
resultKey: "deletedCompressionAnalytics",
},
{
table: "compression_run_telemetry",
column: "timestamp",
cutoff: "epochMs",
resultKey: "deletedCompressionRunTelemetry",
},
{
table: "routing_decisions",
column: "created_at",
cutoff: "iso",
resultKey: "deletedRoutingDecisions",
},
{
table: "quota_consumption",
column: "updated_at",
cutoff: "epochMs",
resultKey: "deletedQuotaConsumption",
},
{ table: "token_ledger", column: "created_at", cutoff: "iso", resultKey: "deletedTokenLedger" },
];

View File

@@ -6,7 +6,7 @@
* - predict → "images" (Imagen image generation)
* - predictLongRunning → "videos" (Veo video generation)
* - embedContent → "embeddings"
* - bidiGenerateContent → "audio" (Live real-time audio)
* - bidiGenerateContent → ignored (Gemini Live is not proxied)
*
* Model-id heuristics refine the long-running bucket because Google exposes both
* Imagen and Veo via long-running methods on the same endpoint:
@@ -19,7 +19,7 @@
*
* This is shared by the `gemini` discovery config and the `vertex` /
* `vertex-partner` (incl. Vertex AI Express key) discovery branches, so every
* model the account can access — chat, image, video, audio and embeddings —
* supported model the account can access — chat, image, video and embeddings —
* surfaces dynamically instead of being limited to the small static registry.
*/
const METHOD_TO_ENDPOINT: Record<string, string> = {
@@ -27,7 +27,6 @@ const METHOD_TO_ENDPOINT: Record<string, string> = {
embedContent: "embeddings",
predict: "images",
predictLongRunning: "videos",
bidiGenerateContent: "audio",
generateAnswer: "chat",
};
@@ -37,6 +36,7 @@ const IGNORED_METHODS = new Set([
"createCachedContent",
"batchGenerateContent",
"asyncBatchEmbedContent",
"bidiGenerateContent",
]);
const RETIRED_GEMINI_MODEL_IDS = new Set(["gemini-3.5-flash"]);
@@ -78,6 +78,13 @@ export function parseGeminiModelsList(data: any): GeminiDiscoveryModel[] {
endpoints.add("images");
}
if (
endpoints.size === 0 &&
methods.length > 0 &&
methods.every((method) => IGNORED_METHODS.has(method))
) {
return null;
}
if (endpoints.size === 0) endpoints.add("chat");
return {
@@ -91,5 +98,8 @@ export function parseGeminiModelsList(data: any): GeminiDiscoveryModel[] {
...(m.thinking === true ? { supportsThinking: true } : {}),
} as GeminiDiscoveryModel;
})
.filter((model: GeminiDiscoveryModel) => !RETIRED_GEMINI_MODEL_IDS.has(model.id));
.filter(
(model: GeminiDiscoveryModel | null): model is GeminiDiscoveryModel =>
Boolean(model) && !RETIRED_GEMINI_MODEL_IDS.has(model.id)
);
}

View File

@@ -1,5 +1,7 @@
import { getVersionManagerTool } from "@/lib/db/versionManager";
import { getSettings } from "@/lib/db/settings";
import { markAllUnavailable } from "@/lib/db/serviceModels";
import { resolveDedicatedCliproxyapiApiKey } from "@omniroute/open-sse/handlers/chatCore/cliproxyapiCredentials";
import { registerSupervisor, getSupervisor } from "./registry";
import { ServiceSupervisor } from "./ServiceSupervisor";
import { resolveSpawnArgs as nineRouterSpawnArgs } from "./installers/ninerouter";
@@ -8,10 +10,7 @@ import {
CLIPROXY_DEFAULT_PORT,
} from "./installers/cliproxy";
import { resolveSpawnArgs as muxSpawnArgs, MUX_DEFAULT_PORT } from "./installers/mux";
import {
resolveSpawnArgs as bifrostSpawnArgs,
BIFROST_DEFAULT_PORT,
} from "./installers/bifrost";
import { resolveSpawnArgs as bifrostSpawnArgs, BIFROST_DEFAULT_PORT } from "./installers/bifrost";
import { resolveSpawnArgs as darioSpawnArgs, DARIO_DEFAULT_PORT } from "./installers/dario";
import { getOrCreateApiKey } from "./apiKey";
import { scheduleServiceModelSync, stopServiceModelSync } from "./modelSync";
@@ -59,7 +58,7 @@ const SERVICES: ServiceEntry[] = [
{
tool: "cliproxy",
port: CLIPROXY_PORT,
healthPath: "/v1/models",
healthPath: "/healthz",
healthIntervalMs: 5_000,
stopTimeoutMs: 15_000,
logsBufferBytes: 5_242_880,
@@ -128,6 +127,11 @@ export async function bootstrapEmbeddedServices(): Promise<void> {
const apiKey = cfg.needsApiKey
? await getOrCreateApiKey(cfg.tool).catch(() => "placeholder")
: "";
// CLIProxyAPI's generated key is management-only; /v1/models uses its dedicated data-plane key.
const modelSyncApiKey =
cfg.tool === "cliproxy"
? (resolveDedicatedCliproxyapiApiKey(await getSettings()) ?? "")
: apiKey;
const supervisor = new ServiceSupervisor({
tool: cfg.tool,
@@ -148,7 +152,7 @@ export async function bootstrapEmbeddedServices(): Promise<void> {
const baseUrl = `http://127.0.0.1:${cfg.port}`;
supervisor.on("stateChange", (status: ServiceStatus) => {
if (status.state === "running") {
scheduleServiceModelSync(cfg.tool, baseUrl, apiKey);
scheduleServiceModelSync(cfg.tool, baseUrl, modelSyncApiKey);
} else if (status.state === "stopped" || status.state === "error") {
stopServiceModelSync(cfg.tool);
markAllUnavailable(cfg.tool);

View File

@@ -70,13 +70,19 @@ type ExecutorLike = { execute: (input: ExecuteInput) => Promise<unknown> };
*/
async function withCapturedCliproxyapiRequest(
fn: () => Promise<unknown>
): Promise<{ headers: Record<string, string>; called: boolean }> {
): Promise<{
headers: Record<string, string>;
body: Record<string, unknown>;
called: boolean;
}> {
let capturedHeaders: Record<string, string> | null = null;
let capturedBody: Record<string, unknown> | null = null;
const originalFetch = globalThis.fetch;
// @ts-expect-error test stub
globalThis.fetch = async (url: string, init: RequestInit) => {
if (String(url).includes("8317")) {
capturedHeaders = init.headers as Record<string, string>;
capturedBody = JSON.parse(String(init.body)) as Record<string, unknown>;
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
@@ -89,7 +95,11 @@ async function withCapturedCliproxyapiRequest(
} finally {
globalThis.fetch = originalFetch;
}
return { headers: capturedHeaders ?? {}, called: capturedHeaders !== null };
return {
headers: capturedHeaders ?? {},
body: capturedBody ?? {},
called: capturedHeaders !== null,
};
}
describe("#7645 — settingsSchemas has a dedicated cliproxyapi_api_key field", () => {
@@ -163,6 +173,47 @@ describe("#7645 — CLIProxyAPI fallback leg authenticates with the dedicated ke
);
});
it("per-connection claude-native mode keeps the dedicated key and model mapping", async () => {
const sourceModel = "claude-3-opus";
const mappedModel = "claude-3-opus-mapped";
await settingsDb.updateSettings({ cliproxyapi_api_key: DEDICATED_KEY });
await upstreamProxyDb.upsertUpstreamProxyConfig({
providerId: "anthropic-7645-per-connection",
mode: "native",
enabled: true,
cliproxyapiModelMapping: { [sourceModel]: mappedModel },
});
const executor = await resolveExecutorWithProxy(
"anthropic-7645-per-connection",
undefined,
{ cliproxyapiMode: "claude-native" }
);
const { headers, body, called } = await withCapturedCliproxyapiRequest(() =>
(executor as ExecutorLike).execute({
model: sourceModel,
body: { model: sourceModel, messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: NATIVE_KEY },
})
);
assert.equal(called, true, "the per-connection override must invoke CLIProxyAPI");
assert.equal(
headers.Authorization,
`Bearer ${DEDICATED_KEY}`,
"per-connection CLIProxyAPI mode must authenticate with the dedicated key"
);
assert.notEqual(
headers.Authorization,
`Bearer ${NATIVE_KEY}`,
"per-connection CLIProxyAPI mode must not reuse the native credential"
);
assert.equal(body.model, mappedModel, "per-connection CLIProxyAPI mode must map the model");
});
it("falls back to the connection's own credential when no dedicated key is configured (no regression)", async () => {
await settingsDb.updateSettings({ cliproxyapi_api_key: "" });
await upstreamProxyDb.upsertUpstreamProxyConfig({

View File

@@ -6,11 +6,15 @@ import {
makeMemoKey,
isDeterministicMode,
clearMemoStore,
resultMemoForTests,
MEMO_CAP,
} from "../../../open-sse/services/compression/resultMemo.ts";
import type { CompressionResult } from "../../../open-sse/services/compression/types.ts";
import { DEFAULT_COMPRESSION_CONFIG } from "../../../open-sse/services/compression/types.ts";
import { applyCompression } from "../../../open-sse/services/compression/strategySelector.ts";
import {
applyCompression,
applyCompressionAsync,
} from "../../../open-sse/services/compression/strategySelector.ts";
const baseBody = {
messages: [{ role: "user", content: "hello world compress me please" }],
@@ -218,7 +222,6 @@ describe("applyCompression with memoization", () => {
});
it("flag OFF: two identical calls both compute (no caching path)", () => {
let callCount = 0;
// We can't easily spy on internal engine, so we verify via deterministic output
// equality between independent calls (proving cache isn't interfering).
// Use a body that will be lightly compressed.
@@ -257,6 +260,38 @@ describe("applyCompression with memoization", () => {
assert.notEqual(memoLookup(key), null);
});
it("memo misses and hits cannot mutate the cached result", () => {
const body = {
messages: [{ role: "user", content: "Mutation isolation test content. ".repeat(15) }],
model: "gpt-4",
};
const miss = applyCompression(body, "lite", { config: memoConfig, principalId: "u1" });
const expected = structuredClone(miss.body);
miss.body.messages[0]!.content = "mutated miss";
const hit = applyCompression(body, "lite", { config: memoConfig, principalId: "u1" });
assert.deepEqual(hit.body, expected);
hit.body.messages[0]!.content = "mutated hit";
assert.deepEqual(
applyCompression(body, "lite", { config: memoConfig, principalId: "u1" }).body,
expected
);
assert.equal(resultMemoForTests.lookupCount, 3);
});
it("async memo misses and hits perform one lookup per call", async () => {
const body = {
messages: [{ role: "user", content: "Async lookup count test content. ".repeat(15) }],
model: "gpt-4",
};
await applyCompressionAsync(body, "lite", { config: memoConfig, principalId: "u1" });
assert.equal(resultMemoForTests.lookupCount, 1);
await applyCompressionAsync(body, "lite", { config: memoConfig, principalId: "u1" });
assert.equal(resultMemoForTests.lookupCount, 2);
});
it("flag ON + deterministic mode: different principalId = MISS", () => {
const body = {
messages: [{ role: "user", content: "Cross-principal test content. ".repeat(10) }],

View File

@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { resolve } from "node:path";
const workflowPath = resolve(".github/workflows/docker-publish.yml");
function ghaCacheExports(): string[] {
return readFileSync(workflowPath, "utf8")
.split("\n")
.map((line) => line.trim())
.filter((line) => line.startsWith("cache-to:"))
.map((line) => line.slice("cache-to:".length).trim())
.filter((config) => config.split(",").some((option) => option.trim() === "type=gha"));
}
test("Docker publish treats every GitHub Actions cache export as best effort", () => {
const exports = ghaCacheExports();
const scopes = exports
.map((config) => {
const scope = config
.split(",")
.map((option) => option.trim())
.find((option) => option.startsWith("scope="));
return scope?.slice("scope=".length);
})
.sort();
assert.deepEqual(scopes, [
"docker-${{ matrix.arch }}",
"docker-bun-base-${{ matrix.arch }}",
"docker-bun-web-${{ matrix.arch }}",
"docker-web-${{ matrix.arch }}",
]);
for (const config of exports) {
assert.ok(
config.split(",").some((option) => option.trim() === "ignore-error=true"),
`GitHub Actions cache export must be best effort: ${config}`
);
}
});

View File

@@ -72,14 +72,21 @@ test("parseGeminiModelsList maps generateContent image models to the chat endpoi
assert.deepEqual(proImage!.supportedEndpoints, ["chat"]);
});
test("parseGeminiModelsList maps embedContent and bidiGenerateContent", () => {
test("parseGeminiModelsList maps embeddings without advertising unsupported Gemini Live", () => {
const models = parseGeminiModelsList(SAMPLE);
assert.deepEqual(models.find((m) => m.id === "text-embedding-004")!.supportedEndpoints, [
"embeddings",
]);
assert.deepEqual(models.find((m) => m.id === "gemini-live-2.5-flash")!.supportedEndpoints, [
"audio",
]);
assert.equal(models.some((m) => m.id === "gemini-live-2.5-flash"), false);
const [hybrid] = parseGeminiModelsList({
models: [
{
name: "models/gemini-live-hybrid",
supportedGenerationMethods: ["generateContent", "bidiGenerateContent"],
},
],
});
assert.deepEqual(hybrid.supportedEndpoints, ["chat"]);
});
test("parseGeminiModelsList maps Veo predictLongRunning models to the videos endpoint", () => {

View File

@@ -0,0 +1,99 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { makeManagementSessionRequest } from "../helpers/managementSession.ts";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-provider-autofetch-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = "provider-autofetch-test-secret";
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
const originalFetch = globalThis.fetch;
const core = await import("../../src/lib/db/core.ts");
const providersRoute = await import("../../src/app/api/providers/route.ts");
const modelSyncUrls: string[] = [];
globalThis.fetch = (async (input: string | URL | Request) => {
const url =
typeof input === "string" ? input : input instanceof Request ? input.url : input.toString();
if (new URL(url).pathname.includes("/sync-models")) {
modelSyncUrls.push(url);
}
return new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
type CreateOptions = {
autoFetchModels?: boolean;
clientOwnsModelSync?: boolean;
};
async function createConnection(options: CreateOptions = {}): Promise<Response> {
const providerSpecificData =
options.autoFetchModels === undefined
? undefined
: { autoFetchModels: options.autoFetchModels };
const response = await providersRoute.POST(
await makeManagementSessionRequest("http://localhost/api/providers", {
method: "POST",
headers: options.clientOwnsModelSync ? { "X-Skip-Model-Sync": "true" } : undefined,
body: {
provider: "openai",
apiKey: "sk-provider-autofetch-test",
name: "Provider auto-fetch test",
...(providerSpecificData ? { providerSpecificData } : {}),
},
})
);
await new Promise<void>((resolve) => setTimeout(resolve, 10));
return response;
}
test.beforeEach(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
modelSyncUrls.length = 0;
});
test.after(() => {
globalThis.fetch = originalFetch;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("POST /api/providers does not sync models when autoFetchModels is omitted", async () => {
const response = await createConnection();
assert.equal(response.status, 201);
assert.equal(modelSyncUrls.length, 0);
});
test("POST /api/providers does not sync models when autoFetchModels is false", async () => {
const response = await createConnection({ autoFetchModels: false });
assert.equal(response.status, 201);
assert.equal(modelSyncUrls.length, 0);
});
test("POST /api/providers syncs models exactly once when autoFetchModels is true", async () => {
const response = await createConnection({ autoFetchModels: true });
assert.equal(response.status, 201);
assert.equal(modelSyncUrls.length, 1);
assert.match(modelSyncUrls[0], /\/api\/providers\/[^/]+\/sync-models\?mode=import$/);
});
test("POST /api/providers lets a dashboard-owned sync suppress the server duplicate", async () => {
const response = await createConnection({ autoFetchModels: true, clientOwnsModelSync: true });
assert.equal(response.status, 201);
assert.equal(modelSyncUrls.length, 0);
});

View File

@@ -0,0 +1,150 @@
/**
* Regression for #11803: embedded CLIProxyAPI health and model-discovery credentials.
*
* The fake service deliberately separates its public liveness endpoint from
* its authenticated data plane:
* - GET /healthz is public.
* - GET /v1/models accepts only the operator-configured dedicated API key.
*
* CLIProxyAPI's MANAGEMENT_PASSWORD is a control-plane credential and must
* never be reused for /v1/models.
*/
import { after, test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cliproxy-auth-"));
const DEDICATED_API_KEY = "cpa-dedicated-data-plane-key";
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.NODE_ENV = "test";
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
process.env.STORAGE_ENCRYPTION_KEY = "cliproxy-health-model-auth-test-key";
process.env.OMNIROUTE_ADOPT_EXISTING_SERVICE = "1";
const seenPaths: string[] = [];
const modelAuthorizationHeaders: Array<string | null> = [];
const fakeCliproxy = http.createServer((req, res) => {
const requestPath = req.url ?? "/";
seenPaths.push(requestPath);
if (requestPath === "/healthz") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
return;
}
if (requestPath === "/v1/models") {
const authorization = req.headers.authorization ?? null;
modelAuthorizationHeaders.push(authorization);
if (authorization !== `Bearer ${DEDICATED_API_KEY}`) {
res.writeHead(401, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "invalid API key" }));
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ data: [{ id: "fake-cpa-model", object: "model" }] }));
return;
}
res.writeHead(404).end();
});
await new Promise<void>((resolve, reject) => {
fakeCliproxy.once("error", reject);
fakeCliproxy.listen(0, "127.0.0.1", () => resolve());
});
const address = fakeCliproxy.address();
assert.ok(address && typeof address === "object");
process.env.CLIPROXYAPI_PORT = String(address.port);
const core = await import("../../../src/lib/db/core.ts");
const settingsDb = await import("../../../src/lib/db/settings.ts");
const versionManager = await import("../../../src/lib/db/versionManager.ts");
const { decrypt } = await import("../../../src/lib/db/encryption.ts");
const { bootstrapEmbeddedServices } = await import("../../../src/lib/services/bootstrap.ts");
const { getSupervisor, unregisterSupervisor } =
await import("../../../src/lib/services/registry.ts");
const { getOrInitSupervisor } = await import("../../../src/app/api/services/cliproxy/_lib.ts");
const { getServiceModels } = await import("../../../src/lib/db/serviceModels.ts");
const { stopServiceModelSync } = await import("../../../src/lib/services/modelSync.ts");
await versionManager.upsertVersionManagerTool({
tool: "cliproxy",
installedVersion: "test",
status: "stopped",
port: address.port,
});
await settingsDb.updateSettings({ cliproxyapi_api_key: DEDICATED_API_KEY });
after(async () => {
stopServiceModelSync("cliproxy");
const supervisor = getSupervisor("cliproxy");
if (supervisor) await supervisor.stop();
unregisterSupervisor("cliproxy");
await new Promise<void>((resolve) => fakeCliproxy.close(() => resolve()));
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("embedded CLIProxyAPI uses public health and dedicated model credentials", async () => {
await bootstrapEmbeddedServices();
const supervisor = getSupervisor("cliproxy");
assert.ok(supervisor, "bootstrap must register the installed CLIProxyAPI service");
const serviceRow = await versionManager.getServiceRow("cliproxy");
const managementKey = decrypt(serviceRow?.apiKey);
assert.ok(managementKey, "bootstrap must create the separate management credential");
assert.notEqual(
managementKey,
DEDICATED_API_KEY,
"the management and data-plane credentials must remain distinct"
);
const status = await supervisor.start();
assert.equal(status.state, "running", "the public /healthz probe must accept the fake service");
const deadline = Date.now() + 3_000;
while (modelAuthorizationHeaders.length === 0 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 25));
}
assert.ok(seenPaths.includes("/healthz"), "embedded health checks must use public /healthz");
assert.deepEqual(
modelAuthorizationHeaders,
[`Bearer ${DEDICATED_API_KEY}`],
"/v1/models must receive only settings.cliproxyapi_api_key"
);
assert.ok(
getServiceModels("cliproxy").some((model) => model.id === "cliproxy/fake-cpa-model"),
"the authenticated discovery response must be persisted"
);
stopServiceModelSync("cliproxy");
await supervisor.stop();
unregisterSupervisor("cliproxy");
seenPaths.length = 0;
modelAuthorizationHeaders.length = 0;
const onDemandSupervisor = await getOrInitSupervisor();
const onDemandStatus = await onDemandSupervisor.start();
assert.equal(
onDemandStatus.state,
"running",
"the on-demand route supervisor must also use public /healthz"
);
assert.ok(seenPaths.includes("/healthz"));
assert.equal(
modelAuthorizationHeaders.length,
0,
"the on-demand health probe must not call authenticated /v1/models"
);
});

View File

@@ -181,6 +181,30 @@ test("#6848 cleanupCompressionRunTelemetry: deletes rows older than retention wi
assert.strictEqual(remaining.cnt, 1);
});
test("#6848 cleanupCompressionRunTelemetry: missing lazy table is an empty no-op", async () => {
getDbInstance()!.exec("DROP TABLE compression_run_telemetry");
const result = await cleanupCompressionRunTelemetry();
assert.deepStrictEqual(result, { deleted: 0, errors: 0 });
});
test("#6848 cleanupCompressionRunTelemetry: contains table lookup failures", async (t) => {
const db = getDbInstance()!;
const prepare = db.prepare.bind(db);
t.mock.method(db, "prepare", (sql: string) => {
if (sql.includes("sqlite_master")) {
throw new Error("injected table lookup failure");
}
return prepare(sql);
});
t.mock.method(console, "error", () => {});
const result = await cleanupCompressionRunTelemetry();
assert.deepStrictEqual(result, { deleted: 0, errors: 1 });
});
test("#6848 no rows deleted when all data is within retention window (calls all 4 real functions)", async () => {
ensureTelemetryTable();
const db = getDbInstance()!;

View File

@@ -0,0 +1,282 @@
// @vitest-environment jsdom
import fs from "node:fs";
import path from "node:path";
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const translate = (key: string) => key;
const notifications = {
error: vi.fn(),
success: vi.fn(),
};
vi.mock("next-intl", () => ({
useTranslations: () => translate,
}));
vi.mock("@/store/notificationStore", () => ({
useNotificationStore: () => notifications,
}));
import ModelLockoutCard from "../../../src/app/(dashboard)/dashboard/settings/components/ModelLockoutCard";
type OscillatorMock = {
connect: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
frequency: { setValueAtTime: ReturnType<typeof vi.fn> };
onended: (() => void) | null;
start: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
type: OscillatorType;
};
function createAudioContextMock(
options: { resumeRejects?: boolean; state?: AudioContextState } = {}
) {
const contexts: AudioContextMock[] = [];
const oscillators: OscillatorMock[] = [];
const gains: Array<{
connect: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
gain: {
cancelScheduledValues: ReturnType<typeof vi.fn>;
exponentialRampToValueAtTime: ReturnType<typeof vi.fn>;
setValueAtTime: ReturnType<typeof vi.fn>;
};
}> = [];
class AudioContextMock {
close = vi.fn().mockResolvedValue(undefined);
currentTime = 1;
destination = {};
state: AudioContextState = options.state ?? "running";
resume = options.resumeRejects
? vi.fn().mockRejectedValue(new Error("audio resume denied"))
: vi.fn().mockResolvedValue(undefined);
constructor() {
contexts.push(this);
}
createOscillator() {
const oscillator: OscillatorMock = {
connect: vi.fn(),
disconnect: vi.fn(),
frequency: { setValueAtTime: vi.fn() },
onended: null,
start: vi.fn(),
stop: vi.fn(),
type: "sine",
};
oscillators.push(oscillator);
return oscillator;
}
createGain() {
const gain = {
connect: vi.fn(),
disconnect: vi.fn(),
gain: {
cancelScheduledValues: vi.fn(),
exponentialRampToValueAtTime: vi.fn(),
setValueAtTime: vi.fn(),
},
};
gains.push(gain);
return gain;
}
}
return { AudioContextMock, contexts, gains, oscillators };
}
const roots: Array<{ container: HTMLDivElement; root: Root }> = [];
async function renderCard(): Promise<{ container: HTMLDivElement; root: Root }> {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push({ container, root });
await act(async () => {
root.render(<ModelLockoutCard />);
await Promise.resolve();
await Promise.resolve();
});
return { container, root };
}
function disposeCard(rendered: { container: HTMLDivElement; root: Root }): void {
act(() => rendered.root.unmount());
rendered.container.remove();
const index = roots.findIndex(({ root }) => root === rendered.root);
if (index >= 0) roots.splice(index, 1);
}
describe("Model lockout optional notification sound", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
modelLockout: {
enabled: false,
errorCodes: [403, 404, 429, 502, 503, 504],
baseCooldownMs: 120_000,
maxCooldownMs: 1_800_000,
maxBackoffSteps: 10,
useExponentialBackoff: true,
},
}),
{ status: 200 }
)
)
);
});
afterEach(() => {
for (const { container, root } of roots) {
act(() => root.unmount());
container.remove();
}
roots.length = 0;
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("does not ship or reference the unprovenanced MP3", () => {
const legacyAssetName = "ui-notify.mp3";
const legacyAssetUrl = ["/audio", legacyAssetName].join("/");
const assetPath = path.join(process.cwd(), "public/audio", legacyAssetName);
const componentPath = path.join(
process.cwd(),
"src/app/(dashboard)/dashboard/settings/components/ModelLockoutCard.tsx"
);
expect(fs.existsSync(assetPath)).toBe(false);
expect(fs.readFileSync(componentPath, "utf8")).not.toContain(legacyAssetUrl);
});
it("plays generated feedback for both model-lockout toggles", async () => {
const { AudioContextMock, contexts, gains, oscillators } = createAudioContextMock();
vi.stubGlobal("AudioContext", AudioContextMock);
const legacyAudio = vi.fn(() => ({
currentTime: 0,
pause: vi.fn(),
play: vi.fn(),
volume: 1,
}));
vi.stubGlobal("Audio", legacyAudio);
const { container } = await renderCard();
const toggles = [...container.querySelectorAll<HTMLButtonElement>('button[role="switch"]')];
expect(toggles).toHaveLength(2);
act(() => toggles[0]?.click());
act(() => toggles[1]?.click());
expect(contexts).toHaveLength(1);
expect(oscillators).toHaveLength(2);
expect(gains).toHaveLength(2);
oscillators.forEach((oscillator, index) => {
const gain = gains[index];
expect(gain).toBeDefined();
expect(oscillator.type).toBe("sine");
expect(oscillator.frequency.setValueAtTime).toHaveBeenCalledWith(880, 1);
expect(oscillator.connect).toHaveBeenCalledWith(gain);
expect(gain?.connect).toHaveBeenCalledWith(contexts[0]?.destination);
expect(gain?.gain.setValueAtTime).toHaveBeenCalledWith(0.0001, 1);
expect(gain?.gain.exponentialRampToValueAtTime).toHaveBeenNthCalledWith(1, 0.045, 1.012);
expect(gain?.gain.exponentialRampToValueAtTime).toHaveBeenNthCalledWith(2, 0.0001, 1.09);
expect(oscillator.start).toHaveBeenCalledWith(1);
expect(oscillator.stop).toHaveBeenCalledWith(1.1);
oscillator.onended?.();
expect(oscillator.disconnect).toHaveBeenCalledOnce();
expect(gain?.disconnect).toHaveBeenCalledOnce();
});
expect(legacyAudio).not.toHaveBeenCalled();
});
it("uses the prefixed Web Audio constructor when AudioContext is unavailable", async () => {
const { AudioContextMock, contexts, oscillators } = createAudioContextMock();
vi.stubGlobal("AudioContext", undefined);
vi.stubGlobal("webkitAudioContext", AudioContextMock);
const { container } = await renderCard();
const toggle = container.querySelector<HTMLButtonElement>('button[role="switch"]');
act(() => toggle?.click());
expect(contexts).toHaveLength(1);
expect(oscillators).toHaveLength(1);
});
it("starts the optional chime after a suspended context resumes", async () => {
const { AudioContextMock, contexts, oscillators } = createAudioContextMock({
state: "suspended",
});
vi.stubGlobal("AudioContext", AudioContextMock);
const { container } = await renderCard();
const toggle = container.querySelector<HTMLButtonElement>('button[role="switch"]');
await act(async () => {
toggle?.click();
await Promise.resolve();
});
expect(contexts[0]?.resume).toHaveBeenCalledOnce();
expect(oscillators).toHaveLength(1);
});
it("keeps both toggles working when Web Audio is unavailable", async () => {
vi.stubGlobal("AudioContext", undefined);
const { container } = await renderCard();
const toggles = [...container.querySelectorAll<HTMLButtonElement>('button[role="switch"]')];
act(() => toggles[0]?.click());
act(() => toggles[1]?.click());
expect(toggles[0]?.getAttribute("aria-checked")).toBe("true");
expect(toggles[1]?.getAttribute("aria-checked")).toBe("false");
});
it("keeps the toggle working when a suspended context cannot resume", async () => {
const { AudioContextMock, contexts, oscillators } = createAudioContextMock({
resumeRejects: true,
state: "suspended",
});
vi.stubGlobal("AudioContext", AudioContextMock);
const { container } = await renderCard();
const toggle = container.querySelector<HTMLButtonElement>('button[role="switch"]');
await act(async () => {
toggle?.click();
await Promise.resolve();
await Promise.resolve();
});
expect(toggle?.getAttribute("aria-checked")).toBe("true");
expect(contexts[0]?.resume).toHaveBeenCalledOnce();
expect(oscillators).toHaveLength(0);
});
it("releases its audio context when the settings card unmounts", async () => {
const { AudioContextMock, contexts } = createAudioContextMock();
vi.stubGlobal("AudioContext", AudioContextMock);
const rendered = await renderCard();
const toggle = rendered.container.querySelector<HTMLButtonElement>('button[role="switch"]');
act(() => toggle?.click());
expect(contexts).toHaveLength(1);
disposeCard(rendered);
expect(contexts[0]?.close).toHaveBeenCalledOnce();
});
});

View File

@@ -6,9 +6,8 @@ vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
const { useProviderModels } = await import(
"@/app/(dashboard)/dashboard/providers/hooks/useProviderModels"
);
const { useProviderModels } =
await import("@/app/(dashboard)/dashboard/providers/hooks/useProviderModels");
function createResponse(body: unknown, ok = true): Response {
return {
@@ -17,6 +16,15 @@ function createResponse(body: unknown, ok = true): Response {
} as Response;
}
function connection(id: string, autoFetchModels: boolean, isActive = true) {
return {
id,
provider: "custom-provider",
isActive,
providerSpecificData: { autoFetchModels },
};
}
async function renderProviderModels(providerId = "custom-provider") {
const container = document.createElement("div");
document.body.appendChild(container);
@@ -101,8 +109,71 @@ describe("useProviderModels upstream auto-fetch", () => {
const mounted = await renderProviderModels();
await flushQueuedSync();
expect(fetchMock).toHaveBeenCalledWith("/api/providers/connection-1/sync-models?mode=sync", {
method: "POST",
});
mounted.unmount();
});
it.each([
[
"enabled connection first",
[connection("connection-on", true), connection("connection-off", false)],
],
[
"disabled connection first",
[connection("connection-off", false), connection("connection-on", true)],
],
])("does not synchronize a mixed provider when the %s", async (_name, connections) => {
const fetchMock = vi.fn(async (input: string) => {
if (input.startsWith("/api/v1/providers/")) {
return createResponse({ data: [] });
}
if (input === "/api/providers") {
return createResponse({ connections });
}
throw new Error(`Unexpected request: ${input}`);
});
vi.stubGlobal("fetch", fetchMock);
const mounted = await renderProviderModels();
try {
await flushQueuedSync();
expect(fetchMock).not.toHaveBeenCalledWith(
expect.stringContaining("/sync-models?mode=sync"),
expect.anything()
);
} finally {
mounted.unmount();
}
});
it("ignores inactive opt-outs when every active connection is enabled", async () => {
const fetchMock = vi.fn(async (input: string) => {
if (input.startsWith("/api/v1/providers/")) {
return createResponse({ data: [] });
}
if (input === "/api/providers") {
return createResponse({
connections: [
connection("connection-inactive", false, false),
connection("connection-active", true),
],
});
}
if (input === "/api/providers/connection-active/sync-models?mode=sync") {
return createResponse({});
}
throw new Error(`Unexpected request: ${input}`);
});
vi.stubGlobal("fetch", fetchMock);
const mounted = await renderProviderModels();
await flushQueuedSync();
expect(fetchMock).toHaveBeenCalledWith(
"/api/providers/connection-1/sync-models?mode=sync",
"/api/providers/connection-active/sync-models?mode=sync",
{ method: "POST" }
);
mounted.unmount();