Compare commits

...

10 Commits

Author SHA1 Message Date
diegosouzapw
50edcb82af docs(changelog): bind asset cleanup to PR 11735 2026-08-28 00:55:38 -03:00
diegosouzapw
dc18f470ea chore(assets): prune unresolved provider variants 2026-08-28 00:55:38 -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
29 changed files with 710 additions and 119 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 @@
- **chore(assets):** remove four unused provider assets whose redistribution provenance could not be verified while retaining the active Freebuff SVG ([#11735](https://github.com/diegosouzapw/OmniRoute/pull/11735))

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

View File

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" rx="22" fill="#000000"/>
<!-- Sparkle in upper-left -->
<path d="M 36 16 C 36 28 26 38 14 38 C 26 38 36 48 36 60 C 36 48 46 38 58 38 C 46 38 36 28 36 16 Z" fill="#FFFFFF"/>
<!-- Terminal cursor bar -->
<rect x="44" y="46" width="34" height="14" rx="3" fill="#FFFFFF"/>
</svg>

Before

Width:  |  Height:  |  Size: 404 B

View File

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" rx="22" fill="#000000"/>
<!-- Sparkle in upper-left -->
<path d="M 36 16 C 36 28 26 38 14 38 C 26 38 36 48 36 60 C 36 48 46 38 58 38 C 46 38 36 28 36 16 Z" fill="#FFFFFF"/>
<!-- Terminal cursor bar -->
<rect x="44" y="46" width="34" height="14" rx="3" fill="#FFFFFF"/>
</svg>

Before

Width:  |  Height:  |  Size: 404 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -1,12 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="OpenVecta">
<title>OpenVecta</title>
<defs>
<linearGradient id="ov-grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#7C3AED" />
<stop offset="100%" stop-color="#A855F7" />
</linearGradient>
</defs>
<rect x="2" y="2" width="60" height="60" rx="14" fill="url(#ov-grad)" />
<path d="M14 18 L32 46 L50 18" fill="none" stroke="#FFFFFF" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="32" cy="40" r="3.5" fill="#FFFFFF" />
</svg>

Before

Width:  |  Height:  |  Size: 608 B

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

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

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