mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
* test: resolve typescript strictness complaints in unit tests * Update Claude Code obfuscation to version 2.1.114 (#1403) * fix(cloud-code): scope thinking stripping to executor boundaries (#1401) * fix(cloud-code): scope thinking stripping to executors * fix(cloud-code): guard antigravity normalized body * Update Claude Code obfuscation to version 2.1.114 - Update Claude Code version from 2.1.87 to 2.1.114 - Update X-Stainless-Package-Version from 0.80.0 to 0.81.0 - Add new beta flags: redact-thinking-2026-02-12, advisor-tool-2026-03-01, advanced-tool-use-2025-11-20 - Add missing headers: anthropic-version, anthropic-dangerous-direct-browser-access, x-app, X-Stainless-Timeout - Add all X-Stainless-* headers (Arch, Lang, OS, Runtime, Runtime-Version, Retry-Count) - Fix accept-encoding header: identity -> gzip, deflate, br, zstd - Add connection: keep-alive header - Update tool name mapping: add lsp, apply_patch, websearch These changes ensure that requests from OpenCode through Omniroute are indistinguishable from genuine Claude Code 2.1.114 requests, allowing proper authentication with Anthropic's API without triggering extra credits errors. * fix: resolve CodeQL password hash alert and TruffleHog CI failure --------- Co-authored-by: Randi <55005611+rdself@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: Nikolay Popov <ekklesio.dev@gmail.com> Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com> * fix(claude-code): scope obfuscation to cli clients and fix tests * docs(workflows): enforce PR merge instead of manual close * docs(changelog): update 3.6.9 notes with missing PR 1403 and fixes * docs(workflows): update generate-release to use full changelog for PR body * fix(tsc): silence baseUrl deprecation warnings for TS 5.5+ * fix(chatcore): apply proactive compression before provider translation (#1406) Integrated into release/v3.6.9 * docs(changelog): add PR 1406 * Makes text visible in dark-mode (#1409) Integrated into release/v3.6.9 * docs(changelog): add PR 1409 * chore: save local work * chore(release): sync version references to 3.6.9 * fix(codex): prevent proactive token refresh consumption and strip background parameter * ci: shard long-running suites and relax timeouts * ci: allow manual CI dispatch for release branches * feat(skills): provider-aware marketplace UX, scored AUTO injection, and memory pipeline hardening (#1411) * fix/400 for GeminiCLI(add "ref" in GEMINI_UNSUPPORTED_SCHEMA_KEYS) * feat(cc-compatible): align request shape with Claude CLI * fix(cc-compatible): add Claude CLI system skeleton for OpenAI input * preserve reasoning when translating chat to responses (#1414) Integrated into release/v3.6.9 * fix(skills): optimize AUTO scoring and include Responses input context (#1418) Integrated into release/v3.6.9 * chore: fix TS errors and update review-prs workflow * fix(api): stop sending unsupported Gemini and Codex parameters Prevent Gemini request translation from injecting default thoughtSignature values that the upstream API strictly validates and rejects. Only preserve real signatures resolved from prior upstream responses, and strip additionalProperties from Gemini function schemas to avoid 400 "Unknown name" errors. Also remove fallback-injected session_id and conversation_id fields before sending Codex requests, and restore compatibility with the legacy OUTBOUND_SSRF_GUARD_ENABLED flag when determining whether private provider URLs are allowed. Updates the Gemini translator and regression tests for issue #1410 and related 400 error cases. * fix(core): stabilization fixes for token refresh, usage translation, and testing - Update Codex token refresh detection logic - Mark provider connections invalid on unrecoverable refresh error - Fix Claude usage translation under-reporting cached tokens - Update test expectations - Update CHANGELOG.md for v3.6.9 * fix(auth): reload fresh token state and unify expiry persistence Refresh checks now re-read the latest stored provider connection before attempting rotation so they do not use stale refresh tokens captured by an earlier sweep. Token updates also persist both expiresAt and tokenExpiresAt across the health check, usage-limit refresh path, and SSE refresh flow. This keeps known token expiry metadata in sync and avoids interval-based refreshes for connections whose tokens are still valid well into the future. * fix: resolve SSRF environment static evaluation bug (#1427) Fix import aliases and strict TS typings for tests and ACP agents. * test: resolve remaining strict type errors in test files * test: fix provider service assertion for anthropic-compatible header * fix(codex): respect openaiStoreEnabled setting during native passthrough (#1432) * fix(codex): fix token refresh unrecoverable detection for expired tokens * fix(ci): restore release v3.6.9 build and flaky tests * fix(cc-compatible): trim default OpenAI system skeleton (#1433) Integrated into release/v3.6.9 * fix: prevent masked API keys from being written to CLI tool configs (#1435) * feat: mark Qwen provider as deprecated and add deprecation warning to CLI tool (#1437) * docs(changelog): comprehensive v3.6.9 update with all 59 commits since v3.6.8 * test(ci): align qwen guide settings assertions * fix(security): resolve CodeQL alert 163 for incomplete URL sanitization in Qwen CLI settings --------- Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com> Co-authored-by: Nikolay Popov <74762779+nikolay-popov-ideogram@users.noreply.github.com> Co-authored-by: Randi <55005611+rdself@users.noreply.github.com> Co-authored-by: Nikolay Popov <ekklesio.dev@gmail.com> Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com> Co-authored-by: Tim Massey <tim-massey@users.noreply.github.com> Co-authored-by: Paijo <oyi77@users.noreply.github.com> Co-authored-by: dail45 <dail45@yandex.ru> Co-authored-by: R.D. <rogerproself@gmail.com>
817 lines
24 KiB
TypeScript
817 lines
24 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import Database from "better-sqlite3";
|
|
|
|
const serial = { concurrency: false };
|
|
const originalEnv = {
|
|
DATA_DIR: process.env.DATA_DIR,
|
|
NEXT_PHASE: process.env.NEXT_PHASE,
|
|
HOME: process.env.HOME,
|
|
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
|
|
APPDATA: process.env.APPDATA,
|
|
};
|
|
|
|
function restoreEnv() {
|
|
for (const [key, value] of Object.entries(originalEnv)) {
|
|
if (value === undefined) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
function cleanupGlobalDb() {
|
|
try {
|
|
if (globalThis.__omnirouteDb?.open) {
|
|
globalThis.__omnirouteDb.close();
|
|
}
|
|
} catch {}
|
|
|
|
delete globalThis.__omnirouteDb;
|
|
}
|
|
|
|
function makeTempDir(prefix) {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
}
|
|
|
|
function removePath(targetPath) {
|
|
fs.rmSync(targetPath, { recursive: true, force: true });
|
|
}
|
|
|
|
async function importFresh(modulePath) {
|
|
const url = pathToFileURL(path.resolve(modulePath)).href;
|
|
return import(`${url}?test=${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
|
}
|
|
|
|
async function withEnv(overrides, fn) {
|
|
const snapshot = {};
|
|
for (const key of Object.keys(overrides)) {
|
|
snapshot[key] = process.env[key];
|
|
const value = overrides[key];
|
|
if (value === undefined) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = value;
|
|
}
|
|
}
|
|
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
for (const [key, value] of Object.entries(snapshot)) {
|
|
if (value === undefined) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = value as string;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function createLegacySchemaDb(sqliteFile, { withData = false } = {}) {
|
|
const seedDb = new Database(sqliteFile);
|
|
seedDb.exec(`
|
|
CREATE TABLE schema_migrations (version TEXT);
|
|
CREATE TABLE provider_connections (
|
|
id TEXT PRIMARY KEY,
|
|
provider TEXT NOT NULL,
|
|
auth_type TEXT,
|
|
name TEXT,
|
|
email TEXT,
|
|
priority INTEGER DEFAULT 0,
|
|
is_active INTEGER DEFAULT 1,
|
|
access_token TEXT,
|
|
refresh_token TEXT,
|
|
expires_at TEXT,
|
|
token_expires_at TEXT,
|
|
scope TEXT,
|
|
project_id TEXT,
|
|
test_status TEXT,
|
|
error_code TEXT,
|
|
last_error TEXT,
|
|
last_error_at TEXT,
|
|
last_error_type TEXT,
|
|
last_error_source TEXT,
|
|
backoff_level INTEGER DEFAULT 0,
|
|
rate_limited_until TEXT,
|
|
health_check_interval INTEGER,
|
|
last_health_check_at TEXT,
|
|
last_tested TEXT,
|
|
api_key TEXT,
|
|
id_token TEXT,
|
|
provider_specific_data TEXT,
|
|
expires_in INTEGER,
|
|
display_name TEXT,
|
|
global_priority INTEGER,
|
|
default_model TEXT,
|
|
token_type TEXT,
|
|
consecutive_use_count INTEGER DEFAULT 0,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX idx_pc_provider ON provider_connections(provider);
|
|
CREATE INDEX idx_pc_active ON provider_connections(is_active);
|
|
CREATE INDEX idx_pc_priority ON provider_connections(provider, priority);
|
|
`);
|
|
|
|
if (withData) {
|
|
const now = new Date().toISOString();
|
|
seedDb
|
|
.prepare(
|
|
"INSERT INTO provider_connections (id, provider, auth_type, name, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
)
|
|
.run("legacy-openai", "openai", "apikey", "Legacy", 1, now, now);
|
|
}
|
|
|
|
seedDb.close();
|
|
}
|
|
|
|
function createLegacyCallLogsDb(sqliteFile) {
|
|
const seedDb = new Database(sqliteFile);
|
|
seedDb.exec(`
|
|
CREATE TABLE provider_connections (
|
|
id TEXT PRIMARY KEY,
|
|
provider TEXT NOT NULL,
|
|
auth_type TEXT,
|
|
name TEXT,
|
|
email TEXT,
|
|
priority INTEGER DEFAULT 0,
|
|
is_active INTEGER DEFAULT 1,
|
|
access_token TEXT,
|
|
refresh_token TEXT,
|
|
expires_at TEXT,
|
|
token_expires_at TEXT,
|
|
scope TEXT,
|
|
project_id TEXT,
|
|
test_status TEXT,
|
|
error_code TEXT,
|
|
last_error TEXT,
|
|
last_error_at TEXT,
|
|
last_error_type TEXT,
|
|
last_error_source TEXT,
|
|
backoff_level INTEGER DEFAULT 0,
|
|
rate_limited_until TEXT,
|
|
health_check_interval INTEGER,
|
|
last_health_check_at TEXT,
|
|
last_tested TEXT,
|
|
api_key TEXT,
|
|
id_token TEXT,
|
|
provider_specific_data TEXT,
|
|
expires_in INTEGER,
|
|
display_name TEXT,
|
|
global_priority INTEGER,
|
|
default_model TEXT,
|
|
token_type TEXT,
|
|
consecutive_use_count INTEGER DEFAULT 0,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX idx_pc_provider ON provider_connections(provider);
|
|
CREATE INDEX idx_pc_active ON provider_connections(is_active);
|
|
CREATE INDEX idx_pc_priority ON provider_connections(provider, priority);
|
|
|
|
CREATE TABLE call_logs (
|
|
id TEXT PRIMARY KEY,
|
|
timestamp TEXT NOT NULL,
|
|
method TEXT,
|
|
path TEXT,
|
|
status INTEGER,
|
|
model TEXT,
|
|
provider TEXT,
|
|
account TEXT,
|
|
connection_id TEXT,
|
|
duration INTEGER DEFAULT 0,
|
|
tokens_in INTEGER DEFAULT 0,
|
|
tokens_out INTEGER DEFAULT 0,
|
|
source_format TEXT,
|
|
target_format TEXT,
|
|
api_key_id TEXT,
|
|
api_key_name TEXT,
|
|
combo_name TEXT,
|
|
request_body TEXT,
|
|
response_body TEXT,
|
|
error TEXT
|
|
);
|
|
CREATE INDEX idx_cl_timestamp ON call_logs(timestamp);
|
|
CREATE INDEX idx_cl_status ON call_logs(status);
|
|
`);
|
|
seedDb.close();
|
|
}
|
|
|
|
function createRecoverableDb(sqliteFile) {
|
|
const seedDb = new Database(sqliteFile);
|
|
const now = new Date().toISOString();
|
|
seedDb.exec(`
|
|
CREATE TABLE provider_connections (
|
|
id TEXT PRIMARY KEY,
|
|
provider TEXT NOT NULL,
|
|
auth_type TEXT,
|
|
name TEXT,
|
|
is_active INTEGER DEFAULT 1,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE provider_nodes (
|
|
id TEXT PRIMARY KEY,
|
|
type TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
prefix TEXT,
|
|
api_type TEXT,
|
|
base_url TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE key_value (
|
|
namespace TEXT NOT NULL,
|
|
key TEXT NOT NULL,
|
|
value TEXT NOT NULL,
|
|
PRIMARY KEY (namespace, key)
|
|
);
|
|
|
|
CREATE TABLE combos (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE,
|
|
data TEXT NOT NULL,
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE api_keys (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
key TEXT NOT NULL UNIQUE,
|
|
machine_id TEXT,
|
|
allowed_models TEXT DEFAULT '[]',
|
|
no_log INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
`);
|
|
|
|
seedDb
|
|
.prepare(
|
|
"INSERT INTO provider_connections (id, provider, auth_type, name, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
)
|
|
.run("recover-openai", "openai", "apikey", "Recover Me", 1, now, now);
|
|
seedDb
|
|
.prepare(
|
|
"INSERT INTO provider_nodes (id, type, name, prefix, api_type, base_url, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
|
)
|
|
.run(
|
|
"recover-node",
|
|
"custom",
|
|
"Recover Node",
|
|
"recover",
|
|
"openai",
|
|
"https://example.com",
|
|
now,
|
|
now
|
|
);
|
|
seedDb
|
|
.prepare("INSERT INTO key_value (namespace, key, value) VALUES (?, ?, ?)")
|
|
.run("settings", "globalFallbackModel", JSON.stringify("openai/gpt-4o-mini"));
|
|
seedDb
|
|
.prepare("INSERT INTO key_value (namespace, key, value) VALUES (?, ?, ?)")
|
|
.run("modelAliases", "fast-default", JSON.stringify("openai/gpt-4o-mini"));
|
|
seedDb
|
|
.prepare(
|
|
"INSERT INTO combos (id, name, data, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
|
|
)
|
|
.run(
|
|
"recover-combo",
|
|
"Recover Combo",
|
|
JSON.stringify({
|
|
id: "recover-combo",
|
|
name: "Recover Combo",
|
|
models: ["openai/gpt-4o-mini"],
|
|
}),
|
|
1,
|
|
now,
|
|
now
|
|
);
|
|
seedDb
|
|
.prepare(
|
|
"INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
)
|
|
.run(
|
|
"recover-key",
|
|
"Recover Key",
|
|
"sk-recover-key",
|
|
"machine-recover",
|
|
JSON.stringify(["openai/gpt-4o-mini"]),
|
|
1,
|
|
now
|
|
);
|
|
seedDb.close();
|
|
}
|
|
|
|
function listProbeFailedBackups(sqliteFile) {
|
|
const directory = path.dirname(sqliteFile);
|
|
const prefix = `${path.basename(sqliteFile)}.probe-failed-`;
|
|
return fs
|
|
.readdirSync(directory)
|
|
.filter((name) => name.startsWith(prefix))
|
|
.map((name) => path.join(directory, name))
|
|
.sort();
|
|
}
|
|
|
|
test.beforeEach(() => {
|
|
restoreEnv();
|
|
cleanupGlobalDb();
|
|
});
|
|
|
|
test.afterEach(() => {
|
|
cleanupGlobalDb();
|
|
restoreEnv();
|
|
});
|
|
|
|
test.after(() => {
|
|
cleanupGlobalDb();
|
|
restoreEnv();
|
|
});
|
|
|
|
test("getDbInstance creates sqlite schema, metadata and applies migrations", serial, async () => {
|
|
const dataDir = makeTempDir("omniroute-db-core-");
|
|
|
|
try {
|
|
await withEnv({ DATA_DIR: dataDir, NEXT_PHASE: undefined }, async () => {
|
|
const core = await importFresh("src/lib/db/core.ts");
|
|
const db = core.getDbInstance();
|
|
|
|
assert.equal(fs.existsSync(core.SQLITE_FILE), true);
|
|
assert.ok(
|
|
db
|
|
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
|
.get("provider_connections")
|
|
);
|
|
assert.deepEqual(db.prepare("SELECT value FROM db_meta WHERE key = 'schema_version'").get(), {
|
|
value: "1",
|
|
});
|
|
|
|
const versions = db
|
|
.prepare("SELECT version FROM _omniroute_migrations ORDER BY version")
|
|
.all()
|
|
.map((row) => row.version);
|
|
|
|
assert.equal(versions[0], "001");
|
|
assert.ok(versions.includes("017"));
|
|
assert.ok(
|
|
db
|
|
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
|
.get("version_manager")
|
|
);
|
|
|
|
core.resetDbInstance();
|
|
});
|
|
} finally {
|
|
removePath(dataDir);
|
|
}
|
|
});
|
|
|
|
test("getDbInstance reuses the singleton and closeDbInstance resets it", serial, async () => {
|
|
const dataDir = makeTempDir("omniroute-db-core-");
|
|
|
|
try {
|
|
await withEnv({ DATA_DIR: dataDir, NEXT_PHASE: undefined }, async () => {
|
|
const core = await importFresh("src/lib/db/core.ts");
|
|
const firstDb = core.getDbInstance();
|
|
const secondDb = core.getDbInstance();
|
|
|
|
assert.strictEqual(secondDb, firstDb);
|
|
assert.equal(core.closeDbInstance(), true);
|
|
assert.equal(firstDb.open, false);
|
|
assert.equal(core.closeDbInstance(), false);
|
|
|
|
const reopenedDb = core.getDbInstance();
|
|
assert.notStrictEqual(reopenedDb, firstDb);
|
|
|
|
core.resetDbInstance();
|
|
});
|
|
} finally {
|
|
removePath(dataDir);
|
|
}
|
|
});
|
|
|
|
test("local sqlite configuration enables WAL and sane pragmas", serial, async () => {
|
|
const dataDir = makeTempDir("omniroute-db-core-");
|
|
|
|
try {
|
|
await withEnv({ DATA_DIR: dataDir, NEXT_PHASE: undefined }, async () => {
|
|
const core = await importFresh("src/lib/db/core.ts");
|
|
const db = core.getDbInstance();
|
|
|
|
assert.equal(db.pragma("journal_mode", { simple: true }), "wal");
|
|
assert.equal(db.pragma("busy_timeout", { simple: true }), 5000);
|
|
assert.equal(db.pragma("synchronous", { simple: true }), 1);
|
|
assert.equal(core.closeDbInstance({ checkpointMode: null }), true);
|
|
});
|
|
} finally {
|
|
removePath(dataDir);
|
|
}
|
|
});
|
|
|
|
test("module exports honor DATA_DIR from the environment", serial, async () => {
|
|
const dataDir = makeTempDir("omniroute-db-core-env-");
|
|
|
|
try {
|
|
await withEnv({ DATA_DIR: dataDir }, async () => {
|
|
const core = await importFresh("src/lib/db/core.ts");
|
|
|
|
assert.equal(core.DATA_DIR, path.resolve(dataDir));
|
|
assert.equal(core.SQLITE_FILE, path.join(path.resolve(dataDir), "storage.sqlite"));
|
|
assert.equal(core.DB_BACKUPS_DIR, path.join(path.resolve(dataDir), "db_backups"));
|
|
});
|
|
} finally {
|
|
removePath(dataDir);
|
|
}
|
|
});
|
|
|
|
test(
|
|
"module falls back to the default home data directory when DATA_DIR is absent",
|
|
serial,
|
|
async () => {
|
|
const fakeHome = makeTempDir("omniroute-home-");
|
|
|
|
try {
|
|
await withEnv(
|
|
{
|
|
DATA_DIR: undefined,
|
|
XDG_CONFIG_HOME: undefined,
|
|
HOME: fakeHome,
|
|
USERPROFILE: fakeHome,
|
|
APPDATA: undefined,
|
|
},
|
|
async () => {
|
|
const core = await importFresh("src/lib/db/core.ts");
|
|
const expectedDir =
|
|
process.platform === "win32"
|
|
? path.join(fakeHome, "AppData", "Roaming", "omniroute")
|
|
: path.join(fakeHome, ".omniroute");
|
|
|
|
assert.equal(core.DATA_DIR, expectedDir);
|
|
assert.equal(core.SQLITE_FILE, path.join(expectedDir, "storage.sqlite"));
|
|
}
|
|
);
|
|
} finally {
|
|
removePath(fakeHome);
|
|
}
|
|
}
|
|
);
|
|
|
|
test("build phase uses an in-memory database without creating sqlite files", serial, async () => {
|
|
const dataDir = makeTempDir("omniroute-db-build-");
|
|
|
|
try {
|
|
await withEnv(
|
|
{
|
|
DATA_DIR: dataDir,
|
|
NEXT_PHASE: "phase-production-build",
|
|
},
|
|
async () => {
|
|
const core = await importFresh("src/lib/db/core.ts");
|
|
const db = core.getDbInstance();
|
|
|
|
assert.ok(
|
|
db
|
|
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
|
.get("provider_connections")
|
|
);
|
|
assert.equal(fs.existsSync(path.join(dataDir, "storage.sqlite")), false);
|
|
assert.equal(db.pragma("journal_mode", { simple: true }), "memory");
|
|
|
|
core.resetDbInstance();
|
|
}
|
|
);
|
|
} finally {
|
|
removePath(dataDir);
|
|
}
|
|
});
|
|
|
|
test("getDbInstance surfaces invalid DATA_DIR paths as sqlite open failures", serial, async () => {
|
|
const sandboxDir = makeTempDir("omniroute-db-bad-path-");
|
|
const fileAsDir = path.join(sandboxDir, "not-a-directory");
|
|
fs.writeFileSync(fileAsDir, "blocked");
|
|
|
|
try {
|
|
await withEnv({ DATA_DIR: fileAsDir }, async () => {
|
|
const core = await importFresh("src/lib/db/core.ts");
|
|
|
|
assert.throws(
|
|
() => core.getDbInstance(),
|
|
/unable to open database file|ENOTDIR|not a directory/i
|
|
);
|
|
assert.equal(core.closeDbInstance(), false);
|
|
});
|
|
} finally {
|
|
removePath(sandboxDir);
|
|
}
|
|
});
|
|
|
|
test(
|
|
"legacy empty schema databases are renamed before a fresh sqlite database is created",
|
|
serial,
|
|
async () => {
|
|
const dataDir = makeTempDir("omniroute-db-legacy-empty-");
|
|
const sqliteFile = path.join(dataDir, "storage.sqlite");
|
|
createLegacySchemaDb(sqliteFile);
|
|
|
|
try {
|
|
await withEnv({ DATA_DIR: dataDir }, async () => {
|
|
const core = await importFresh("src/lib/db/core.ts");
|
|
const db = core.getDbInstance();
|
|
|
|
assert.equal(fs.existsSync(`${sqliteFile}.old-schema`), true);
|
|
assert.ok(
|
|
db
|
|
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
|
.get("_omniroute_migrations")
|
|
);
|
|
assert.equal(
|
|
db
|
|
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
|
.get("schema_migrations"),
|
|
undefined
|
|
);
|
|
|
|
core.resetDbInstance();
|
|
});
|
|
} finally {
|
|
removePath(dataDir);
|
|
}
|
|
}
|
|
);
|
|
|
|
test(
|
|
"legacy databases with data preserve rows while removing the old migration table",
|
|
serial,
|
|
async () => {
|
|
const dataDir = makeTempDir("omniroute-db-legacy-data-");
|
|
const sqliteFile = path.join(dataDir, "storage.sqlite");
|
|
createLegacySchemaDb(sqliteFile, { withData: true });
|
|
|
|
try {
|
|
await withEnv({ DATA_DIR: dataDir }, async () => {
|
|
const core = await importFresh("src/lib/db/core.ts");
|
|
const db = core.getDbInstance();
|
|
|
|
assert.deepEqual(
|
|
db
|
|
.prepare("SELECT id, provider FROM provider_connections WHERE id = ?")
|
|
.get("legacy-openai"),
|
|
{ id: "legacy-openai", provider: "openai" }
|
|
);
|
|
assert.equal(
|
|
db
|
|
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
|
.get("schema_migrations"),
|
|
undefined
|
|
);
|
|
assert.ok(
|
|
db
|
|
.prepare("SELECT name FROM pragma_table_info('provider_connections') WHERE name = ?")
|
|
.get("rate_limit_protection")
|
|
);
|
|
assert.ok(
|
|
db
|
|
.prepare("SELECT name FROM pragma_table_info('provider_connections') WHERE name = ?")
|
|
.get("last_used_at")
|
|
);
|
|
|
|
core.resetDbInstance();
|
|
});
|
|
} finally {
|
|
removePath(dataDir);
|
|
}
|
|
}
|
|
);
|
|
|
|
test(
|
|
"legacy call_logs schemas are upgraded before combo target indexes are created",
|
|
serial,
|
|
async () => {
|
|
const dataDir = makeTempDir("omniroute-db-legacy-call-logs-");
|
|
const sqliteFile = path.join(dataDir, "storage.sqlite");
|
|
createLegacyCallLogsDb(sqliteFile);
|
|
|
|
try {
|
|
await withEnv({ DATA_DIR: dataDir }, async () => {
|
|
const core = await importFresh("src/lib/db/core.ts");
|
|
const db = core.getDbInstance();
|
|
|
|
assert.ok(
|
|
db
|
|
.prepare("SELECT name FROM pragma_table_info('call_logs') WHERE name = ?")
|
|
.get("requested_model")
|
|
);
|
|
assert.ok(
|
|
db
|
|
.prepare("SELECT name FROM pragma_table_info('call_logs') WHERE name = ?")
|
|
.get("request_type")
|
|
);
|
|
assert.ok(
|
|
db
|
|
.prepare("SELECT name FROM pragma_table_info('call_logs') WHERE name = ?")
|
|
.get("combo_step_id")
|
|
);
|
|
assert.ok(
|
|
db
|
|
.prepare("SELECT name FROM pragma_table_info('call_logs') WHERE name = ?")
|
|
.get("combo_execution_key")
|
|
);
|
|
assert.ok(
|
|
db
|
|
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?")
|
|
.get("idx_call_logs_requested_model")
|
|
);
|
|
assert.ok(
|
|
db
|
|
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?")
|
|
.get("idx_call_logs_request_type")
|
|
);
|
|
assert.ok(
|
|
db
|
|
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?")
|
|
.get("idx_cl_combo_target")
|
|
);
|
|
|
|
core.resetDbInstance();
|
|
});
|
|
} finally {
|
|
removePath(dataDir);
|
|
}
|
|
}
|
|
);
|
|
|
|
test(
|
|
"legacy 022 call_logs_cache_source tracking is rehomed so 022_add_memory_fts5 still applies",
|
|
serial,
|
|
async () => {
|
|
const dataDir = makeTempDir("omniroute-db-calllogs-cache-source-");
|
|
const sqliteFile = path.join(dataDir, "storage.sqlite");
|
|
const seedDb = new Database(sqliteFile);
|
|
seedDb.exec(`
|
|
CREATE TABLE _omniroute_migrations (
|
|
version TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
INSERT INTO _omniroute_migrations (version, name) VALUES ('001', 'initial_schema');
|
|
INSERT INTO _omniroute_migrations (version, name) VALUES ('022', 'call_logs_cache_source');
|
|
|
|
CREATE TABLE call_logs (
|
|
id TEXT PRIMARY KEY,
|
|
timestamp TEXT NOT NULL,
|
|
status INTEGER,
|
|
combo_name TEXT,
|
|
cache_source TEXT DEFAULT 'semantic'
|
|
);
|
|
|
|
CREATE TABLE memories (
|
|
id TEXT PRIMARY KEY,
|
|
api_key_id TEXT NOT NULL,
|
|
session_id TEXT,
|
|
type TEXT NOT NULL,
|
|
key TEXT,
|
|
content TEXT NOT NULL,
|
|
metadata TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
expires_at TEXT
|
|
);
|
|
|
|
INSERT INTO memories (id, api_key_id, type, key, content, metadata)
|
|
VALUES ('550e8400-e29b-41d4-a716-446655440000', 'key-1', 'factual', 'topic', 'memory content', '{}');
|
|
`);
|
|
seedDb.close();
|
|
|
|
try {
|
|
await withEnv({ DATA_DIR: dataDir, DISABLE_SQLITE_AUTO_BACKUP: "true" }, async () => {
|
|
const core = await importFresh("src/lib/db/core.ts");
|
|
const db = core.getDbInstance();
|
|
|
|
assert.ok(
|
|
db.prepare("SELECT version FROM _omniroute_migrations WHERE version = ?").get("022")
|
|
);
|
|
assert.ok(
|
|
db.prepare("SELECT version FROM _omniroute_migrations WHERE version = ?").get("023")
|
|
);
|
|
assert.ok(
|
|
db.prepare("SELECT version FROM _omniroute_migrations WHERE version = ?").get("026")
|
|
);
|
|
assert.ok(
|
|
db.prepare("SELECT version FROM _omniroute_migrations WHERE version = ?").get("027")
|
|
);
|
|
assert.equal(
|
|
db
|
|
.prepare("SELECT version FROM _omniroute_migrations WHERE version = ? AND name = ?")
|
|
.get("022", "call_logs_cache_source"),
|
|
undefined
|
|
);
|
|
assert.deepEqual(db.prepare("SELECT memory_id FROM memories").get(), { memory_id: 1 });
|
|
assert.deepEqual(db.prepare("SELECT rowid, content FROM memory_fts").get(), {
|
|
rowid: 1,
|
|
content: "memory content",
|
|
});
|
|
|
|
core.resetDbInstance();
|
|
});
|
|
} finally {
|
|
removePath(dataDir);
|
|
}
|
|
}
|
|
);
|
|
|
|
test(
|
|
"probe failures restore preserved critical state instead of booting with an empty database",
|
|
serial,
|
|
async () => {
|
|
const dataDir = makeTempDir("omniroute-db-probe-recover-");
|
|
const sqliteFile = path.join(dataDir, "storage.sqlite");
|
|
createRecoverableDb(sqliteFile);
|
|
|
|
const originalPrepare = Database.prototype.prepare;
|
|
|
|
try {
|
|
Database.prototype.prepare = function patchedPrepare(sql, ...args) {
|
|
if (String(sql).includes("schema_migrations")) {
|
|
throw new Error("forced probe failure");
|
|
}
|
|
return originalPrepare.call(this, sql, ...args);
|
|
};
|
|
|
|
await withEnv({ DATA_DIR: dataDir }, async () => {
|
|
const core = await importFresh("src/lib/db/core.ts");
|
|
const db = core.getDbInstance();
|
|
|
|
assert.deepEqual(
|
|
db
|
|
.prepare("SELECT id, provider, name FROM provider_connections WHERE id = ?")
|
|
.get("recover-openai"),
|
|
{ id: "recover-openai", provider: "openai", name: "Recover Me" }
|
|
);
|
|
assert.deepEqual(
|
|
db.prepare("SELECT id, name FROM provider_nodes WHERE id = ?").get("recover-node"),
|
|
{ id: "recover-node", name: "Recover Node" }
|
|
);
|
|
assert.deepEqual(
|
|
db.prepare("SELECT id, name FROM combos WHERE id = ?").get("recover-combo"),
|
|
{ id: "recover-combo", name: "Recover Combo" }
|
|
);
|
|
assert.deepEqual(
|
|
db.prepare("SELECT id, name, no_log FROM api_keys WHERE id = ?").get("recover-key"),
|
|
{ id: "recover-key", name: "Recover Key", no_log: 1 }
|
|
);
|
|
assert.deepEqual(
|
|
db
|
|
.prepare("SELECT value FROM key_value WHERE namespace = 'settings' AND key = ?")
|
|
.get("globalFallbackModel"),
|
|
{ value: JSON.stringify("openai/gpt-4o-mini") }
|
|
);
|
|
assert.equal(listProbeFailedBackups(sqliteFile).length >= 1, true);
|
|
|
|
core.resetDbInstance();
|
|
});
|
|
} finally {
|
|
Database.prototype.prepare = originalPrepare;
|
|
removePath(dataDir);
|
|
}
|
|
}
|
|
);
|
|
|
|
test(
|
|
"probe failures without a safe snapshot abort startup and keep manual recovery explicit",
|
|
serial,
|
|
async () => {
|
|
const dataDir = makeTempDir("omniroute-db-probe-abort-");
|
|
const sqliteFile = path.join(dataDir, "storage.sqlite");
|
|
fs.writeFileSync(sqliteFile, "not-a-valid-sqlite-database");
|
|
|
|
try {
|
|
await withEnv({ DATA_DIR: dataDir }, async () => {
|
|
const core = await importFresh("src/lib/db/core.ts");
|
|
|
|
assert.throws(() => core.getDbInstance(), /Manual recovery required after probe failure/i);
|
|
assert.equal(fs.existsSync(sqliteFile), false);
|
|
assert.equal(listProbeFailedBackups(sqliteFile).length >= 1, true);
|
|
|
|
const restartedCore = await importFresh("src/lib/db/core.ts");
|
|
assert.throws(
|
|
() => restartedCore.getDbInstance(),
|
|
/Manual recovery required before startup/i
|
|
);
|
|
assert.equal(fs.existsSync(sqliteFile), false);
|
|
core.resetDbInstance();
|
|
});
|
|
} finally {
|
|
removePath(dataDir);
|
|
}
|
|
}
|
|
);
|