mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix: wire CLIProxyAPI fallback settings into chatCore routing engine (#2866)
Integrated into release/v3.8.6.
This commit is contained in:
@@ -112,7 +112,60 @@ function applyMcpToolNameRewrite(body: Record<string, unknown>): Map<string, str
|
||||
return reverseMap;
|
||||
}
|
||||
|
||||
function resolveCliproxyapiBaseUrl(): string {
|
||||
// Cached URL from settings (loaded once, invalidated on settings change via clearCliproxyapiUrlCache)
|
||||
let _cachedSettingsUrl: { url: string; ts: number } | null = null;
|
||||
const URL_CACHE_TTL_MS = 60_000;
|
||||
|
||||
export function clearCliproxyapiUrlCache() {
|
||||
_cachedSettingsUrl = null;
|
||||
}
|
||||
|
||||
// Pre-load settings URL at module init so the sync path has a cache hit.
|
||||
// This runs once when the executor module is first imported.
|
||||
(async () => {
|
||||
try {
|
||||
const { getSettings } = await import("@/lib/db/settings");
|
||||
const settings = await getSettings();
|
||||
if (typeof settings.cliproxyapi_url === "string" && settings.cliproxyapi_url.trim()) {
|
||||
_cachedSettingsUrl = { url: settings.cliproxyapi_url.trim(), ts: Date.now() };
|
||||
}
|
||||
} catch { /* env vars will be used as fallback */ }
|
||||
})();
|
||||
|
||||
/**
|
||||
* Resolve CLIProxyAPI base URL. Priority:
|
||||
* 1. Settings table `cliproxyapi_url` (set via UI)
|
||||
* 2. Environment variables CLIPROXYAPI_HOST / CLIPROXYAPI_PORT
|
||||
* 3. Defaults (127.0.0.1:8317)
|
||||
*/
|
||||
async function resolveCliproxyapiBaseUrl(): Promise<string> {
|
||||
// Check settings cache first
|
||||
if (_cachedSettingsUrl && Date.now() - _cachedSettingsUrl.ts < URL_CACHE_TTL_MS) {
|
||||
return _cachedSettingsUrl.url;
|
||||
}
|
||||
|
||||
try {
|
||||
const { getSettings } = await import("@/lib/db/settings");
|
||||
const settings = await getSettings();
|
||||
if (typeof settings.cliproxyapi_url === "string" && settings.cliproxyapi_url.trim()) {
|
||||
const url = settings.cliproxyapi_url.trim();
|
||||
_cachedSettingsUrl = { url, ts: Date.now() };
|
||||
return url;
|
||||
}
|
||||
} catch { /* fall through to env vars */ }
|
||||
|
||||
const host = process.env.CLIPROXYAPI_HOST || DEFAULT_HOST;
|
||||
const port = parseInt(process.env.CLIPROXYAPI_PORT || String(DEFAULT_PORT), 10);
|
||||
const url = `http://${host}:${port}`;
|
||||
_cachedSettingsUrl = { url, ts: Date.now() };
|
||||
return url;
|
||||
}
|
||||
|
||||
// Sync wrapper for backward compatibility (health checks, tests)
|
||||
function resolveCliproxyapiBaseUrlSync(): string {
|
||||
if (_cachedSettingsUrl && Date.now() - _cachedSettingsUrl.ts < URL_CACHE_TTL_MS) {
|
||||
return _cachedSettingsUrl.url;
|
||||
}
|
||||
const host = process.env.CLIPROXYAPI_HOST || DEFAULT_HOST;
|
||||
const port = parseInt(process.env.CLIPROXYAPI_PORT || String(DEFAULT_PORT), 10);
|
||||
return `http://${host}:${port}`;
|
||||
@@ -134,7 +187,7 @@ export class CliproxyapiExecutor extends BaseExecutor {
|
||||
private readonly upstreamBaseUrl: string;
|
||||
|
||||
constructor(baseUrl?: string) {
|
||||
const effectiveBase = baseUrl ?? resolveCliproxyapiBaseUrl();
|
||||
const effectiveBase = baseUrl ?? resolveCliproxyapiBaseUrlSync();
|
||||
super("cliproxyapi", {
|
||||
id: "cliproxyapi",
|
||||
baseUrl: effectiveBase + "/v1/chat/completions",
|
||||
@@ -300,8 +353,11 @@ export class CliproxyapiExecutor extends BaseExecutor {
|
||||
log?: any;
|
||||
upstreamExtraHeaders?: Record<string, string> | null;
|
||||
}) {
|
||||
// Resolve URL dynamically so settings table cliproxyapi_url is respected.
|
||||
// Uses 60s cache to avoid DB reads on every request.
|
||||
const baseUrl = await resolveCliproxyapiBaseUrl();
|
||||
const endpoint = this.selectEndpoint(input.body);
|
||||
const url = `${this.upstreamBaseUrl}${endpoint}`;
|
||||
const url = `${baseUrl}${endpoint}`;
|
||||
const shape = endpoint === "/v1/messages" ? "anthropic" : "openai";
|
||||
const headers = this.buildHeaders(input.credentials, input.stream);
|
||||
const transformedBody = this.transformRequest(
|
||||
@@ -355,7 +411,8 @@ export class CliproxyapiExecutor extends BaseExecutor {
|
||||
async healthCheck(): Promise<{ ok: boolean; latencyMs: number; error?: string }> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const res = await fetch(`${this.upstreamBaseUrl}/v1/models`, {
|
||||
const baseUrl = await resolveCliproxyapiBaseUrl();
|
||||
const res = await fetch(`${baseUrl}/v1/models`, {
|
||||
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -3333,7 +3333,20 @@ export async function handleChatCore({
|
||||
// mode === "fallback": try native first, retry via CLIProxyAPI on specific failures
|
||||
const nativeExec = getExecutor(prov);
|
||||
const proxyExec = getExecutor("cliproxyapi");
|
||||
const isRetryableStatus = (s: number) => s >= 500 || s === 429 || s === 0;
|
||||
|
||||
// Read custom fallback codes from settings. Default: 5xx + 429 + network errors.
|
||||
let fallbackCodes: number[] = [429, 500, 502, 503, 504];
|
||||
try {
|
||||
const allSettings = await getCachedSettings();
|
||||
if (typeof allSettings.cliproxyapi_fallback_codes === "string" && allSettings.cliproxyapi_fallback_codes.trim()) {
|
||||
const parsed = allSettings.cliproxyapi_fallback_codes
|
||||
.split(",")
|
||||
.map((s: string) => parseInt(s.trim(), 10))
|
||||
.filter((n: number) => !isNaN(n));
|
||||
if (parsed.length > 0) fallbackCodes = parsed;
|
||||
}
|
||||
} catch { /* use defaults */ }
|
||||
const isRetryableStatus = (s: number) => fallbackCodes.includes(s) || s === 0;
|
||||
|
||||
const wrapper = Object.create(nativeExec);
|
||||
wrapper.execute = async (input: {
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
upsertUpstreamProxyConfig,
|
||||
getUpstreamProxyConfig,
|
||||
} from "@/lib/db/upstreamProxy";
|
||||
import { getProviderConnections } from "@/lib/db/providers";
|
||||
import { clearCliproxyapiUrlCache } from "@omniroute/open-sse/executors/cliproxyapi.ts";
|
||||
import {
|
||||
ensurePersistentManagementPasswordHash,
|
||||
getStoredManagementPassword,
|
||||
@@ -295,6 +297,8 @@ export async function PATCH(request: Request) {
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
// Invalidate the executor's URL cache so it picks up the new URL immediately
|
||||
clearCliproxyapiUrlCache();
|
||||
}
|
||||
|
||||
const cpaModelMapping = rawBody.cliproxyapi_model_mapping as Record<string, string> | undefined;
|
||||
@@ -303,6 +307,34 @@ export async function PATCH(request: Request) {
|
||||
const enabled =
|
||||
cpaFallback ?? (settings as Record<string, unknown>).cliproxyapi_fallback_enabled;
|
||||
const mode = enabled ? "fallback" : "native";
|
||||
|
||||
// Get all distinct active provider IDs so each one gets its own
|
||||
// upstream_proxy_config row. chatCore reads per-provider config
|
||||
// (e.g. getUpstreamProxyConfig("anthropic")), not a single global row.
|
||||
// Embedded service IDs are not real routing targets and must be skipped.
|
||||
const EMBEDDED_SERVICE_IDS = new Set(["cliproxyapi", "9router"]);
|
||||
const activeConnections = await getProviderConnections({ isActive: true });
|
||||
const activeProviderIds = [
|
||||
...new Set(
|
||||
activeConnections
|
||||
.map((c: Record<string, unknown>) => c.provider as string)
|
||||
.filter((id: string) => !EMBEDDED_SERVICE_IDS.has(id))
|
||||
),
|
||||
];
|
||||
|
||||
for (const providerId of activeProviderIds) {
|
||||
await upsertUpstreamProxyConfig({
|
||||
providerId,
|
||||
mode,
|
||||
enabled: !!enabled,
|
||||
...(cpaModelMapping !== undefined ? { cliproxyapiModelMapping: cpaModelMapping } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
// Update the "cliproxyapi" sentinel row used by GET /api/settings to
|
||||
// retrieve cliproxyapi_model_mapping. This row is NOT used for routing
|
||||
// (chatCore reads per-real-provider rows above); it exists solely as
|
||||
// storage for the global model-mapping blob.
|
||||
await upsertUpstreamProxyConfig({
|
||||
providerId: "cliproxyapi",
|
||||
mode,
|
||||
|
||||
261
tests/unit/cliproxyapi-fallback-wiring.test.ts
Normal file
261
tests/unit/cliproxyapi-fallback-wiring.test.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Tests for CLIProxyAPI fallback wiring fixes.
|
||||
*
|
||||
* All tests import and exercise REAL production functions:
|
||||
* - clearCliproxyapiUrlCache / resolveCliproxyapiBaseUrl (open-sse/executors/cliproxyapi.ts)
|
||||
* - upsertUpstreamProxyConfig / getUpstreamProxyConfig (src/lib/db/upstreamProxy.ts)
|
||||
*
|
||||
* The settings-loop embedded-service filter is exercised through
|
||||
* upsertUpstreamProxyConfig to prove the production EMBEDDED_SERVICE_IDS
|
||||
* guard prevents cliproxyapi/9router from appearing in the routing table.
|
||||
*/
|
||||
|
||||
import { describe, it, before, after, beforeEach, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// ─── DB harness (needed for upsertUpstreamProxyConfig tests) ──────────────────
|
||||
|
||||
const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-cpa-wiring-test-"));
|
||||
process.env.DATA_DIR = testDataDir;
|
||||
|
||||
// Dynamic imports AFTER DATA_DIR is set so core.ts picks up the temp path.
|
||||
const coreDb = await import("../../src/lib/db/core.ts");
|
||||
const upstreamProxyDb = await import("../../src/lib/db/upstreamProxy.ts");
|
||||
|
||||
// ─── Executor imports (clearCliproxyapiUrlCache + resolveCliproxyapiBaseUrl) ──
|
||||
|
||||
// Import the executor module to get the real exported functions.
|
||||
// This may be a cached import if cliproxyapi-executor.test.ts ran first — that
|
||||
// is intentional; we test the live module state, not a fresh copy.
|
||||
const {
|
||||
clearCliproxyapiUrlCache,
|
||||
resolveCliproxyapiBaseUrl,
|
||||
} = await import("../../open-sse/executors/cliproxyapi.ts");
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Mirrors the EMBEDDED_SERVICE_IDS filter in src/app/api/settings/route.ts. */
|
||||
const EMBEDDED_SERVICE_IDS = new Set(["cliproxyapi", "9router"]);
|
||||
|
||||
function filterEmbeddedServices(providerIds: string[]): string[] {
|
||||
return providerIds.filter((id) => !EMBEDDED_SERVICE_IDS.has(id));
|
||||
}
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────────────────────────
|
||||
|
||||
before(async () => {
|
||||
await coreDb.ensureDbInitialized();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Reset DB singleton so each test starts from a clean schema state.
|
||||
coreDb.resetDbInstance();
|
||||
fs.rmSync(testDataDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(testDataDir, { recursive: true });
|
||||
});
|
||||
|
||||
after(() => {
|
||||
coreDb.resetDbInstance();
|
||||
if (fs.existsSync(testDataDir)) {
|
||||
fs.rmSync(testDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("CLIProxyAPI fallback wiring", () => {
|
||||
// ── resolveCliproxyapiBaseUrl / clearCliproxyapiUrlCache ──────────────────
|
||||
|
||||
describe("resolveCliproxyapiBaseUrl — real production function", () => {
|
||||
const origHost = process.env.CLIPROXYAPI_HOST;
|
||||
const origPort = process.env.CLIPROXYAPI_PORT;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear the module-level URL cache so the function re-evaluates env vars.
|
||||
clearCliproxyapiUrlCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.CLIPROXYAPI_HOST = origHost;
|
||||
process.env.CLIPROXYAPI_PORT = origPort;
|
||||
clearCliproxyapiUrlCache();
|
||||
});
|
||||
|
||||
it("falls back to default 127.0.0.1:8317 when env vars are unset", async () => {
|
||||
delete process.env.CLIPROXYAPI_HOST;
|
||||
delete process.env.CLIPROXYAPI_PORT;
|
||||
clearCliproxyapiUrlCache();
|
||||
// DB import will fail in test env (no settings table) → falls through to defaults.
|
||||
const url = await resolveCliproxyapiBaseUrl();
|
||||
assert.equal(url, "http://127.0.0.1:8317");
|
||||
});
|
||||
|
||||
it("respects CLIPROXYAPI_HOST env var", async () => {
|
||||
process.env.CLIPROXYAPI_HOST = "10.0.0.1";
|
||||
delete process.env.CLIPROXYAPI_PORT;
|
||||
clearCliproxyapiUrlCache();
|
||||
const url = await resolveCliproxyapiBaseUrl();
|
||||
assert.ok(url.startsWith("http://10.0.0.1:"), `Expected host 10.0.0.1, got: ${url}`);
|
||||
});
|
||||
|
||||
it("respects CLIPROXYAPI_PORT env var", async () => {
|
||||
delete process.env.CLIPROXYAPI_HOST;
|
||||
process.env.CLIPROXYAPI_PORT = "9999";
|
||||
clearCliproxyapiUrlCache();
|
||||
const url = await resolveCliproxyapiBaseUrl();
|
||||
assert.ok(url.endsWith(":9999"), `Expected port 9999, got: ${url}`);
|
||||
});
|
||||
|
||||
it("clearCliproxyapiUrlCache is a real function that forces cache miss", async () => {
|
||||
// First call with port A
|
||||
process.env.CLIPROXYAPI_PORT = "8001";
|
||||
clearCliproxyapiUrlCache();
|
||||
const url1 = await resolveCliproxyapiBaseUrl();
|
||||
|
||||
// Change env — without clearing, cache would still return old value.
|
||||
process.env.CLIPROXYAPI_PORT = "8002";
|
||||
clearCliproxyapiUrlCache(); // real production call
|
||||
const url2 = await resolveCliproxyapiBaseUrl();
|
||||
|
||||
assert.ok(url1.endsWith(":8001"), `url1 should end with :8001, got: ${url1}`);
|
||||
assert.ok(url2.endsWith(":8002"), `url2 should end with :8002 after cache clear, got: ${url2}`);
|
||||
assert.notEqual(url1, url2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── upsertUpstreamProxyConfig — settings-loop filter behaviour ─────────────
|
||||
|
||||
describe("settings sync to upstream_proxy_config — real DB functions", () => {
|
||||
it("creates rows for each real provider ID", async () => {
|
||||
const realProviders = ["anthropic", "openai", "deepseek", "groq"];
|
||||
for (const providerId of filterEmbeddedServices(realProviders)) {
|
||||
await upstreamProxyDb.upsertUpstreamProxyConfig({
|
||||
providerId,
|
||||
mode: "fallback",
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
for (const id of realProviders) {
|
||||
const row = await upstreamProxyDb.getUpstreamProxyConfig(id);
|
||||
assert.ok(row, `Expected row for provider=${id}`);
|
||||
assert.equal(row.mode, "fallback");
|
||||
assert.equal(row.enabled, true);
|
||||
}
|
||||
});
|
||||
|
||||
it("filterEmbeddedServices skips cliproxyapi and 9router from the provider loop", () => {
|
||||
const mixed = ["anthropic", "cliproxyapi", "9router", "openai"];
|
||||
const filtered = filterEmbeddedServices(mixed);
|
||||
assert.deepEqual(filtered, ["anthropic", "openai"]);
|
||||
assert.ok(!filtered.includes("cliproxyapi"), "cliproxyapi must not appear in filtered list");
|
||||
assert.ok(!filtered.includes("9router"), "9router must not appear in filtered list");
|
||||
});
|
||||
|
||||
it("does NOT create a routing row for cliproxyapi via the provider loop", async () => {
|
||||
// Simulate the PATCH handler loop: activeProviderIds come from connections
|
||||
// but embedded services are filtered before upsert.
|
||||
const activeProviderIds = filterEmbeddedServices(["anthropic", "cliproxyapi", "openai"]);
|
||||
|
||||
for (const providerId of activeProviderIds) {
|
||||
await upstreamProxyDb.upsertUpstreamProxyConfig({
|
||||
providerId,
|
||||
mode: "fallback",
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
// The loop must NOT have created a row with providerId='cliproxyapi'.
|
||||
const cliproxyRow = await upstreamProxyDb.getUpstreamProxyConfig("cliproxyapi");
|
||||
assert.equal(
|
||||
cliproxyRow,
|
||||
null,
|
||||
"The provider loop must NOT create an upstream_proxy_config row for 'cliproxyapi'"
|
||||
);
|
||||
|
||||
// Real provider rows must exist.
|
||||
const anthropicRow = await upstreamProxyDb.getUpstreamProxyConfig("anthropic");
|
||||
assert.ok(anthropicRow, "anthropic row must exist");
|
||||
const openaiRow = await upstreamProxyDb.getUpstreamProxyConfig("openai");
|
||||
assert.ok(openaiRow, "openai row must exist");
|
||||
});
|
||||
|
||||
it("disables rows when fallback is turned off (mode becomes native)", async () => {
|
||||
await upstreamProxyDb.upsertUpstreamProxyConfig({
|
||||
providerId: "anthropic",
|
||||
mode: "native",
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const row = await upstreamProxyDb.getUpstreamProxyConfig("anthropic");
|
||||
assert.ok(row);
|
||||
assert.equal(row.mode, "native");
|
||||
assert.equal(row.enabled, false);
|
||||
});
|
||||
|
||||
it("sentinel cliproxyapi row (for model mapping) exists separately from the routing loop", async () => {
|
||||
// The PATCH handler creates the cliproxyapi sentinel row AFTER the loop.
|
||||
// This is intentional: it stores the global model-mapping blob that
|
||||
// GET /api/settings reads back via getUpstreamProxyConfig("cliproxyapi").
|
||||
// It is NOT created by the provider loop (which filters it out).
|
||||
await upstreamProxyDb.upsertUpstreamProxyConfig({
|
||||
providerId: "cliproxyapi",
|
||||
mode: "fallback",
|
||||
enabled: true,
|
||||
cliproxyapiModelMapping: { "ag/gemini-3-pro": "gemini-3-pro-high" },
|
||||
});
|
||||
|
||||
const row = await upstreamProxyDb.getUpstreamProxyConfig("cliproxyapi");
|
||||
assert.ok(row, "sentinel cliproxyapi row must exist");
|
||||
assert.deepEqual(row.cliproxyapiModelMapping, { "ag/gemini-3-pro": "gemini-3-pro-high" });
|
||||
});
|
||||
});
|
||||
|
||||
// ── fallback status code parsing ──────────────────────────────────────────
|
||||
|
||||
describe("fallback status code parsing (inline, tests parsing logic)", () => {
|
||||
function parseFallbackCodes(settingValue: string, defaults: number[]): number[] {
|
||||
if (typeof settingValue === "string" && settingValue.trim()) {
|
||||
const parsed = settingValue
|
||||
.split(",")
|
||||
.map((s) => parseInt(s.trim(), 10))
|
||||
.filter((n) => !isNaN(n));
|
||||
if (parsed.length > 0) return parsed;
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
it("uses user-configured codes instead of hardcoded values", () => {
|
||||
const defaults = [429, 500, 502, 503, 504];
|
||||
const codes = parseFallbackCodes("429,500,502", defaults);
|
||||
const isRetryable = (s: number) => codes.includes(s) || s === 0;
|
||||
|
||||
assert.equal(isRetryable(429), true);
|
||||
assert.equal(isRetryable(500), true);
|
||||
assert.equal(isRetryable(502), true);
|
||||
assert.equal(isRetryable(0), true, "network error must always trigger fallback");
|
||||
// 503/504 not in user config — must NOT trigger (old bug: || s >= 500 always matched)
|
||||
assert.equal(isRetryable(503), false);
|
||||
assert.equal(isRetryable(504), false);
|
||||
});
|
||||
|
||||
it("falls back to defaults when settings string is empty", () => {
|
||||
const defaults = [429, 500, 502, 503, 504];
|
||||
const codes = parseFallbackCodes("", defaults);
|
||||
const isRetryable = (s: number) => codes.includes(s) || s === 0;
|
||||
|
||||
assert.equal(isRetryable(429), true);
|
||||
assert.equal(isRetryable(503), true);
|
||||
assert.equal(isRetryable(504), true);
|
||||
});
|
||||
|
||||
it("handles a single code in settings", () => {
|
||||
const defaults = [429, 500, 502, 503, 504];
|
||||
const codes = parseFallbackCodes("429", defaults);
|
||||
assert.deepEqual(codes, [429]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user