Compare commits

...

10 Commits

Author SHA1 Message Date
diegosouzapw
538f258eb0 docs(changelog): bind notices to PR 11726 2026-08-28 00:56:12 -03:00
diegosouzapw
0d410d3ce2 docs(legal): add pinned MIT third-party notices 2026-08-28 00:56:12 -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
26 changed files with 766 additions and 93 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

@@ -24,3 +24,59 @@ NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPO
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## blackwell-systems/gcf-typescript
The generic-profile codec in
`open-sse/services/compression/engines/headroom/gcf/{decode_generic,generic,index,scalar}.ts`
is adapted from
[`blackwell-systems/gcf-typescript`](https://github.com/blackwell-systems/gcf-typescript/tree/00972f2dc781477eb6d369e62edfe03ad4112a07),
commit `00972f2dc781477eb6d369e62edfe03ad4112a07`. The license below is reproduced
from that commit's
[`LICENSE`](https://github.com/blackwell-systems/gcf-typescript/blob/00972f2dc781477eb6d369e62edfe03ad4112a07/LICENSE).
MIT License
Copyright (c) 2026 Dayna Blackwell
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## lipis/flag-icons
The country flag SVGs in `docs/assets/flags/` are copied from the `flags/4x3/` directory of
[`lipis/flag-icons`](https://github.com/lipis/flag-icons/tree/086f7e97d657358203916dbe84f61c2bccaa81eb),
commit `086f7e97d657358203916dbe84f61c2bccaa81eb`. The license below is reproduced
from that commit's
[`LICENSE`](https://github.com/lipis/flag-icons/blob/086f7e97d657358203916dbe84f61c2bccaa81eb/LICENSE).
The MIT License (MIT)
Copyright (c) 2013 Panayiotis Lipiridis
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

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 @@
- **docs(legal):** centralize pinned MIT notices for the vendored GCF codec and local flag assets ([#11726](https://github.com/diegosouzapw/OmniRoute/pull/11726))

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