567 lines
18 KiB
TypeScript
567 lines
18 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";
|
|
|
|
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-models-dev-"));
|
|
process.env.DATA_DIR = TEST_DATA_DIR;
|
|
|
|
const core = await import("../../src/lib/db/core.ts");
|
|
const settingsDb = await import("../../src/lib/db/settings.ts");
|
|
|
|
const modulePath = path.join(process.cwd(), "src/lib/modelsDevSync.ts");
|
|
const originalFetch = globalThis.fetch;
|
|
const originalEnv = { ...process.env };
|
|
const loadedModules = new Set();
|
|
|
|
const MOCK_MODELS_DEV_DATA = {
|
|
openai: {
|
|
id: "openai",
|
|
models: {
|
|
"gpt-4o": {
|
|
id: "gpt-4o",
|
|
name: "GPT-4o",
|
|
family: "gpt-4",
|
|
attachment: true,
|
|
reasoning: false,
|
|
tool_call: true,
|
|
structured_output: true,
|
|
temperature: true,
|
|
knowledge: "2024-10",
|
|
release_date: "2024-05-13",
|
|
last_updated: "2024-10-01",
|
|
open_weights: false,
|
|
cost: {
|
|
input: 2.5,
|
|
output: 10,
|
|
cache_read: 1.25,
|
|
cache_write: 2.5,
|
|
},
|
|
limit: {
|
|
context: 128000,
|
|
input: 128000,
|
|
output: 16384,
|
|
},
|
|
modalities: {
|
|
input: ["text", "image"],
|
|
output: ["text"],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
anthropic: {
|
|
id: "anthropic",
|
|
models: {
|
|
"claude-sonnet-4-20250514": {
|
|
id: "claude-sonnet-4-20250514",
|
|
name: "Claude Sonnet 4",
|
|
tool_call: true,
|
|
reasoning: false,
|
|
attachment: true,
|
|
structured_output: true,
|
|
temperature: true,
|
|
release_date: "2025-05-14",
|
|
last_updated: "2025-05-14",
|
|
open_weights: false,
|
|
cost: {
|
|
input: 3,
|
|
output: 15,
|
|
cache_read: 0.3,
|
|
},
|
|
limit: {
|
|
context: 200000,
|
|
output: 64000,
|
|
},
|
|
modalities: {
|
|
input: ["text", "image"],
|
|
output: ["text"],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
async function importFresh(label) {
|
|
const mod = await import(
|
|
`${pathToFileURL(modulePath).href}?case=${label}-${Date.now()}-${Math.random()}`
|
|
);
|
|
loadedModules.add(mod);
|
|
return mod;
|
|
}
|
|
|
|
function restoreEnv() {
|
|
for (const key of Object.keys(process.env)) {
|
|
if (!(key in originalEnv)) delete process.env[key];
|
|
}
|
|
Object.assign(process.env, originalEnv);
|
|
process.env.DATA_DIR = TEST_DATA_DIR;
|
|
}
|
|
|
|
function resetStorage() {
|
|
core.resetDbInstance();
|
|
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
}
|
|
|
|
async function waitFor(predicate, timeoutMs = 200) {
|
|
const start = Date.now();
|
|
while (Date.now() - start < timeoutMs) {
|
|
const value = predicate();
|
|
if (value) return value;
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function mockFetchWith(body, status = 200, statusText = "OK") {
|
|
globalThis.fetch = async () =>
|
|
new Response(typeof body === "string" ? body : JSON.stringify(body), {
|
|
status,
|
|
statusText,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
|
|
test.beforeEach(async () => {
|
|
resetStorage();
|
|
});
|
|
|
|
test.afterEach(async () => {
|
|
for (const mod of loadedModules) {
|
|
if (typeof (mod as any).stopPeriodicSync === "function") {
|
|
(mod as any).stopPeriodicSync();
|
|
}
|
|
}
|
|
loadedModules.clear();
|
|
globalThis.fetch = originalFetch;
|
|
restoreEnv();
|
|
core.resetDbInstance();
|
|
});
|
|
|
|
test.after(async () => {
|
|
core.resetDbInstance();
|
|
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
});
|
|
|
|
test("fetchModelsDev caches successful responses and rejects invalid JSON or non-ok responses", async () => {
|
|
const modelsDev = await importFresh("fetch-cache");
|
|
let calls = 0;
|
|
globalThis.fetch = async () => {
|
|
calls += 1;
|
|
return new Response(JSON.stringify(MOCK_MODELS_DEV_DATA), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
};
|
|
|
|
const first = await modelsDev.fetchModelsDev();
|
|
const second = await modelsDev.fetchModelsDev();
|
|
|
|
assert.strictEqual(first, second);
|
|
assert.equal(calls, 1);
|
|
|
|
const invalid = await importFresh("fetch-invalid-json");
|
|
mockFetchWith("not-json");
|
|
await assert.rejects(() => invalid.fetchModelsDev(), /invalid JSON/);
|
|
|
|
const nonOk = await importFresh("fetch-non-ok");
|
|
mockFetchWith({ error: "boom" }, 503, "Service Unavailable");
|
|
await assert.rejects(() => nonOk.fetchModelsDev(), /models\.dev fetch failed \[503\]/);
|
|
});
|
|
|
|
test("modelsDev interval falls back to the default when env values are invalid or non-positive", async () => {
|
|
process.env.MODELS_DEV_SYNC_INTERVAL = "0";
|
|
const zeroInterval = await importFresh("interval-zero");
|
|
zeroInterval.startPeriodicSync();
|
|
assert.equal(zeroInterval.getSyncStatus().intervalMs, 86400 * 1000);
|
|
zeroInterval.stopPeriodicSync();
|
|
|
|
process.env.MODELS_DEV_SYNC_INTERVAL = "not-a-number";
|
|
const invalidInterval = await importFresh("interval-invalid");
|
|
invalidInterval.startPeriodicSync();
|
|
assert.equal(invalidInterval.getSyncStatus().intervalMs, 86400 * 1000);
|
|
invalidInterval.stopPeriodicSync();
|
|
});
|
|
|
|
test("transform helpers skip incomplete pricing entries and preserve partial capability defaults", async () => {
|
|
const modelsDev = await importFresh("transform-edge-cases");
|
|
const raw = {
|
|
sparse: {
|
|
id: "sparse",
|
|
models: {
|
|
"missing-cost": {
|
|
id: "missing-cost",
|
|
name: "Missing Cost",
|
|
},
|
|
"missing-input": {
|
|
id: "missing-input",
|
|
name: "Missing Input",
|
|
cost: { output: 4.2 },
|
|
},
|
|
complete: {
|
|
id: "complete",
|
|
name: "Complete",
|
|
cost: { input: 1.5 },
|
|
interleaved: { field: "" },
|
|
},
|
|
},
|
|
},
|
|
nomodels: {
|
|
id: "nomodels",
|
|
},
|
|
};
|
|
|
|
const pricing = modelsDev.transformModelsDevToPricing(raw);
|
|
assert.deepEqual(pricing.sparse, {
|
|
complete: {
|
|
input: 1.5,
|
|
output: 0,
|
|
},
|
|
});
|
|
assert.equal(pricing.nomodels, undefined);
|
|
|
|
const capabilities = modelsDev.transformModelsDevToCapabilities(raw);
|
|
assert.equal(capabilities.sparse.complete.tool_call, null);
|
|
assert.equal(capabilities.sparse.complete.reasoning, null);
|
|
assert.equal(capabilities.sparse.complete.attachment, null);
|
|
assert.equal(capabilities.sparse.complete.structured_output, null);
|
|
assert.equal(capabilities.sparse.complete.temperature, null);
|
|
assert.equal(capabilities.sparse.complete.modalities_input, "[]");
|
|
assert.equal(capabilities.sparse.complete.modalities_output, "[]");
|
|
assert.equal(capabilities.sparse.complete.limit_context, null);
|
|
assert.equal(capabilities.sparse.complete.limit_input, null);
|
|
assert.equal(capabilities.sparse.complete.limit_output, null);
|
|
assert.equal(capabilities.sparse.complete.interleaved_field, null);
|
|
assert.equal(capabilities.nomodels, undefined);
|
|
});
|
|
|
|
test("modelsDev pricing helpers persist records, skip corrupted rows, and clear the namespace", async () => {
|
|
const modelsDev = await importFresh("pricing-storage");
|
|
const pricing = modelsDev.transformModelsDevToPricing(MOCK_MODELS_DEV_DATA);
|
|
|
|
modelsDev.saveModelsDevPricing(pricing);
|
|
const saved = modelsDev.getModelsDevPricing();
|
|
assert.equal(saved.openai["gpt-4o"].input, 2.5);
|
|
assert.equal(saved.cx["gpt-4o"].cache_creation, 2.5);
|
|
|
|
const db = core.getDbInstance();
|
|
db.prepare("INSERT INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
|
|
"models_dev_pricing",
|
|
"corrupted",
|
|
"{oops"
|
|
);
|
|
|
|
const withCorruption = modelsDev.getModelsDevPricing();
|
|
assert.equal(withCorruption.corrupted, undefined);
|
|
|
|
modelsDev.clearModelsDevPricing();
|
|
assert.deepEqual(modelsDev.getModelsDevPricing(), {});
|
|
});
|
|
|
|
test("modelsDev capabilities helpers create the table, persist rows, filter by provider/model, and expose context limits", async () => {
|
|
const modelsDev = await importFresh("capabilities-storage");
|
|
const capabilities = modelsDev.transformModelsDevToCapabilities(MOCK_MODELS_DEV_DATA);
|
|
|
|
modelsDev.ensureCapabilitiesTable();
|
|
modelsDev.saveModelsDevCapabilities(capabilities);
|
|
|
|
const allCaps = modelsDev.getSyncedCapabilities();
|
|
const openaiOnly = modelsDev.getSyncedCapabilities("openai", "gpt-4o");
|
|
|
|
assert.equal(allCaps.openai["gpt-4o"].tool_call, true);
|
|
assert.equal(allCaps.anthropic["claude-sonnet-4-20250514"].attachment, true);
|
|
assert.deepEqual(Object.keys(openaiOnly), ["openai"]);
|
|
assert.equal(openaiOnly.openai["gpt-4o"].limit_context, 128000);
|
|
assert.equal("getModelContextLimit" in modelsDev, false);
|
|
|
|
modelsDev.clearModelsDevCapabilities();
|
|
assert.deepEqual(modelsDev.getSyncedCapabilities(), {});
|
|
});
|
|
|
|
test("modelsDev capability helpers coerce false/null values and ignore malformed rows", async () => {
|
|
const modelsDev = await importFresh("capabilities-malformed");
|
|
const db = core.getDbInstance();
|
|
const originalPrepare = db.prepare.bind(db);
|
|
db.prepare = (sql) => {
|
|
if (String(sql).includes("SELECT * FROM model_capabilities")) {
|
|
return {
|
|
all: () => [
|
|
123,
|
|
{ provider: null, model_id: "missing-provider" },
|
|
{
|
|
provider: "openai",
|
|
model_id: "coerced-model",
|
|
tool_call: 0,
|
|
reasoning: null,
|
|
attachment: 0,
|
|
structured_output: 0,
|
|
temperature: 0,
|
|
modalities_input: null,
|
|
modalities_output: 42,
|
|
knowledge_cutoff: 77,
|
|
release_date: 88,
|
|
last_updated: 99,
|
|
status: 123,
|
|
family: 456,
|
|
open_weights: null,
|
|
limit_context: "bad",
|
|
limit_input: 4096,
|
|
limit_output: "nope",
|
|
interleaved_field: 321,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
return originalPrepare(sql);
|
|
};
|
|
|
|
try {
|
|
const openai = modelsDev.getSyncedCapabilities("openai");
|
|
assert.deepEqual(openai.openai["coerced-model"], {
|
|
tool_call: false,
|
|
reasoning: null,
|
|
attachment: false,
|
|
structured_output: false,
|
|
temperature: false,
|
|
modalities_input: "[]",
|
|
modalities_output: "[]",
|
|
knowledge_cutoff: null,
|
|
release_date: null,
|
|
last_updated: null,
|
|
status: null,
|
|
family: null,
|
|
open_weights: null,
|
|
limit_context: null,
|
|
limit_input: 4096,
|
|
limit_output: null,
|
|
interleaved_field: null,
|
|
});
|
|
|
|
const all = modelsDev.getSyncedCapabilities();
|
|
assert.equal(all["7"], undefined);
|
|
assert.equal(all.openai["missing-provider"], undefined);
|
|
} finally {
|
|
db.prepare = originalPrepare;
|
|
}
|
|
});
|
|
|
|
test("modelsDev pricing helpers ignore malformed sqlite rows without crashing", async () => {
|
|
const modelsDev = await importFresh("pricing-malformed");
|
|
const db = core.getDbInstance();
|
|
const originalPrepare = db.prepare.bind(db);
|
|
db.prepare = (sql) => {
|
|
if (String(sql).includes("SELECT key, value FROM key_value")) {
|
|
return {
|
|
all: () => [
|
|
123,
|
|
{ key: 123, value: JSON.stringify({ ignored: true }) },
|
|
{ key: "missing-value", value: 456 },
|
|
{ key: "broken", value: "{oops" },
|
|
{ key: "openai", value: JSON.stringify({ "gpt-4o": { input: 2.5, output: 10 } }) },
|
|
],
|
|
};
|
|
}
|
|
return originalPrepare(sql);
|
|
};
|
|
|
|
try {
|
|
assert.deepEqual(modelsDev.getModelsDevPricing(), {
|
|
openai: {
|
|
"gpt-4o": {
|
|
input: 2.5,
|
|
output: 10,
|
|
},
|
|
},
|
|
});
|
|
} finally {
|
|
db.prepare = originalPrepare;
|
|
}
|
|
});
|
|
|
|
test("saveModelsDevCapabilities round-trips false and null booleans", async () => {
|
|
const modelsDev = await importFresh("capabilities-roundtrip-falsey");
|
|
modelsDev.saveModelsDevCapabilities({
|
|
openai: {
|
|
"gpt-falsey": {
|
|
tool_call: false,
|
|
reasoning: null,
|
|
attachment: false,
|
|
structured_output: false,
|
|
temperature: null,
|
|
modalities_input: "[]",
|
|
modalities_output: '["text"]',
|
|
knowledge_cutoff: null,
|
|
release_date: null,
|
|
last_updated: null,
|
|
status: null,
|
|
family: null,
|
|
open_weights: false,
|
|
limit_context: null,
|
|
limit_input: null,
|
|
limit_output: 1024,
|
|
interleaved_field: null,
|
|
},
|
|
},
|
|
});
|
|
|
|
assert.deepEqual(modelsDev.getSyncedCapabilities("openai", "gpt-falsey"), {
|
|
openai: {
|
|
"gpt-falsey": {
|
|
tool_call: false,
|
|
reasoning: null,
|
|
attachment: false,
|
|
structured_output: false,
|
|
temperature: null,
|
|
modalities_input: "[]",
|
|
modalities_output: '["text"]',
|
|
knowledge_cutoff: null,
|
|
release_date: null,
|
|
last_updated: null,
|
|
status: null,
|
|
family: null,
|
|
open_weights: false,
|
|
limit_context: null,
|
|
limit_input: null,
|
|
limit_output: 1024,
|
|
interleaved_field: null,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
test("syncModelsDev supports dry-run mode, persistence, capability toggles, and failure reporting", async () => {
|
|
const modelsDev = await importFresh("sync-main");
|
|
mockFetchWith(MOCK_MODELS_DEV_DATA);
|
|
|
|
const dryRun = await modelsDev.syncModelsDev({ dryRun: true, syncCapabilities: false });
|
|
assert.equal(dryRun.success, true);
|
|
assert.equal(dryRun.dryRun, true);
|
|
assert.equal(dryRun.capabilityCount, 0);
|
|
assert.ok(dryRun.data.pricing.openai["gpt-4o"]);
|
|
assert.deepEqual(modelsDev.getModelsDevPricing(), {});
|
|
|
|
const persisted = await modelsDev.syncModelsDev();
|
|
assert.equal(persisted.success, true);
|
|
assert.equal(persisted.dryRun, false);
|
|
assert.ok(persisted.modelCount > 0);
|
|
assert.ok(modelsDev.getModelsDevPricing().anthropic["claude-sonnet-4-20250514"]);
|
|
assert.ok(modelsDev.getSyncedCapabilities().openai["gpt-4o"]);
|
|
assert.ok(modelsDev.getSyncStatus().lastSync);
|
|
assert.ok(modelsDev.getSyncStatus().lastSyncModelCount > 0);
|
|
|
|
const failing = await importFresh("sync-failure");
|
|
globalThis.fetch = async () => {
|
|
throw new Error("network down");
|
|
};
|
|
const failed = await failing.syncModelsDev();
|
|
assert.equal(failed.success, false);
|
|
assert.match(failed.error, /network down/);
|
|
});
|
|
|
|
test("syncModelsDev string failures are normalized into an error payload", async () => {
|
|
const modelsDev = await importFresh("sync-string-error");
|
|
globalThis.fetch = async () => {
|
|
throw "hard fail";
|
|
};
|
|
|
|
const failed = await modelsDev.syncModelsDev({ dryRun: true });
|
|
assert.equal(failed.success, false);
|
|
assert.equal(failed.error, "hard fail");
|
|
assert.equal(failed.dryRun, true);
|
|
});
|
|
|
|
test("syncModelsDev honors abort signals during retry backoff", async () => {
|
|
const modelsDev = await importFresh("sync-abort");
|
|
const warnings = [];
|
|
const originalWarn = console.warn;
|
|
console.warn = (...args) => warnings.push(args.map((arg) => String(arg)).join(" "));
|
|
globalThis.fetch = async () => {
|
|
throw new Error("network down");
|
|
};
|
|
|
|
try {
|
|
const controller = new AbortController();
|
|
const pending = modelsDev.syncModelsDev({ signal: controller.signal, maxRetries: 3 });
|
|
const warned = await waitFor(() => warnings.length > 0, 100);
|
|
assert.ok(warned, "expected the first retry warning before aborting");
|
|
|
|
controller.abort();
|
|
const aborted = await pending;
|
|
assert.equal(aborted.success, false);
|
|
assert.equal(aborted.error, "aborted");
|
|
assert.equal(warnings.length, 1);
|
|
} finally {
|
|
console.warn = originalWarn;
|
|
}
|
|
});
|
|
|
|
test("startPeriodicSync, stopPeriodicSync, getSyncStatus, and initModelsDevSync honor settings and avoid duplicate timers", async () => {
|
|
const modelsDev = await importFresh("periodic-sync");
|
|
mockFetchWith(MOCK_MODELS_DEV_DATA);
|
|
|
|
modelsDev.startPeriodicSync(25);
|
|
const started = modelsDev.getSyncStatus();
|
|
assert.equal(started.enabled, true);
|
|
assert.equal(started.intervalMs, 25);
|
|
|
|
modelsDev.startPeriodicSync(99);
|
|
assert.equal(modelsDev.getSyncStatus().intervalMs, 25);
|
|
|
|
const syncedAt = await waitFor(() => modelsDev.getSyncStatus().lastSync, 2000);
|
|
assert.ok(syncedAt, "expected initial periodic sync to complete");
|
|
assert.ok(modelsDev.getSyncStatus().nextSync);
|
|
|
|
modelsDev.stopPeriodicSync();
|
|
const stopped = modelsDev.getSyncStatus();
|
|
assert.equal(stopped.enabled, false);
|
|
assert.equal(stopped.nextSync, null);
|
|
|
|
await settingsDb.updateSettings({
|
|
modelsDevSyncEnabled: false,
|
|
modelsDevSyncInterval: 15,
|
|
});
|
|
const disabled = await importFresh("init-disabled");
|
|
await disabled.initModelsDevSync();
|
|
assert.equal(disabled.getSyncStatus().enabled, false);
|
|
|
|
await settingsDb.updateSettings({
|
|
modelsDevSyncEnabled: true,
|
|
modelsDevSyncInterval: 15,
|
|
});
|
|
const enabled = await importFresh("init-enabled");
|
|
mockFetchWith(MOCK_MODELS_DEV_DATA);
|
|
await enabled.initModelsDevSync();
|
|
assert.equal(enabled.getSyncStatus().enabled, true);
|
|
assert.equal(enabled.getSyncStatus().intervalMs, 15);
|
|
await waitFor(() => enabled.getSyncStatus().lastSync, 2000);
|
|
});
|
|
|
|
test("stopPeriodicSync aborts the in-flight initial sync", async () => {
|
|
const modelsDev = await importFresh("periodic-stop-abort");
|
|
let aborted = false;
|
|
|
|
globalThis.fetch = async (_url, init) =>
|
|
await new Promise((_resolve, reject) => {
|
|
const signal = init?.signal;
|
|
const onAbort = () => {
|
|
aborted = true;
|
|
const error = new Error("aborted");
|
|
error.name = "AbortError";
|
|
reject(error);
|
|
};
|
|
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
});
|
|
|
|
modelsDev.startPeriodicSync(25);
|
|
await waitFor(() => modelsDev.getSyncStatus().enabled, 50);
|
|
modelsDev.stopPeriodicSync();
|
|
|
|
const stopped = await waitFor(() => aborted, 200);
|
|
assert.equal(stopped, true);
|
|
assert.equal(modelsDev.getSyncStatus().enabled, false);
|
|
assert.equal(modelsDev.getSyncStatus().lastSync, null);
|
|
});
|