mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(cliproxyapi): add DB schema, upstream proxy config & settings UI
Part 1 of CLIProxyAPI integration (#902). - DB migration for version_manager + upstream_proxy_config tables - DB modules: versionManager.ts (tool lifecycle CRUD), upstreamProxy.ts (proxy config CRUD + SSRF guard) - localDb.ts re-exports for new modules - Provider registration: UPSTREAM_PROXY_PROVIDERS in providers.ts - Zod validation schemas for version-manager API routes - Settings UI: CliproxyapiSettingsTab (Advanced tab) - 47 unit tests for DB modules
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Button, Input, Toggle } from "@/shared/components";
|
||||
|
||||
export default function CliproxyapiSettingsTab() {
|
||||
const [settings, setSettings] = useState<Record<string, any>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: string; text: string } | null>(null);
|
||||
const [toolState, setToolState] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setSettings(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
|
||||
fetch("/api/version-manager/status")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const entry = Array.isArray(data) ? data.find((t: any) => t.tool === "cliproxyapi") : null;
|
||||
setToolState(entry);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const updateSetting = async (key: string, value: any) => {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ [key]: value }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
setMessage({ type: "success", text: "Setting saved" });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "Failed to save setting" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cpaEnabled = settings.cliproxyapi_fallback_enabled === true;
|
||||
const cpaUrl = settings.cliproxyapi_url || "http://127.0.0.1:8317";
|
||||
const cpaCodes = settings.cliproxyapi_fallback_codes || "502,401,403,429,503";
|
||||
|
||||
const statusColor =
|
||||
toolState?.status === "running"
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: toolState?.status === "error"
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: "text-text-muted";
|
||||
|
||||
const statusIcon =
|
||||
toolState?.status === "running"
|
||||
? "check_circle"
|
||||
: toolState?.status === "error"
|
||||
? "error"
|
||||
: "help";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{message && (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-xs ${
|
||||
message.type === "success"
|
||||
? "bg-green-500/10 text-green-600 dark:text-green-400"
|
||||
: "bg-red-500/10 text-red-600 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{message.type === "success" ? "check_circle" : "error"}
|
||||
</span>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="size-8 rounded-lg flex items-center justify-center bg-indigo-500/10">
|
||||
<span className="material-symbols-outlined text-indigo-500 text-xl">swap_horiz</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-sm">CLIProxyAPI Fallback</h3>
|
||||
<p className="text-xs text-text-muted">
|
||||
When enabled, failed requests are retried through CLIProxyAPI (localhost:8317)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm text-text-main">Enable CLIProxyAPI Fallback</label>
|
||||
<Toggle
|
||||
checked={cpaEnabled}
|
||||
onChange={(checked) => updateSetting("cliproxyapi_fallback_enabled", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{cpaEnabled && (
|
||||
<>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1.5 block">CLIProxyAPI URL</label>
|
||||
<Input
|
||||
value={cpaUrl}
|
||||
onChange={(e) => updateSetting("cliproxyapi_url", e.target.value)}
|
||||
placeholder="http://127.0.0.1:8317"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1.5 block">
|
||||
Fallback Status Codes (comma-separated)
|
||||
</label>
|
||||
<Input
|
||||
value={cpaCodes}
|
||||
onChange={(e) => updateSetting("cliproxyapi_fallback_codes", e.target.value)}
|
||||
placeholder="502,401,403,429,503"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="md">
|
||||
<h3 className="font-medium text-sm mb-4">CLIProxyAPI Status</h3>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-text-muted text-sm">
|
||||
<span className="material-symbols-outlined animate-spin text-base">
|
||||
progress_activity
|
||||
</span>
|
||||
Loading...
|
||||
</div>
|
||||
) : toolState ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<p className="text-xs text-text-muted mb-1">Status</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`material-symbols-outlined text-sm ${statusColor}`}>
|
||||
{statusIcon}
|
||||
</span>
|
||||
<p className={`text-sm font-medium capitalize ${statusColor}`}>
|
||||
{toolState.status?.replace("_", " ") || "Unknown"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<p className="text-xs text-text-muted mb-1">Version</p>
|
||||
<p className="text-sm font-medium">
|
||||
{toolState.installedVersion ? `v${toolState.installedVersion}` : "Not installed"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<p className="text-xs text-text-muted mb-1">Health</p>
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
toolState.healthStatus === "healthy"
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: toolState.healthStatus === "unhealthy"
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{toolState.healthStatus === "healthy"
|
||||
? "Healthy"
|
||||
: toolState.healthStatus === "unhealthy"
|
||||
? "Unhealthy"
|
||||
: "Unknown"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<p className="text-xs text-text-muted mb-1">Port</p>
|
||||
<p className="text-sm font-mono">{toolState.port || 8317}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">CLIProxyAPI not detected</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import CacheStatsCard from "./components/CacheStatsCard";
|
||||
import CacheSettingsTab from "./components/CacheSettingsTab";
|
||||
import MemorySkillsTab from "./components/MemorySkillsTab";
|
||||
import ResilienceTab from "./components/ResilienceTab";
|
||||
import CliproxyapiSettingsTab from "./components/CliproxyapiSettingsTab";
|
||||
|
||||
const tabs = [
|
||||
{ id: "general", labelKey: "general", icon: "settings" },
|
||||
@@ -113,7 +114,12 @@ export default function SettingsPage() {
|
||||
|
||||
{activeTab === "resilience" && <ResilienceTab />}
|
||||
|
||||
{activeTab === "advanced" && <ProxyTab />}
|
||||
{activeTab === "advanced" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ProxyTab />
|
||||
<CliproxyapiSettingsTab />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* App Info */}
|
||||
|
||||
56
src/lib/db/migrations/016_version_manager_upstream_proxy.sql
Normal file
56
src/lib/db/migrations/016_version_manager_upstream_proxy.sql
Normal file
@@ -0,0 +1,56 @@
|
||||
-- Migration 016: Version Manager & Upstream Proxy tables
|
||||
--
|
||||
-- Adds two tables for CLIProxyAPI integration:
|
||||
-- version_manager — binary lifecycle management for CLI tools (CLIProxyAPI, etc.)
|
||||
-- upstream_proxy_config — per-provider routing mode (native vs CLIProxyAPI vs fallback)
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- Table: version_manager
|
||||
-- Tracks installed versions, process state, and update settings for
|
||||
-- externally managed CLI tools (initially CLIProxyAPI).
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS version_manager (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tool TEXT NOT NULL UNIQUE, -- 'cliproxyapi' | 'omniroute'
|
||||
current_version TEXT, -- latest known release version
|
||||
installed_version TEXT, -- currently installed version
|
||||
pinned_version TEXT, -- user-pinned version (null = auto)
|
||||
binary_path TEXT, -- absolute path to binary
|
||||
status TEXT NOT NULL DEFAULT 'not_installed', -- not_installed | installed | running | stopped | error
|
||||
pid INTEGER, -- process ID when running
|
||||
port INTEGER DEFAULT 8317, -- managed tool's port
|
||||
api_key TEXT, -- API key for CLIProxyAPI (from its config)
|
||||
management_key TEXT, -- CLIProxyAPI management secret key
|
||||
auto_update INTEGER NOT NULL DEFAULT 1, -- 1 = auto-update enabled
|
||||
auto_start INTEGER NOT NULL DEFAULT 0, -- 1 = start with OmniRoute
|
||||
last_health_check TEXT, -- ISO timestamp
|
||||
last_update_check TEXT, -- ISO timestamp
|
||||
health_status TEXT DEFAULT 'unknown', -- unknown | healthy | unhealthy | timeout
|
||||
config_overrides TEXT, -- JSON blob for CLIProxyAPI config overrides
|
||||
error_message TEXT, -- last error message
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- Table: upstream_proxy_config
|
||||
-- Per-provider routing configuration for CLIProxyAPI passthrough mode.
|
||||
-- Determines whether each provider uses OmniRoute's native executor,
|
||||
-- delegates to CLIProxyAPI, or uses fallback (native first, then CLIProxyAPI).
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS upstream_proxy_config (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider_id TEXT NOT NULL UNIQUE, -- e.g., 'antigravity', 'claude', 'codex'
|
||||
mode TEXT NOT NULL DEFAULT 'native', -- 'native' | 'cliproxyapi' | 'fallback'
|
||||
cliproxyapi_model_mapping TEXT, -- JSON: { "ag/gemini-3-pro": "gemini-3-pro-high", ... }
|
||||
native_priority INTEGER NOT NULL DEFAULT 1, -- order in fallback chain (1 = first)
|
||||
cliproxyapi_priority INTEGER NOT NULL DEFAULT 2, -- order in fallback chain
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_upc_provider ON upstream_proxy_config(provider_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_upc_mode ON upstream_proxy_config(mode);
|
||||
CREATE INDEX IF NOT EXISTS idx_vm_tool ON version_manager(tool);
|
||||
CREATE INDEX IF NOT EXISTS idx_vm_status ON version_manager(status);
|
||||
230
src/lib/db/upstreamProxy.ts
Normal file
230
src/lib/db/upstreamProxy.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/** Upstream proxy config persistence for upstream_proxy_config table. */
|
||||
import { getDbInstance } from "./core";
|
||||
|
||||
interface UpstreamProxyConfig {
|
||||
id: number;
|
||||
providerId: string;
|
||||
mode: string;
|
||||
cliproxyapiModelMapping: Record<string, unknown> | null;
|
||||
nativePriority: number;
|
||||
cliproxyapiPriority: number;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface UpstreamProxyRow {
|
||||
id: unknown;
|
||||
provider_id: unknown;
|
||||
mode: unknown;
|
||||
cliproxyapi_model_mapping: unknown;
|
||||
native_priority: unknown;
|
||||
cliproxyapi_priority: unknown;
|
||||
enabled: unknown;
|
||||
created_at: unknown;
|
||||
updated_at: unknown;
|
||||
}
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
const BLOCKED_HOSTNAMES = ["metadata.google.internal", "169.254.169.254", "metadata.aws.internal"];
|
||||
|
||||
function isPrivateHost(hostname: string): boolean {
|
||||
if (BLOCKED_HOSTNAMES.includes(hostname)) return true;
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") return false;
|
||||
if (
|
||||
/^10\./.test(hostname) ||
|
||||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||
|
||||
/^192\.168\./.test(hostname)
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
/^0\./.test(hostname) ||
|
||||
/^127\./.test(hostname) ||
|
||||
/^224\./.test(hostname) ||
|
||||
/^169\.254\./.test(hostname)
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function validateProxyUrl(
|
||||
url: string
|
||||
): { valid: true; url: string } | { valid: false; error: string } {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Unsupported protocol "${parsed.protocol}" — use http or https`,
|
||||
};
|
||||
}
|
||||
if (isPrivateHost(parsed.hostname)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Proxy URL cannot point to private/internal address "${parsed.hostname}"`,
|
||||
};
|
||||
}
|
||||
return { valid: true, url };
|
||||
} catch {
|
||||
return { valid: false, error: `Invalid URL: "${url}"` };
|
||||
}
|
||||
}
|
||||
|
||||
function rowToConfig(record: Record<string, unknown>): UpstreamProxyConfig {
|
||||
return {
|
||||
id: record.id as number,
|
||||
providerId: record.provider_id as string,
|
||||
mode: record.mode as string,
|
||||
cliproxyapiModelMapping:
|
||||
record.cliproxyapi_model_mapping && typeof record.cliproxyapi_model_mapping === "string"
|
||||
? JSON.parse(record.cliproxyapi_model_mapping)
|
||||
: null,
|
||||
nativePriority: record.native_priority as number,
|
||||
cliproxyapiPriority: record.cliproxyapi_priority as number,
|
||||
enabled: record.enabled === 1 || record.enabled === true,
|
||||
createdAt: record.created_at as string,
|
||||
updatedAt: record.updated_at as string,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUpstreamProxyConfigs() {
|
||||
const db = getDbInstance();
|
||||
const rows = db
|
||||
.prepare("SELECT * FROM upstream_proxy_config ORDER BY provider_id")
|
||||
.all() as UpstreamProxyRow[];
|
||||
return rows.map((row) => rowToConfig(toRecord(row)));
|
||||
}
|
||||
|
||||
export async function getUpstreamProxyConfig(providerId: string) {
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
.prepare("SELECT * FROM upstream_proxy_config WHERE provider_id = ?")
|
||||
.get(providerId) as UpstreamProxyRow | undefined;
|
||||
if (!row) return null;
|
||||
return rowToConfig(toRecord(row));
|
||||
}
|
||||
|
||||
export async function upsertUpstreamProxyConfig(data: {
|
||||
providerId: string;
|
||||
mode?: string;
|
||||
cliproxyapiModelMapping?: Record<string, unknown> | null;
|
||||
nativePriority?: number;
|
||||
cliproxyapiPriority?: number;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const db = getDbInstance();
|
||||
const mode = data.mode ?? "native";
|
||||
const cliproxyapiModelMapping =
|
||||
data.cliproxyapiModelMapping !== undefined
|
||||
? JSON.stringify(data.cliproxyapiModelMapping)
|
||||
: null;
|
||||
const nativePriority = data.nativePriority ?? 1;
|
||||
const cliproxyapiPriority = data.cliproxyapiPriority ?? 2;
|
||||
const enabled = data.enabled !== false ? 1 : 0;
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO upstream_proxy_config
|
||||
(provider_id, mode, cliproxyapi_model_mapping, native_priority, cliproxyapi_priority, enabled, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
ON CONFLICT(provider_id) DO UPDATE SET
|
||||
mode = excluded.mode,
|
||||
cliproxyapi_model_mapping = excluded.cliproxyapi_model_mapping,
|
||||
native_priority = excluded.native_priority,
|
||||
cliproxyapi_priority = excluded.cliproxyapi_priority,
|
||||
enabled = excluded.enabled,
|
||||
updated_at = datetime('now')`
|
||||
).run(
|
||||
data.providerId,
|
||||
mode,
|
||||
cliproxyapiModelMapping,
|
||||
nativePriority,
|
||||
cliproxyapiPriority,
|
||||
enabled
|
||||
);
|
||||
|
||||
return getUpstreamProxyConfig(data.providerId);
|
||||
}
|
||||
|
||||
export async function updateUpstreamProxyConfig(
|
||||
providerId: string,
|
||||
updates: Record<string, unknown>
|
||||
) {
|
||||
const db = getDbInstance();
|
||||
const current = await getUpstreamProxyConfig(providerId);
|
||||
if (!current) {
|
||||
throw new Error(`Provider ${providerId} not found`);
|
||||
}
|
||||
|
||||
const sets: string[] = ["updated_at = datetime('now')"];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (updates.mode !== undefined) {
|
||||
sets.push("mode = ?");
|
||||
params.push(updates.mode);
|
||||
}
|
||||
if (updates.cliproxyapiModelMapping !== undefined) {
|
||||
sets.push("cliproxyapi_model_mapping = ?");
|
||||
params.push(
|
||||
updates.cliproxyapiModelMapping === null
|
||||
? null
|
||||
: JSON.stringify(updates.cliproxyapiModelMapping)
|
||||
);
|
||||
}
|
||||
if (updates.nativePriority !== undefined) {
|
||||
sets.push("native_priority = ?");
|
||||
params.push(updates.nativePriority);
|
||||
}
|
||||
if (updates.cliproxyapiPriority !== undefined) {
|
||||
sets.push("cliproxyapi_priority = ?");
|
||||
params.push(updates.cliproxyapiPriority);
|
||||
}
|
||||
if (updates.enabled !== undefined) {
|
||||
sets.push("enabled = ?");
|
||||
params.push(updates.enabled === true ? 1 : 0);
|
||||
}
|
||||
|
||||
params.push(providerId);
|
||||
db.prepare(`UPDATE upstream_proxy_config SET ${sets.join(", ")} WHERE provider_id = ?`).run(
|
||||
...params
|
||||
);
|
||||
|
||||
return getUpstreamProxyConfig(providerId);
|
||||
}
|
||||
|
||||
export async function deleteUpstreamProxyConfig(providerId: string) {
|
||||
const db = getDbInstance();
|
||||
const result = db
|
||||
.prepare("DELETE FROM upstream_proxy_config WHERE provider_id = ?")
|
||||
.run(providerId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export async function getProvidersByMode(mode: string) {
|
||||
const db = getDbInstance();
|
||||
const rows = db
|
||||
.prepare(
|
||||
"SELECT * FROM upstream_proxy_config WHERE mode = ? AND enabled = 1 ORDER BY provider_id"
|
||||
)
|
||||
.all(mode) as UpstreamProxyRow[];
|
||||
return rows.map((row) => rowToConfig(toRecord(row)));
|
||||
}
|
||||
|
||||
export async function getFallbackChainForProvider(providerId: string) {
|
||||
const config = await getUpstreamProxyConfig(providerId);
|
||||
if (!config) return [];
|
||||
|
||||
const chain: { executor: "native" | "cliproxyapi"; priority: number }[] = [];
|
||||
|
||||
if (config.enabled) {
|
||||
chain.push({ executor: "native", priority: config.nativePriority });
|
||||
if (config.mode === "cliproxyapi" || config.mode === "fallback") {
|
||||
chain.push({ executor: "cliproxyapi", priority: config.cliproxyapiPriority });
|
||||
}
|
||||
}
|
||||
|
||||
chain.sort((a, b) => a.priority - b.priority);
|
||||
return chain;
|
||||
}
|
||||
300
src/lib/db/versionManager.ts
Normal file
300
src/lib/db/versionManager.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
/** Version manager tool state persistence. */
|
||||
|
||||
import { getDbInstance } from "./core";
|
||||
|
||||
interface VersionManagerRow {
|
||||
id?: unknown;
|
||||
tool?: unknown;
|
||||
current_version?: unknown;
|
||||
installed_version?: unknown;
|
||||
pinned_version?: unknown;
|
||||
binary_path?: unknown;
|
||||
status?: unknown;
|
||||
pid?: unknown;
|
||||
port?: unknown;
|
||||
api_key?: unknown;
|
||||
management_key?: unknown;
|
||||
auto_update?: unknown;
|
||||
auto_start?: unknown;
|
||||
last_health_check?: unknown;
|
||||
last_update_check?: unknown;
|
||||
health_status?: unknown;
|
||||
config_overrides?: unknown;
|
||||
error_message?: unknown;
|
||||
created_at?: unknown;
|
||||
updated_at?: unknown;
|
||||
}
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function parseConfigOverrides(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "string" || value.trim() === "") return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyConfigOverrides(value: Record<string, unknown> | null): string | null {
|
||||
if (value === null) return null;
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface VersionManagerTool {
|
||||
id: number;
|
||||
tool: string;
|
||||
currentVersion: string | null;
|
||||
installedVersion: string | null;
|
||||
pinnedVersion: string | null;
|
||||
binaryPath: string | null;
|
||||
status: string;
|
||||
pid: number | null;
|
||||
port: number;
|
||||
apiKey: string | null;
|
||||
managementKey: string | null;
|
||||
autoUpdate: boolean;
|
||||
autoStart: boolean;
|
||||
lastHealthCheck: string | null;
|
||||
lastUpdateCheck: string | null;
|
||||
healthStatus: string;
|
||||
configOverrides: Record<string, unknown> | null;
|
||||
errorMessage: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function rowToVersionManager(row: VersionManagerRow): VersionManagerTool {
|
||||
const record = toRecord(row);
|
||||
return {
|
||||
id: typeof record.id === "number" ? record.id : 0,
|
||||
tool: typeof record.tool === "string" ? record.tool : "",
|
||||
currentVersion:
|
||||
record.current_version === null
|
||||
? null
|
||||
: typeof record.current_version === "string"
|
||||
? record.current_version
|
||||
: null,
|
||||
installedVersion:
|
||||
record.installed_version === null
|
||||
? null
|
||||
: typeof record.installed_version === "string"
|
||||
? record.installed_version
|
||||
: null,
|
||||
pinnedVersion:
|
||||
record.pinned_version === null
|
||||
? null
|
||||
: typeof record.pinned_version === "string"
|
||||
? record.pinned_version
|
||||
: null,
|
||||
binaryPath:
|
||||
record.binary_path === null
|
||||
? null
|
||||
: typeof record.binary_path === "string"
|
||||
? record.binary_path
|
||||
: null,
|
||||
status: typeof record.status === "string" ? record.status : "not_installed",
|
||||
pid: record.pid === null ? null : typeof record.pid === "number" ? record.pid : null,
|
||||
port: typeof record.port === "number" ? record.port : 8317,
|
||||
apiKey:
|
||||
record.api_key === null ? null : typeof record.api_key === "string" ? record.api_key : null,
|
||||
managementKey:
|
||||
record.management_key === null
|
||||
? null
|
||||
: typeof record.management_key === "string"
|
||||
? record.management_key
|
||||
: null,
|
||||
autoUpdate:
|
||||
record.auto_update === 1 || record.auto_update === true || record.auto_update === "1",
|
||||
autoStart: record.auto_start === 1 || record.auto_start === true || record.auto_start === "1",
|
||||
lastHealthCheck:
|
||||
record.last_health_check === null
|
||||
? null
|
||||
: typeof record.last_health_check === "string"
|
||||
? record.last_health_check
|
||||
: null,
|
||||
lastUpdateCheck:
|
||||
record.last_update_check === null
|
||||
? null
|
||||
: typeof record.last_update_check === "string"
|
||||
? record.last_update_check
|
||||
: null,
|
||||
healthStatus: typeof record.health_status === "string" ? record.health_status : "unknown",
|
||||
configOverrides: parseConfigOverrides(record.config_overrides),
|
||||
errorMessage:
|
||||
record.error_message === null
|
||||
? null
|
||||
: typeof record.error_message === "string"
|
||||
? record.error_message
|
||||
: null,
|
||||
createdAt: typeof record.created_at === "string" ? record.created_at : "",
|
||||
updatedAt: typeof record.updated_at === "string" ? record.updated_at : "",
|
||||
};
|
||||
}
|
||||
|
||||
export async function getVersionManagerStatus(): Promise<VersionManagerTool[]> {
|
||||
const db = getDbInstance();
|
||||
const rows = db.prepare("SELECT * FROM version_manager").all() as VersionManagerRow[];
|
||||
return rows.map(rowToVersionManager);
|
||||
}
|
||||
|
||||
export async function getVersionManagerTool(tool: string): Promise<VersionManagerTool | null> {
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT * FROM version_manager WHERE tool = ?").get(tool) as
|
||||
| VersionManagerRow
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
return rowToVersionManager(row);
|
||||
}
|
||||
|
||||
export async function upsertVersionManagerTool(data: {
|
||||
tool: string;
|
||||
currentVersion?: string | null;
|
||||
installedVersion?: string | null;
|
||||
pinnedVersion?: string | null;
|
||||
binaryPath?: string | null;
|
||||
status?: string;
|
||||
pid?: number | null;
|
||||
port?: number;
|
||||
apiKey?: string | null;
|
||||
managementKey?: string | null;
|
||||
autoUpdate?: boolean;
|
||||
autoStart?: boolean;
|
||||
healthStatus?: string;
|
||||
configOverrides?: Record<string, unknown> | null;
|
||||
errorMessage?: string | null;
|
||||
}): Promise<VersionManagerTool> {
|
||||
const db = getDbInstance();
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO version_manager (
|
||||
tool, current_version, installed_version, pinned_version, binary_path,
|
||||
status, pid, port, api_key, management_key, auto_update, auto_start,
|
||||
health_status, config_overrides, error_message, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
ON CONFLICT(tool) DO UPDATE SET
|
||||
current_version = excluded.current_version,
|
||||
installed_version = excluded.installed_version,
|
||||
pinned_version = excluded.pinned_version,
|
||||
binary_path = excluded.binary_path,
|
||||
status = excluded.status,
|
||||
pid = excluded.pid,
|
||||
port = excluded.port,
|
||||
api_key = excluded.api_key,
|
||||
management_key = excluded.management_key,
|
||||
auto_update = excluded.auto_update,
|
||||
auto_start = excluded.auto_start,
|
||||
health_status = excluded.health_status,
|
||||
config_overrides = excluded.config_overrides,
|
||||
error_message = excluded.error_message,
|
||||
updated_at = datetime('now')
|
||||
`
|
||||
).run(
|
||||
data.tool,
|
||||
data.currentVersion ?? null,
|
||||
data.installedVersion ?? null,
|
||||
data.pinnedVersion ?? null,
|
||||
data.binaryPath ?? null,
|
||||
data.status ?? "not_installed",
|
||||
data.pid ?? null,
|
||||
data.port ?? 8317,
|
||||
data.apiKey ?? null,
|
||||
data.managementKey ?? null,
|
||||
data.autoUpdate !== undefined ? (data.autoUpdate ? 1 : 0) : 1,
|
||||
data.autoStart !== undefined ? (data.autoStart ? 1 : 0) : 0,
|
||||
data.healthStatus ?? "unknown",
|
||||
stringifyConfigOverrides(data.configOverrides ?? null),
|
||||
data.errorMessage ?? null
|
||||
);
|
||||
const result = await getVersionManagerTool(data.tool);
|
||||
if (!result) throw new Error("Failed to retrieve inserted version manager tool");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function updateVersionManagerTool(
|
||||
tool: string,
|
||||
updates: Record<string, unknown>
|
||||
): Promise<VersionManagerTool | null> {
|
||||
const db = getDbInstance();
|
||||
const existing = await getVersionManagerTool(tool);
|
||||
if (!existing) return null;
|
||||
|
||||
const sets: string[] = ["updated_at = datetime('now')"];
|
||||
const params: Record<string, unknown> = { tool };
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
const dbKey = key.replace(/([A-Z])/g, "_$1").toLowerCase();
|
||||
|
||||
if (key === "configOverrides") {
|
||||
sets.push("config_overrides = @configOverrides");
|
||||
params.configOverrides = stringifyConfigOverrides(value as Record<string, unknown> | null);
|
||||
} else if (key === "autoUpdate" || key === "autoStart") {
|
||||
sets.push(`${dbKey} = @${key}`);
|
||||
params[key] = value === true ? 1 : 0;
|
||||
} else if (value === null) {
|
||||
sets.push(`${dbKey} = null`);
|
||||
} else {
|
||||
sets.push(`${dbKey} = @${key}`);
|
||||
params[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(`UPDATE version_manager SET ${sets.join(", ")} WHERE tool = @tool`).run(params);
|
||||
return getVersionManagerTool(tool);
|
||||
}
|
||||
|
||||
export async function deleteVersionManagerTool(tool: string): Promise<boolean> {
|
||||
const db = getDbInstance();
|
||||
const result = db.prepare("DELETE FROM version_manager WHERE tool = ?").run(tool);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export async function updateToolHealth(tool: string, healthStatus: string): Promise<boolean> {
|
||||
const db = getDbInstance();
|
||||
const result = db
|
||||
.prepare(
|
||||
"UPDATE version_manager SET health_status = ?, last_health_check = datetime('now') WHERE tool = ?"
|
||||
)
|
||||
.run(healthStatus, tool);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export async function updateToolVersion(
|
||||
tool: string,
|
||||
field: "current_version" | "installed_version",
|
||||
version: string
|
||||
): Promise<boolean> {
|
||||
const db = getDbInstance();
|
||||
const result = db
|
||||
.prepare(`UPDATE version_manager SET ${field} = ?, updated_at = datetime('now') WHERE tool = ?`)
|
||||
.run(version, tool);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export async function setToolStatus(
|
||||
tool: string,
|
||||
status: string,
|
||||
pid?: number,
|
||||
errorMessage?: string
|
||||
): Promise<boolean> {
|
||||
const db = getDbInstance();
|
||||
const result = db
|
||||
.prepare(
|
||||
pid !== undefined
|
||||
? "UPDATE version_manager SET status = ?, pid = ?, error_message = ?, updated_at = datetime('now') WHERE tool = ?"
|
||||
: "UPDATE version_manager SET status = ?, error_message = ?, updated_at = datetime('now') WHERE tool = ?"
|
||||
)
|
||||
.run(
|
||||
...(pid !== undefined
|
||||
? [status, pid, errorMessage ?? null, tool]
|
||||
: [status, errorMessage ?? null, tool])
|
||||
);
|
||||
return result.changes > 0;
|
||||
}
|
||||
@@ -210,6 +210,28 @@ export {
|
||||
|
||||
export type { QuotaSnapshotRow, ProviderUtilizationPoint } from "@/shared/types/utilization";
|
||||
|
||||
export {
|
||||
getVersionManagerStatus,
|
||||
getVersionManagerTool,
|
||||
upsertVersionManagerTool,
|
||||
updateVersionManagerTool,
|
||||
deleteVersionManagerTool,
|
||||
updateToolHealth,
|
||||
updateToolVersion,
|
||||
setToolStatus,
|
||||
} from "./db/versionManager";
|
||||
|
||||
export {
|
||||
getUpstreamProxyConfigs,
|
||||
getUpstreamProxyConfig,
|
||||
upsertUpstreamProxyConfig,
|
||||
updateUpstreamProxyConfig,
|
||||
deleteUpstreamProxyConfig,
|
||||
getProvidersByMode,
|
||||
getFallbackChainForProvider,
|
||||
validateProxyUrl,
|
||||
} from "./db/upstreamProxy";
|
||||
|
||||
export {
|
||||
getProviderLimitsCache,
|
||||
getAllProviderLimitsCache,
|
||||
|
||||
@@ -611,12 +611,35 @@ export function isAnthropicCompatibleProvider(providerId) {
|
||||
return typeof providerId === "string" && providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
|
||||
}
|
||||
|
||||
export const UPSTREAM_PROXY_PROVIDERS = {
|
||||
cliproxyapi: {
|
||||
id: "cliproxyapi",
|
||||
alias: "cpa",
|
||||
name: "CLIProxyAPI",
|
||||
icon: "proxy",
|
||||
color: "#6366F1",
|
||||
textIcon: "CPA",
|
||||
website: "https://github.com/router-for-me/CLIProxyAPI",
|
||||
defaultPort: 8317,
|
||||
healthEndpoint: "/v1/models",
|
||||
managementPrefix: "/v0/management",
|
||||
configDir: "~/.cli-proxy-api",
|
||||
binaryName: "cli-proxy-api",
|
||||
githubRepo: "router-for-me/CLIProxyAPI",
|
||||
},
|
||||
};
|
||||
|
||||
export function isClaudeCodeCompatibleProvider(providerId) {
|
||||
return typeof providerId === "string" && providerId.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX);
|
||||
}
|
||||
|
||||
// All providers (combined)
|
||||
export const AI_PROVIDERS = { ...FREE_PROVIDERS, ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS };
|
||||
export const AI_PROVIDERS = {
|
||||
...FREE_PROVIDERS,
|
||||
...OAUTH_PROVIDERS,
|
||||
...APIKEY_PROVIDERS,
|
||||
...UPSTREAM_PROXY_PROVIDERS,
|
||||
};
|
||||
|
||||
// Auth methods
|
||||
export const AUTH_METHODS = {
|
||||
|
||||
@@ -1320,3 +1320,11 @@ export const updateAutoDisableAccountsSchema = z
|
||||
threshold: z.number().int().min(1).max(10).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const versionManagerToolSchema = z.object({
|
||||
tool: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const versionManagerInstallSchema = versionManagerToolSchema.extend({
|
||||
version: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
340
tests/unit/db-upstreamProxy.test.mjs
Normal file
340
tests/unit/db-upstreamProxy.test.mjs
Normal file
@@ -0,0 +1,340 @@
|
||||
import { describe, it, beforeEach, afterEach, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
const fileTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-dbupc-test-"));
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS upstream_proxy_config (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider_id TEXT NOT NULL UNIQUE,
|
||||
mode TEXT NOT NULL DEFAULT 'native',
|
||||
cliproxyapi_model_mapping TEXT,
|
||||
native_priority INTEGER NOT NULL DEFAULT 1,
|
||||
cliproxyapi_priority INTEGER NOT NULL DEFAULT 2,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`;
|
||||
|
||||
let testDb;
|
||||
let testDbPath;
|
||||
|
||||
beforeEach(() => {
|
||||
testDbPath = path.join(fileTmpDir, `test-${Date.now()}.db`);
|
||||
testDb = new Database(testDbPath);
|
||||
testDb.exec(SCHEMA);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
testDb.close();
|
||||
try {
|
||||
fs.unlinkSync(testDbPath);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (fs.existsSync(fileTmpDir)) fs.rmSync(fileTmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function upsert(db, data) {
|
||||
db.prepare(
|
||||
`
|
||||
INSERT OR REPLACE INTO upstream_proxy_config
|
||||
(provider_id, mode, cliproxyapi_model_mapping, native_priority, cliproxyapi_priority, enabled, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
`
|
||||
).run(
|
||||
data.providerId,
|
||||
data.mode ?? "native",
|
||||
data.cliproxyapiModelMapping !== undefined
|
||||
? JSON.stringify(data.cliproxyapiModelMapping)
|
||||
: null,
|
||||
data.nativePriority ?? 1,
|
||||
data.cliproxyapiPriority ?? 2,
|
||||
data.enabled !== false ? 1 : 0
|
||||
);
|
||||
return getConfig(db, data.providerId);
|
||||
}
|
||||
|
||||
function getConfig(db, providerId) {
|
||||
const row = db
|
||||
.prepare("SELECT * FROM upstream_proxy_config WHERE provider_id = ?")
|
||||
.get(providerId);
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
providerId: row.provider_id,
|
||||
mode: row.mode,
|
||||
cliproxyapiModelMapping:
|
||||
row.cliproxyapi_model_mapping && typeof row.cliproxyapi_model_mapping === "string"
|
||||
? JSON.parse(row.cliproxyapi_model_mapping)
|
||||
: null,
|
||||
nativePriority: row.native_priority,
|
||||
cliproxyapiPriority: row.cliproxyapi_priority,
|
||||
enabled: row.enabled === 1,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function getAllConfigs(db) {
|
||||
return db
|
||||
.prepare("SELECT * FROM upstream_proxy_config ORDER BY provider_id")
|
||||
.all()
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
providerId: r.provider_id,
|
||||
mode: r.mode,
|
||||
cliproxyapiModelMapping:
|
||||
r.cliproxyapi_model_mapping && typeof r.cliproxyapi_model_mapping === "string"
|
||||
? JSON.parse(r.cliproxyapi_model_mapping)
|
||||
: null,
|
||||
nativePriority: r.native_priority,
|
||||
cliproxyapiPriority: r.cliproxyapi_priority,
|
||||
enabled: r.enabled === 1,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
function getProvidersByMode(db, mode) {
|
||||
return db
|
||||
.prepare(
|
||||
"SELECT * FROM upstream_proxy_config WHERE mode = ? AND enabled = 1 ORDER BY provider_id"
|
||||
)
|
||||
.all(mode)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
providerId: r.provider_id,
|
||||
mode: r.mode,
|
||||
cliproxyapiModelMapping: r.cliproxyapi_model_mapping
|
||||
? JSON.parse(r.cliproxyapi_model_mapping)
|
||||
: null,
|
||||
nativePriority: r.native_priority,
|
||||
cliproxyapiPriority: r.cliproxyapi_priority,
|
||||
enabled: r.enabled === 1,
|
||||
}));
|
||||
}
|
||||
|
||||
function getFallbackChain(db, providerId) {
|
||||
const config = getConfig(db, providerId);
|
||||
if (!config) return [];
|
||||
const chain = [];
|
||||
if (config.enabled) {
|
||||
chain.push({ executor: "native", priority: config.nativePriority });
|
||||
if (config.mode === "cliproxyapi" || config.mode === "fallback") {
|
||||
chain.push({ executor: "cliproxyapi", priority: config.cliproxyapiPriority });
|
||||
}
|
||||
}
|
||||
chain.sort((a, b) => a.priority - b.priority);
|
||||
return chain;
|
||||
}
|
||||
|
||||
describe("db/upstreamProxy (logic)", () => {
|
||||
describe("upsertUpstreamProxyConfig", () => {
|
||||
it("should insert and read back", () => {
|
||||
const config = upsert(testDb, { providerId: "claude", mode: "native" });
|
||||
assert.equal(config.providerId, "claude");
|
||||
assert.equal(config.mode, "native");
|
||||
assert.equal(config.enabled, true);
|
||||
assert.equal(config.nativePriority, 1);
|
||||
assert.equal(config.cliproxyapiPriority, 2);
|
||||
assert.equal(config.cliproxyapiModelMapping, null);
|
||||
assert.ok(config.id > 0);
|
||||
});
|
||||
|
||||
it("should replace on conflict", () => {
|
||||
upsert(testDb, { providerId: "t", mode: "native" });
|
||||
const replaced = upsert(testDb, { providerId: "t", mode: "fallback" });
|
||||
assert.equal(replaced.mode, "fallback");
|
||||
});
|
||||
|
||||
it("should store model mapping as JSON", () => {
|
||||
const mapping = { "ag/gemini-3-pro": "gemini-3-pro-high" };
|
||||
const config = upsert(testDb, { providerId: "mapped", cliproxyapiModelMapping: mapping });
|
||||
assert.deepEqual(config.cliproxyapiModelMapping, mapping);
|
||||
});
|
||||
|
||||
it("should store null model mapping", () => {
|
||||
const config = upsert(testDb, { providerId: "nomap", cliproxyapiModelMapping: null });
|
||||
assert.equal(config.cliproxyapiModelMapping, null);
|
||||
});
|
||||
|
||||
it("should handle enabled=false", () => {
|
||||
assert.equal(upsert(testDb, { providerId: "dis", enabled: false }).enabled, false);
|
||||
});
|
||||
|
||||
it("should set custom priorities", () => {
|
||||
const config = upsert(testDb, {
|
||||
providerId: "pri",
|
||||
nativePriority: 5,
|
||||
cliproxyapiPriority: 10,
|
||||
});
|
||||
assert.equal(config.nativePriority, 5);
|
||||
assert.equal(config.cliproxyapiPriority, 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUpstreamProxyConfig", () => {
|
||||
it("should return null for non-existent provider", () => {
|
||||
assert.equal(getConfig(testDb, "ghost"), null);
|
||||
});
|
||||
|
||||
it("should return config for provider", () => {
|
||||
upsert(testDb, { providerId: "claude", mode: "fallback" });
|
||||
assert.equal(getConfig(testDb, "claude").mode, "fallback");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUpstreamProxyConfigs", () => {
|
||||
it("should return all ordered by provider_id", () => {
|
||||
upsert(testDb, { providerId: "zebra" });
|
||||
upsert(testDb, { providerId: "alpha" });
|
||||
const configs = getAllConfigs(testDb);
|
||||
assert.equal(configs.length, 2);
|
||||
assert.equal(configs[0].providerId, "alpha");
|
||||
assert.equal(configs[1].providerId, "zebra");
|
||||
});
|
||||
|
||||
it("should return empty array", () => {
|
||||
assert.equal(getAllConfigs(testDb).length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateUpstreamProxyConfig", () => {
|
||||
it("should update individual fields", () => {
|
||||
upsert(testDb, { providerId: "u", mode: "native" });
|
||||
testDb
|
||||
.prepare(
|
||||
"UPDATE upstream_proxy_config SET mode = ?, updated_at = datetime('now') WHERE provider_id = ?"
|
||||
)
|
||||
.run("cliproxyapi", "u");
|
||||
assert.equal(getConfig(testDb, "u").mode, "cliproxyapi");
|
||||
});
|
||||
|
||||
it("should update model mapping", () => {
|
||||
upsert(testDb, { providerId: "u2" });
|
||||
testDb
|
||||
.prepare(
|
||||
"UPDATE upstream_proxy_config SET cliproxyapi_model_mapping = ?, updated_at = datetime('now') WHERE provider_id = ?"
|
||||
)
|
||||
.run(JSON.stringify({ k: "v" }), "u2");
|
||||
assert.deepEqual(getConfig(testDb, "u2").cliproxyapiModelMapping, { k: "v" });
|
||||
});
|
||||
|
||||
it("should update multiple fields", () => {
|
||||
upsert(testDb, { providerId: "m" });
|
||||
testDb
|
||||
.prepare(
|
||||
"UPDATE upstream_proxy_config SET mode = ?, native_priority = ?, enabled = ?, updated_at = datetime('now') WHERE provider_id = ?"
|
||||
)
|
||||
.run("fallback", 3, 0, "m");
|
||||
const config = getConfig(testDb, "m");
|
||||
assert.equal(config.mode, "fallback");
|
||||
assert.equal(config.nativePriority, 3);
|
||||
assert.equal(config.enabled, false);
|
||||
});
|
||||
|
||||
it("should set model mapping to null", () => {
|
||||
upsert(testDb, { providerId: "n", cliproxyapiModelMapping: { a: 1 } });
|
||||
testDb
|
||||
.prepare(
|
||||
"UPDATE upstream_proxy_config SET cliproxyapi_model_mapping = NULL WHERE provider_id = ?"
|
||||
)
|
||||
.run("n");
|
||||
assert.equal(getConfig(testDb, "n").cliproxyapiModelMapping, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteUpstreamProxyConfig", () => {
|
||||
it("should delete existing config", () => {
|
||||
upsert(testDb, { providerId: "del" });
|
||||
assert.equal(
|
||||
testDb.prepare("DELETE FROM upstream_proxy_config WHERE provider_id = ?").run("del")
|
||||
.changes,
|
||||
1
|
||||
);
|
||||
assert.equal(getConfig(testDb, "del"), null);
|
||||
});
|
||||
|
||||
it("should return 0 for non-existent", () => {
|
||||
assert.equal(
|
||||
testDb.prepare("DELETE FROM upstream_proxy_config WHERE provider_id = ?").run("ghost")
|
||||
.changes,
|
||||
0
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProvidersByMode", () => {
|
||||
it("should filter by mode and enabled", () => {
|
||||
upsert(testDb, { providerId: "p1", mode: "fallback", enabled: true });
|
||||
upsert(testDb, { providerId: "p2", mode: "fallback", enabled: true });
|
||||
upsert(testDb, { providerId: "p3", mode: "native", enabled: true });
|
||||
upsert(testDb, { providerId: "p4", mode: "fallback", enabled: false });
|
||||
const results = getProvidersByMode(testDb, "fallback");
|
||||
assert.equal(results.length, 2);
|
||||
assert.ok(results.every((r) => r.enabled));
|
||||
});
|
||||
|
||||
it("should return empty for no matches", () => {
|
||||
assert.equal(getProvidersByMode(testDb, "cliproxyapi").length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFallbackChainForProvider", () => {
|
||||
it("should return empty for non-existent provider", () => {
|
||||
assert.deepEqual(getFallbackChain(testDb, "ghost"), []);
|
||||
});
|
||||
|
||||
it("should return native-only for native mode", () => {
|
||||
upsert(testDb, { providerId: "native-only", mode: "native" });
|
||||
const chain = getFallbackChain(testDb, "native-only");
|
||||
assert.equal(chain.length, 1);
|
||||
assert.equal(chain[0].executor, "native");
|
||||
});
|
||||
|
||||
it("should return native+cliproxyapi for fallback mode", () => {
|
||||
upsert(testDb, {
|
||||
providerId: "fb",
|
||||
mode: "fallback",
|
||||
nativePriority: 1,
|
||||
cliproxyapiPriority: 2,
|
||||
});
|
||||
const chain = getFallbackChain(testDb, "fb");
|
||||
assert.equal(chain.length, 2);
|
||||
assert.equal(chain[0].executor, "native");
|
||||
assert.equal(chain[1].executor, "cliproxyapi");
|
||||
});
|
||||
|
||||
it("should return both for cliproxyapi mode", () => {
|
||||
upsert(testDb, { providerId: "cpa", mode: "cliproxyapi" });
|
||||
const chain = getFallbackChain(testDb, "cpa");
|
||||
assert.equal(chain.length, 2);
|
||||
});
|
||||
|
||||
it("should sort by priority", () => {
|
||||
upsert(testDb, {
|
||||
providerId: "rev",
|
||||
mode: "fallback",
|
||||
nativePriority: 10,
|
||||
cliproxyapiPriority: 1,
|
||||
});
|
||||
const chain = getFallbackChain(testDb, "rev");
|
||||
assert.equal(chain[0].executor, "cliproxyapi");
|
||||
assert.equal(chain[0].priority, 1);
|
||||
assert.equal(chain[1].executor, "native");
|
||||
assert.equal(chain[1].priority, 10);
|
||||
});
|
||||
|
||||
it("should return empty chain when disabled", () => {
|
||||
upsert(testDb, { providerId: "dis-fb", mode: "fallback", enabled: false });
|
||||
assert.deepEqual(getFallbackChain(testDb, "dis-fb"), []);
|
||||
});
|
||||
});
|
||||
});
|
||||
368
tests/unit/db-versionManager.test.mjs
Normal file
368
tests/unit/db-versionManager.test.mjs
Normal file
@@ -0,0 +1,368 @@
|
||||
import { describe, it, beforeEach, afterEach, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
const fileTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-dbvm-test-"));
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS version_manager (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tool TEXT NOT NULL UNIQUE,
|
||||
current_version TEXT,
|
||||
installed_version TEXT,
|
||||
pinned_version TEXT,
|
||||
binary_path TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'not_installed',
|
||||
pid INTEGER,
|
||||
port INTEGER DEFAULT 8317,
|
||||
api_key TEXT,
|
||||
management_key TEXT,
|
||||
auto_update INTEGER NOT NULL DEFAULT 1,
|
||||
auto_start INTEGER NOT NULL DEFAULT 0,
|
||||
last_health_check TEXT,
|
||||
last_update_check TEXT,
|
||||
health_status TEXT DEFAULT 'unknown',
|
||||
config_overrides TEXT,
|
||||
error_message TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`;
|
||||
|
||||
let testDb;
|
||||
let testDbPath;
|
||||
|
||||
beforeEach(() => {
|
||||
testDbPath = path.join(fileTmpDir, `test-${Date.now()}.db`);
|
||||
testDb = new Database(testDbPath);
|
||||
testDb.exec(SCHEMA);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
testDb.close();
|
||||
try {
|
||||
fs.unlinkSync(testDbPath);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (fs.existsSync(fileTmpDir)) fs.rmSync(fileTmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function upsertTool(db, data) {
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO version_manager (
|
||||
tool, current_version, installed_version, pinned_version, binary_path,
|
||||
status, pid, port, api_key, management_key, auto_update, auto_start,
|
||||
health_status, config_overrides, error_message, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
ON CONFLICT(tool) DO UPDATE SET
|
||||
current_version = excluded.current_version,
|
||||
installed_version = excluded.installed_version,
|
||||
pinned_version = excluded.pinned_version,
|
||||
binary_path = excluded.binary_path,
|
||||
status = excluded.status,
|
||||
pid = excluded.pid,
|
||||
port = excluded.port,
|
||||
api_key = excluded.api_key,
|
||||
management_key = excluded.management_key,
|
||||
auto_update = excluded.auto_update,
|
||||
auto_start = excluded.auto_start,
|
||||
health_status = excluded.health_status,
|
||||
config_overrides = excluded.config_overrides,
|
||||
error_message = excluded.error_message,
|
||||
updated_at = datetime('now')
|
||||
`
|
||||
).run(
|
||||
data.tool,
|
||||
data.currentVersion ?? null,
|
||||
data.installedVersion ?? null,
|
||||
data.pinnedVersion ?? null,
|
||||
data.binaryPath ?? null,
|
||||
data.status ?? "not_installed",
|
||||
data.pid ?? null,
|
||||
data.port ?? 8317,
|
||||
data.apiKey ?? null,
|
||||
data.managementKey ?? null,
|
||||
data.autoUpdate !== undefined ? (data.autoUpdate ? 1 : 0) : 1,
|
||||
data.autoStart !== undefined ? (data.autoStart ? 1 : 0) : 0,
|
||||
data.healthStatus ?? "unknown",
|
||||
data.configOverrides !== undefined
|
||||
? data.configOverrides
|
||||
? JSON.stringify(data.configOverrides)
|
||||
: null
|
||||
: null,
|
||||
data.errorMessage ?? null
|
||||
);
|
||||
return db.prepare("SELECT * FROM version_manager WHERE tool = ?").get(data.tool);
|
||||
}
|
||||
|
||||
function parseConfigOverrides(value) {
|
||||
if (!value || typeof value !== "string" || value.trim() === "") return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function toTool(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
tool: row.tool,
|
||||
currentVersion: row.current_version,
|
||||
installedVersion: row.installed_version,
|
||||
pinnedVersion: row.pinned_version,
|
||||
binaryPath: row.binary_path,
|
||||
status: row.status,
|
||||
pid: row.pid,
|
||||
port: row.port,
|
||||
apiKey: row.api_key,
|
||||
managementKey: row.management_key,
|
||||
autoUpdate: row.auto_update === 1,
|
||||
autoStart: row.auto_start === 1,
|
||||
lastHealthCheck: row.last_health_check,
|
||||
lastUpdateCheck: row.last_update_check,
|
||||
healthStatus: row.health_status,
|
||||
configOverrides: parseConfigOverrides(row.config_overrides),
|
||||
errorMessage: row.error_message,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
describe("db/versionManager (logic)", () => {
|
||||
describe("upsertVersionManagerTool", () => {
|
||||
it("should insert a new tool and read it back", () => {
|
||||
const tool = toTool(
|
||||
upsertTool(testDb, {
|
||||
tool: "cliproxyapi",
|
||||
installedVersion: "6.9.7",
|
||||
binaryPath: "/tmp/bin/cliproxyapi",
|
||||
status: "installed",
|
||||
})
|
||||
);
|
||||
assert.equal(tool.tool, "cliproxyapi");
|
||||
assert.equal(tool.installedVersion, "6.9.7");
|
||||
assert.equal(tool.binaryPath, "/tmp/bin/cliproxyapi");
|
||||
assert.equal(tool.status, "installed");
|
||||
assert.equal(tool.port, 8317);
|
||||
assert.equal(tool.autoUpdate, true);
|
||||
assert.equal(tool.autoStart, false);
|
||||
assert.equal(tool.healthStatus, "unknown");
|
||||
assert.ok(tool.id > 0);
|
||||
});
|
||||
|
||||
it("should update on conflict (upsert)", () => {
|
||||
upsertTool(testDb, { tool: "test-tool", status: "installed" });
|
||||
const tool = toTool(
|
||||
upsertTool(testDb, { tool: "test-tool", installedVersion: "7.0.0", status: "running" })
|
||||
);
|
||||
assert.equal(tool.installedVersion, "7.0.0");
|
||||
assert.equal(tool.status, "running");
|
||||
});
|
||||
|
||||
it("should store boolean fields as 0/1", () => {
|
||||
const tool = toTool(
|
||||
upsertTool(testDb, { tool: "bool-test", autoUpdate: false, autoStart: true })
|
||||
);
|
||||
assert.equal(tool.autoUpdate, false);
|
||||
assert.equal(tool.autoStart, true);
|
||||
});
|
||||
|
||||
it("should store config overrides as JSON", () => {
|
||||
const tool = toTool(upsertTool(testDb, { tool: "cfg", configOverrides: { port: 9999 } }));
|
||||
assert.deepEqual(tool.configOverrides, { port: 9999 });
|
||||
});
|
||||
|
||||
it("should handle null config overrides", () => {
|
||||
const tool = toTool(upsertTool(testDb, { tool: "null-cfg" }));
|
||||
assert.equal(tool.configOverrides, null);
|
||||
});
|
||||
|
||||
it("should handle pid and error message", () => {
|
||||
const tool = toTool(
|
||||
upsertTool(testDb, { tool: "pid-test", pid: 12345, errorMessage: "err" })
|
||||
);
|
||||
assert.equal(tool.pid, 12345);
|
||||
assert.equal(tool.errorMessage, "err");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getVersionManagerTool", () => {
|
||||
it("should return null for non-existent tool", () => {
|
||||
assert.equal(
|
||||
toTool(testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("ghost")),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
it("should return tool by name", () => {
|
||||
upsertTool(testDb, { tool: "findme", installedVersion: "1.0.0" });
|
||||
const tool = toTool(
|
||||
testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("findme")
|
||||
);
|
||||
assert.equal(tool.installedVersion, "1.0.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getVersionManagerStatus", () => {
|
||||
it("should return all tools", () => {
|
||||
upsertTool(testDb, { tool: "a" });
|
||||
upsertTool(testDb, { tool: "b" });
|
||||
assert.equal(testDb.prepare("SELECT * FROM version_manager").all().length, 2);
|
||||
});
|
||||
|
||||
it("should return empty when no tools", () => {
|
||||
assert.equal(testDb.prepare("SELECT * FROM version_manager").all().length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateVersionManagerTool (partial)", () => {
|
||||
it("should update pinnedVersion and autoUpdate", () => {
|
||||
upsertTool(testDb, { tool: "upd", status: "installed" });
|
||||
testDb
|
||||
.prepare(
|
||||
"UPDATE version_manager SET pinned_version = ?, auto_update = ?, updated_at = datetime('now') WHERE tool = ?"
|
||||
)
|
||||
.run("6.8.0", 0, "upd");
|
||||
const tool = toTool(
|
||||
testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("upd")
|
||||
);
|
||||
assert.equal(tool.pinnedVersion, "6.8.0");
|
||||
assert.equal(tool.autoUpdate, false);
|
||||
assert.equal(tool.status, "installed");
|
||||
});
|
||||
|
||||
it("should set fields to null", () => {
|
||||
upsertTool(testDb, { tool: "nulls", installedVersion: "1.0.0", pid: 42 });
|
||||
testDb
|
||||
.prepare("UPDATE version_manager SET installed_version = NULL, pid = NULL WHERE tool = ?")
|
||||
.run("nulls");
|
||||
const tool = toTool(
|
||||
testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("nulls")
|
||||
);
|
||||
assert.equal(tool.installedVersion, null);
|
||||
assert.equal(tool.pid, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteVersionManagerTool", () => {
|
||||
it("should delete existing tool", () => {
|
||||
upsertTool(testDb, { tool: "del" });
|
||||
assert.equal(
|
||||
testDb.prepare("DELETE FROM version_manager WHERE tool = ?").run("del").changes,
|
||||
1
|
||||
);
|
||||
assert.equal(
|
||||
testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("del"),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it("should return 0 changes for non-existent", () => {
|
||||
assert.equal(
|
||||
testDb.prepare("DELETE FROM version_manager WHERE tool = ?").run("ghost").changes,
|
||||
0
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateToolHealth", () => {
|
||||
it("should update health_status and last_health_check", () => {
|
||||
upsertTool(testDb, { tool: "h" });
|
||||
const r = testDb
|
||||
.prepare(
|
||||
"UPDATE version_manager SET health_status = ?, last_health_check = datetime('now') WHERE tool = ?"
|
||||
)
|
||||
.run("healthy", "h");
|
||||
assert.equal(r.changes, 1);
|
||||
const tool = toTool(testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("h"));
|
||||
assert.equal(tool.healthStatus, "healthy");
|
||||
assert.ok(tool.lastHealthCheck);
|
||||
});
|
||||
|
||||
it("should return 0 changes for non-existent", () => {
|
||||
assert.equal(
|
||||
testDb
|
||||
.prepare("UPDATE version_manager SET health_status = ? WHERE tool = ?")
|
||||
.run("healthy", "ghost").changes,
|
||||
0
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateToolVersion", () => {
|
||||
it("should update current_version", () => {
|
||||
upsertTool(testDb, { tool: "v1" });
|
||||
testDb
|
||||
.prepare("UPDATE version_manager SET current_version = ? WHERE tool = ?")
|
||||
.run("7.0.0", "v1");
|
||||
assert.equal(
|
||||
testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("v1").current_version,
|
||||
"7.0.0"
|
||||
);
|
||||
});
|
||||
|
||||
it("should update installed_version", () => {
|
||||
upsertTool(testDb, { tool: "v2" });
|
||||
testDb
|
||||
.prepare("UPDATE version_manager SET installed_version = ? WHERE tool = ?")
|
||||
.run("7.0.0", "v2");
|
||||
assert.equal(
|
||||
testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("v2").installed_version,
|
||||
"7.0.0"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setToolStatus", () => {
|
||||
it("should update status with pid", () => {
|
||||
upsertTool(testDb, { tool: "s1" });
|
||||
testDb
|
||||
.prepare("UPDATE version_manager SET status = ?, pid = ?, error_message = ? WHERE tool = ?")
|
||||
.run("running", 9999, "ok", "s1");
|
||||
const row = testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("s1");
|
||||
assert.equal(row.status, "running");
|
||||
assert.equal(row.pid, 9999);
|
||||
assert.equal(row.error_message, "ok");
|
||||
});
|
||||
|
||||
it("should update status without pid", () => {
|
||||
upsertTool(testDb, { tool: "s2" });
|
||||
testDb
|
||||
.prepare("UPDATE version_manager SET status = ?, error_message = ? WHERE tool = ?")
|
||||
.run("error", "crashed", "s2");
|
||||
const row = testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("s2");
|
||||
assert.equal(row.status, "error");
|
||||
});
|
||||
|
||||
it("should return 0 for non-existent", () => {
|
||||
assert.equal(
|
||||
testDb
|
||||
.prepare("UPDATE version_manager SET status = ? WHERE tool = ?")
|
||||
.run("running", "ghost").changes,
|
||||
0
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseConfigOverrides", () => {
|
||||
it("should parse valid JSON", () => {
|
||||
assert.deepEqual(parseConfigOverrides('{"key":"val"}'), { key: "val" });
|
||||
});
|
||||
|
||||
it("should return null for invalid JSON", () => {
|
||||
assert.equal(parseConfigOverrides("not-json"), null);
|
||||
assert.equal(parseConfigOverrides(""), null);
|
||||
assert.equal(parseConfigOverrides(null), null);
|
||||
assert.equal(parseConfigOverrides("123"), null);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user