mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-31 11:32:11 +03:00
Compare commits
10 Commits
security/v
...
docs/v3850
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
538f258eb0 | ||
|
|
0d410d3ce2 | ||
|
|
825f8fe425 | ||
|
|
cfeed516e8 | ||
|
|
d937b5229e | ||
|
|
321935d261 | ||
|
|
a14a91dcfe | ||
|
|
09fa818365 | ||
|
|
9be4fd0a0e | ||
|
|
a45e80af43 |
8
.github/workflows/docker-publish.yml
vendored
8
.github/workflows/docker-publish.yml
vendored
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
1
changelog.d/fixes/10591-gemini-live-catalog.md
Normal file
1
changelog.d/fixes/10591-gemini-live-catalog.md
Normal file
@@ -0,0 +1 @@
|
||||
- Stop advertising Gemini Live-only models as supported audio endpoints until OmniRoute proxies the bidirectional Live protocol.
|
||||
@@ -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
|
||||
1
changelog.d/fixes/11811-cliproxy-health-model-auth.md
Normal file
1
changelog.d/fixes/11811-cliproxy-health-model-auth.md
Normal 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))
|
||||
1
changelog.d/maintenance/11726-third-party-notices.md
Normal file
1
changelog.d/maintenance/11726-third-party-notices.md
Normal 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))
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) }],
|
||||
|
||||
42
tests/unit/docker-cache-export-best-effort-7518.test.ts
Normal file
42
tests/unit/docker-cache-export-best-effort-7518.test.ts
Normal 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}`
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
99
tests/unit/providers-route-model-autofetch-optin.test.ts
Normal file
99
tests/unit/providers-route-model-autofetch-optin.test.ts
Normal 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);
|
||||
});
|
||||
150
tests/unit/services/cliproxy-health-model-auth.test.ts
Normal file
150
tests/unit/services/cliproxy-health-model-auth.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
@@ -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()!;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user