diff --git a/.env.example b/.env.example index 9ab3c777ba..b277da66b1 100644 --- a/.env.example +++ b/.env.example @@ -1265,6 +1265,13 @@ APP_LOG_TO_FILE=true # Default: 86400 (24 hours) # MODELS_DEV_SYNC_INTERVAL=86400 +# Self-correcting context-window reconciler interval in seconds (feature 5004). +# Pins provider-declared windows from /models discovery as auto:discovery overrides +# when they diverge from the catalog. Set to 0 to disable. Never overwrites manual overrides. +# Used by: src/lib/contextWindowResolver.ts +# Default: 86400 (24 hours) +# CONTEXT_WINDOW_RECONCILE_INTERVAL=86400 + # ═══════════════════════════════════════════════════════════════════════════════ # 20. PROVIDER-SPECIFIC SETTINGS # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index ee75b0f447..c94053102f 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -744,9 +744,10 @@ Automatic model pricing data synchronization from external sources. ## 19. Model Sync (Dev) -| Variable | Default | Source File | Description | -| -------------------------- | ------------- | -------------------------- | -------------------------------------------------------- | -| `MODELS_DEV_SYNC_INTERVAL` | `86400` (24h) | `src/lib/modelsDevSync.ts` | Development-time model catalog sync interval in seconds. | +| Variable | Default | Source File | Description | +| ----------------------------------- | ------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MODELS_DEV_SYNC_INTERVAL` | `86400` (24h) | `src/lib/modelsDevSync.ts` | Development-time model catalog sync interval in seconds. | +| `CONTEXT_WINDOW_RECONCILE_INTERVAL` | `86400` (24h) | `src/lib/contextWindowResolver.ts` | Interval (seconds) for the self-correcting context-window reconciler (5004): pins provider-declared windows from `/models` discovery as `auto:discovery` overrides when they diverge from the catalog. Set to `0` to disable. Reuses already-synced data (no new fetch); never overwrites `manual` overrides. | --- diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index 30f79f8f59..74c8bae9fc 100755 --- a/src/instrumentation-node.ts +++ b/src/instrumentation-node.ts @@ -323,5 +323,16 @@ export async function registerNodejs(): Promise { const msg = err instanceof Error ? err.message : String(err); console.warn("[STARTUP] models.dev sync failed to start (non-fatal):", msg); } + + // Context-window self-correction (5004): periodically reconcile provider-declared + // windows (from /models discovery) into auto:discovery overrides. Reuses already-synced + // data (no new fetch); disable via CONTEXT_WINDOW_RECONCILE_INTERVAL=0. Never fatal. + try { + const { startContextWindowReconcile } = await import("@/lib/contextWindowResolver"); + startContextWindowReconcile(); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.warn("[STARTUP] context-window reconcile failed to start (non-fatal):", msg); + } } } diff --git a/src/lib/contextWindowResolver.ts b/src/lib/contextWindowResolver.ts new file mode 100644 index 0000000000..bcd6cc132f --- /dev/null +++ b/src/lib/contextWindowResolver.ts @@ -0,0 +1,147 @@ +import { getAllSyncedAvailableModels } from "./db/models"; +import { getResolvedModelCapabilities } from "./modelCapabilities"; +import { + getModelContextOverrideRecord, + setModelContextOverride, + removeModelContextOverride, +} from "./db/modelContextOverrides"; + +/** + * Feature 5004 — self-correcting context-window reconciler. + * + * Compares each model's provider-declared window (captured by `/models` discovery in + * `syncedAvailableModels`) against the override-free catalog and, when they diverge, + * pins the discovered value as an `auto:discovery` override so the real window wins in + * `getModelContextLimit`. It never touches `manual` overrides, and it self-heals by + * removing a now-redundant auto override when the catalog has caught up. + * + * No new network fetch: it reconciles data the managed-model import already persisted, + * so it does not duplicate `modelsDevSync` / `modelDiscovery`. + */ + +export interface DiscoveredWindow { + provider: string; + modelId: string; + window: number | null; +} + +export interface ReconcileDeps { + getCatalogWindow: (provider: string, modelId: string) => number | null; + getExistingSource: (provider: string, modelId: string) => string | null; + writeAuto: (provider: string, modelId: string, window: number) => void; + removeOverride: (provider: string, modelId: string) => void; +} + +export interface ReconcileResult { + scanned: number; + written: number; + removed: number; + skippedManual: number; +} + +/** + * Pure reconcile: given the discovered windows and a set of injectable deps, decide + * which auto overrides to write/remove. Deterministic and side-effect-free except + * through the injected `writeAuto`/`removeOverride`. + */ +export function reconcileContextWindows( + discovered: DiscoveredWindow[], + deps: ReconcileDeps +): ReconcileResult { + const result: ReconcileResult = { scanned: 0, written: 0, removed: 0, skippedManual: 0 }; + for (const { provider, modelId, window } of discovered) { + result.scanned++; + if (!provider || !modelId) continue; + if (typeof window !== "number" || !Number.isInteger(window) || window <= 0) continue; + + const existingSource = deps.getExistingSource(provider, modelId); + if (existingSource === "manual") { + result.skippedManual++; + continue; + } + + const catalog = deps.getCatalogWindow(provider, modelId); + if (window !== catalog) { + deps.writeAuto(provider, modelId, window); + result.written++; + } else if (existingSource) { + // Discovered window now matches the catalog and a stale auto override exists → drop it. + deps.removeOverride(provider, modelId); + result.removed++; + } + } + return result; +} + +/** Flatten the per-provider discovery map into the reconcile input. */ +function toDiscoveredWindows( + byProvider: Record> +): DiscoveredWindow[] { + const out: DiscoveredWindow[] = []; + for (const [provider, models] of Object.entries(byProvider)) { + for (const m of models) { + out.push({ provider, modelId: m.id, window: m.inputTokenLimit ?? null }); + } + } + return out; +} + +/** Run the reconcile against the live DB (discovery → overrides). */ +export async function runContextWindowReconcile(): Promise { + const byProvider = await getAllSyncedAvailableModels(); + const discovered = toDiscoveredWindows(byProvider); + return reconcileContextWindows(discovered, { + getCatalogWindow: (provider, modelId) => + getResolvedModelCapabilities({ provider, model: modelId }).contextWindow, + getExistingSource: (provider, modelId) => + getModelContextOverrideRecord(provider, modelId)?.source ?? null, + writeAuto: (provider, modelId, window) => { + setModelContextOverride(provider, modelId, window, "auto:discovery"); + }, + removeOverride: (provider, modelId) => { + removeModelContextOverride(provider, modelId); + }, + }); +} + +// --- Periodic job (mirrors modelsDevSync.startPeriodicSync) --- + +const DEFAULT_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h +let reconcileTimer: ReturnType | null = null; + +function resolveIntervalMs(): number { + const raw = process.env.CONTEXT_WINDOW_RECONCILE_INTERVAL; + if (raw === undefined) return DEFAULT_INTERVAL_MS; + const seconds = Number(raw); + if (!Number.isFinite(seconds) || seconds <= 0) return 0; // 0/invalid → disabled + return Math.floor(seconds * 1000); +} + +/** + * Start the periodic reconcile. Idempotent. Disabled when + * `CONTEXT_WINDOW_RECONCILE_INTERVAL=0`. Best-effort: failures are swallowed (the + * static catalog remains the source of truth). + */ +export function startContextWindowReconcile(intervalMs?: number): void { + if (reconcileTimer) return; + const interval = intervalMs ?? resolveIntervalMs(); + if (!interval || interval <= 0) return; + + const tick = () => { + void runContextWindowReconcile().catch(() => { + // Swallow — reconcile is advisory; the catalog still resolves windows. + }); + }; + + // Initial non-blocking pass, then on the interval. + setTimeout(tick, 0); + reconcileTimer = setInterval(tick, interval); + reconcileTimer.unref?.(); +} + +export function stopContextWindowReconcile(): void { + if (reconcileTimer) { + clearInterval(reconcileTimer); + reconcileTimer = null; + } +} diff --git a/src/lib/db/migrations/110_model_context_overrides.sql b/src/lib/db/migrations/110_model_context_overrides.sql new file mode 100644 index 0000000000..313e3ae695 --- /dev/null +++ b/src/lib/db/migrations/110_model_context_overrides.sql @@ -0,0 +1,17 @@ +-- 110_model_context_overrides.sql +-- Self-correcting context-window overrides per (provider, model_id). An override wins +-- over the static catalog / models.dev sync in getModelContextLimit(). Source 'manual' +-- is operator-set and is NEVER overwritten by the auto reconciler ('auto:discovery'), +-- which pins the window a provider's own /models discovery declares when it diverges +-- from the catalog. Read path: src/lib/modelCapabilities.ts::getModelContextLimit. + +CREATE TABLE IF NOT EXISTS model_context_overrides ( + provider TEXT NOT NULL, + model_id TEXT NOT NULL, + real_context INTEGER NOT NULL, -- the corrected context window (tokens), > 0 + source TEXT NOT NULL DEFAULT 'manual', -- 'manual' | 'auto:discovery' + refreshed_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (provider, model_id) +) WITHOUT ROWID; + +CREATE INDEX IF NOT EXISTS idx_mco_source ON model_context_overrides (source); diff --git a/src/lib/db/modelContextOverrides.ts b/src/lib/db/modelContextOverrides.ts new file mode 100644 index 0000000000..40b7326a1e --- /dev/null +++ b/src/lib/db/modelContextOverrides.ts @@ -0,0 +1,133 @@ +import { getDbInstance } from "./core"; + +/** + * Feature 5004 — self-correcting context-window overrides. + * + * A persisted override of a model's real context window that wins over the static + * catalog / models.dev sync in `getModelContextLimit()`. Two sources: + * - `manual`: operator-set; never overwritten by the auto reconciler. + * - `auto:discovery`: written by the reconciler when a provider's own `/models` + * discovery declares a window that diverges from the catalog. + * + * Cacheless on purpose: the read path already touches the DB (synced capabilities), + * and a single indexed PK lookup is negligible — this avoids any cache-staleness + * hazard with `resetDbInstance()` in tests. + */ + +export type ModelContextOverrideSource = "manual" | "auto:discovery"; + +export interface ModelContextOverride { + provider: string; + modelId: string; + realContext: number; + source: ModelContextOverrideSource; + refreshedAt: string; +} + +interface OverrideRow { + provider: string; + model_id: string; + real_context: number; + source: string; + refreshed_at: string; +} + +function isPositiveInteger(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value > 0; +} + +function normalizeKey(provider: unknown, modelId: unknown): { provider: string; modelId: string } | null { + const p = typeof provider === "string" ? provider.trim() : ""; + const m = typeof modelId === "string" ? modelId.trim() : ""; + if (!p || !m) return null; + return { provider: p, modelId: m }; +} + +function toOverride(row: OverrideRow): ModelContextOverride { + return { + provider: row.provider, + modelId: row.model_id, + realContext: row.real_context, + source: row.source === "auto:discovery" ? "auto:discovery" : "manual", + refreshedAt: row.refreshed_at, + }; +} + +/** Full override record for (provider, modelId), or null. Never throws. */ +export function getModelContextOverrideRecord( + provider: string | null | undefined, + modelId: string | null | undefined +): ModelContextOverride | null { + const key = normalizeKey(provider, modelId); + if (!key) return null; + try { + const row = getDbInstance() + .prepare( + "SELECT provider, model_id, real_context, source, refreshed_at " + + "FROM model_context_overrides WHERE provider = ? AND model_id = ?" + ) + .get(key.provider, key.modelId) as OverrideRow | undefined; + return row ? toOverride(row) : null; + } catch { + // Table may not exist yet (pre-migration) — fall through to the catalog. + return null; + } +} + +/** The overridden context window (tokens) for (provider, modelId), or null. Never throws. */ +export function getModelContextOverride( + provider: string | null | undefined, + modelId: string | null | undefined +): number | null { + const record = getModelContextOverrideRecord(provider, modelId); + return record ? record.realContext : null; +} + +/** + * Upsert an override. `realContext` must be a positive integer (a token count); + * anything else is rejected (no write). Returns true when a row was written. + */ +export function setModelContextOverride( + provider: string, + modelId: string, + realContext: number, + source: ModelContextOverrideSource = "manual" +): boolean { + const key = normalizeKey(provider, modelId); + if (!key || !isPositiveInteger(realContext)) return false; + const normalizedSource: ModelContextOverrideSource = + source === "auto:discovery" ? "auto:discovery" : "manual"; + getDbInstance() + .prepare( + "INSERT OR REPLACE INTO model_context_overrides " + + "(provider, model_id, real_context, source, refreshed_at) " + + "VALUES (?, ?, ?, ?, datetime('now'))" + ) + .run(key.provider, key.modelId, realContext, normalizedSource); + return true; +} + +/** Remove an override. Returns true when a row was deleted. */ +export function removeModelContextOverride(provider: string, modelId: string): boolean { + const key = normalizeKey(provider, modelId); + if (!key) return false; + const info = getDbInstance() + .prepare("DELETE FROM model_context_overrides WHERE provider = ? AND model_id = ?") + .run(key.provider, key.modelId); + return info.changes > 0; +} + +/** All overrides, newest refresh first. Never throws. */ +export function listModelContextOverrides(): ModelContextOverride[] { + try { + const rows = getDbInstance() + .prepare( + "SELECT provider, model_id, real_context, source, refreshed_at " + + "FROM model_context_overrides ORDER BY refreshed_at DESC" + ) + .all() as OverrideRow[]; + return rows.map(toOverride); + } catch { + return []; + } +} diff --git a/src/lib/modelCapabilities.ts b/src/lib/modelCapabilities.ts index 940e94a260..906a99ec38 100644 --- a/src/lib/modelCapabilities.ts +++ b/src/lib/modelCapabilities.ts @@ -5,6 +5,7 @@ import { import { parseModel, resolveCanonicalProviderModel } from "@omniroute/open-sse/services/model.ts"; import { MODEL_SPECS, getModelSpec, type ModelSpec } from "@/shared/constants/modelSpecs"; import { getSyncedCapability } from "@/lib/modelsDevSync"; +import { getModelContextOverride } from "@/lib/db/modelContextOverrides"; import { isVisionModelId } from "@/shared/constants/visionModels"; const TOOL_CALLING_UNSUPPORTED_PATTERNS: string[] = []; @@ -440,5 +441,9 @@ export function getModelContextLimit( typeof providerOrInput === "string" && modelId !== undefined ? getResolvedModelCapabilities({ provider: providerOrInput, model: modelId }) : getResolvedModelCapabilities(providerOrInput); - return resolved.contextWindow; + // Feature 5004: a persisted override (operator-set or auto-discovered) wins over the + // static catalog / models.dev sync. `getResolvedModelCapabilities` stays override-free + // so the reconciler can compare the catalog value against provider-declared windows. + const override = getModelContextOverride(resolved.provider, resolved.model); + return override ?? resolved.contextWindow; } diff --git a/tests/unit/context-window-reconcile.test.ts b/tests/unit/context-window-reconcile.test.ts new file mode 100644 index 0000000000..1767b88281 --- /dev/null +++ b/tests/unit/context-window-reconcile.test.ts @@ -0,0 +1,86 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + reconcileContextWindows, + type DiscoveredWindow, + type ReconcileDeps, +} from "../../src/lib/contextWindowResolver.ts"; + +// Feature 5004 — pure reconcile logic (auto:discovery overrides), deps injected. + +function makeDeps( + catalog: Record, + existing: Record = {} +) { + const writes: Array<[string, string, number]> = []; + const removes: Array<[string, string]> = []; + const deps: ReconcileDeps = { + getCatalogWindow: (p, m) => (`${p}/${m}` in catalog ? catalog[`${p}/${m}`] : null), + getExistingSource: (p, m) => existing[`${p}/${m}`] ?? null, + writeAuto: (p, m, w) => writes.push([p, m, w]), + removeOverride: (p, m) => removes.push([p, m]), + }; + return { deps, writes, removes }; +} + +describe("reconcileContextWindows (5004)", () => { + it("writes an auto override when the discovered window diverges from the catalog", () => { + const discovered: DiscoveredWindow[] = [{ provider: "openai", modelId: "gpt-x", window: 400000 }]; + const { deps, writes } = makeDeps({ "openai/gpt-x": 128000 }); + const r = reconcileContextWindows(discovered, deps); + assert.deepEqual(writes, [["openai", "gpt-x", 400000]]); + assert.equal(r.written, 1); + assert.equal(r.scanned, 1); + }); + + it("does nothing when the discovered window already matches the catalog", () => { + const discovered: DiscoveredWindow[] = [{ provider: "openai", modelId: "gpt-x", window: 128000 }]; + const { deps, writes, removes } = makeDeps({ "openai/gpt-x": 128000 }); + const r = reconcileContextWindows(discovered, deps); + assert.deepEqual(writes, []); + assert.deepEqual(removes, []); + assert.equal(r.written, 0); + }); + + it("self-heals: removes a stale auto override once the catalog catches up", () => { + const discovered: DiscoveredWindow[] = [{ provider: "openai", modelId: "gpt-x", window: 128000 }]; + const { deps, removes } = makeDeps({ "openai/gpt-x": 128000 }, { "openai/gpt-x": "auto:discovery" }); + const r = reconcileContextWindows(discovered, deps); + assert.deepEqual(removes, [["openai", "gpt-x"]]); + assert.equal(r.removed, 1); + }); + + it("never overwrites or removes a manual override", () => { + const discovered: DiscoveredWindow[] = [{ provider: "openai", modelId: "gpt-x", window: 999999 }]; + const { deps, writes, removes } = makeDeps({ "openai/gpt-x": 128000 }, { "openai/gpt-x": "manual" }); + const r = reconcileContextWindows(discovered, deps); + assert.deepEqual(writes, []); + assert.deepEqual(removes, []); + assert.equal(r.skippedManual, 1); + assert.equal(r.written, 0); + }); + + it("skips invalid windows and empty keys", () => { + const discovered: DiscoveredWindow[] = [ + { provider: "openai", modelId: "a", window: 0 }, + { provider: "openai", modelId: "b", window: -5 }, + { provider: "openai", modelId: "c", window: 1.5 }, + { provider: "openai", modelId: "d", window: null }, + { provider: "", modelId: "e", window: 100000 }, + { provider: "openai", modelId: "", window: 100000 }, + ]; + const { deps, writes } = makeDeps({}); + const r = reconcileContextWindows(discovered, deps); + assert.deepEqual(writes, []); + assert.equal(r.scanned, 6); + assert.equal(r.written, 0); + }); + + it("writes an override when the catalog does not know the model (catalog null)", () => { + const discovered: DiscoveredWindow[] = [{ provider: "local", modelId: "my-7b", window: 131072 }]; + const { deps, writes } = makeDeps({}); // catalog null for everything + const r = reconcileContextWindows(discovered, deps); + assert.deepEqual(writes, [["local", "my-7b", 131072]]); + assert.equal(r.written, 1); + }); +}); diff --git a/tests/unit/db-model-context-overrides.test.ts b/tests/unit/db-model-context-overrides.test.ts new file mode 100644 index 0000000000..5584ac3bc9 --- /dev/null +++ b/tests/unit/db-model-context-overrides.test.ts @@ -0,0 +1,90 @@ +import { describe, it, beforeEach, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Feature 5004 — model_context_overrides DB module round-trip. + +const moduleDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-mco-module-")); +process.env.DATA_DIR = moduleDataDir; + +const coreDb = await import("../../src/lib/db/core.ts"); +const mco = await import("../../src/lib/db/modelContextOverrides.ts"); + +function resetStorage() { + coreDb.resetDbInstance(); + fs.rmSync(moduleDataDir, { recursive: true, force: true }); + fs.mkdirSync(moduleDataDir, { recursive: true }); +} + +beforeEach(() => { + resetStorage(); + // Touch the DB so migration 110 creates the table. + coreDb.getDbInstance(); +}); + +after(() => { + coreDb.resetDbInstance(); + fs.rmSync(moduleDataDir, { recursive: true, force: true }); +}); + +describe("modelContextOverrides", () => { + it("returns null when there is no override", () => { + assert.equal(mco.getModelContextOverride("openai", "gpt-5"), null); + assert.equal(mco.getModelContextOverrideRecord("openai", "gpt-5"), null); + }); + + it("round-trips a manual override (set -> get -> record)", () => { + assert.equal(mco.setModelContextOverride("openai", "gpt-5", 400000), true); + assert.equal(mco.getModelContextOverride("openai", "gpt-5"), 400000); + const rec = mco.getModelContextOverrideRecord("openai", "gpt-5"); + assert.equal(rec?.realContext, 400000); + assert.equal(rec?.source, "manual"); + assert.equal(rec?.provider, "openai"); + assert.equal(rec?.modelId, "gpt-5"); + }); + + it("upserts on the same (provider, model) key and records the source", () => { + mco.setModelContextOverride("anthropic", "claude-sonnet-4-5", 200000, "auto:discovery"); + assert.equal(mco.getModelContextOverrideRecord("anthropic", "claude-sonnet-4-5")?.source, "auto:discovery"); + // Re-set as manual overwrites the same row. + mco.setModelContextOverride("anthropic", "claude-sonnet-4-5", 1000000, "manual"); + const rec = mco.getModelContextOverrideRecord("anthropic", "claude-sonnet-4-5"); + assert.equal(rec?.realContext, 1000000); + assert.equal(rec?.source, "manual"); + assert.equal(mco.listModelContextOverrides().length, 1); + }); + + it("rejects non-positive / non-integer windows and empty keys (no write)", () => { + assert.equal(mco.setModelContextOverride("openai", "gpt-5", 0), false); + assert.equal(mco.setModelContextOverride("openai", "gpt-5", -1), false); + assert.equal(mco.setModelContextOverride("openai", "gpt-5", 1.5), false); + assert.equal(mco.setModelContextOverride("", "gpt-5", 1000), false); + assert.equal(mco.setModelContextOverride("openai", " ", 1000), false); + assert.equal(mco.getModelContextOverride("openai", "gpt-5"), null); + }); + + it("trims keys so lookups match writes", () => { + mco.setModelContextOverride(" openai ", " gpt-5 ", 333000); + assert.equal(mco.getModelContextOverride("openai", "gpt-5"), 333000); + }); + + it("removes an override", () => { + mco.setModelContextOverride("groq", "llama-3.3-70b", 128000); + assert.equal(mco.removeModelContextOverride("groq", "llama-3.3-70b"), true); + assert.equal(mco.getModelContextOverride("groq", "llama-3.3-70b"), null); + assert.equal(mco.removeModelContextOverride("groq", "llama-3.3-70b"), false); + }); + + it("lists all overrides", () => { + mco.setModelContextOverride("openai", "gpt-5", 400000); + mco.setModelContextOverride("anthropic", "claude-sonnet-4-5", 200000, "auto:discovery"); + const all = mco.listModelContextOverrides(); + assert.equal(all.length, 2); + assert.deepEqual( + all.map((o) => `${o.provider}/${o.modelId}`).sort(), + ["anthropic/claude-sonnet-4-5", "openai/gpt-5"] + ); + }); +}); diff --git a/tests/unit/model-context-override-readpath.test.ts b/tests/unit/model-context-override-readpath.test.ts new file mode 100644 index 0000000000..e9aef36538 --- /dev/null +++ b/tests/unit/model-context-override-readpath.test.ts @@ -0,0 +1,59 @@ +import { describe, it, beforeEach, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Feature 5004 — getModelContextLimit reads the override before the static catalog. + +const moduleDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-mco-readpath-")); +process.env.DATA_DIR = moduleDataDir; + +const coreDb = await import("../../src/lib/db/core.ts"); +const mco = await import("../../src/lib/db/modelContextOverrides.ts"); +const caps = await import("../../src/lib/modelCapabilities.ts"); + +beforeEach(() => { + coreDb.resetDbInstance(); + fs.rmSync(moduleDataDir, { recursive: true, force: true }); + fs.mkdirSync(moduleDataDir, { recursive: true }); + coreDb.getDbInstance(); +}); + +after(() => { + coreDb.resetDbInstance(); + fs.rmSync(moduleDataDir, { recursive: true, force: true }); +}); + +describe("getModelContextLimit override precedence (5004)", () => { + it("an override wins over the catalog, and removing it falls back to the catalog", () => { + // Read the override-free catalog value dynamically (non-brittle for any model). + const catalog = caps.getResolvedModelCapabilities({ provider: "openai", model: "gpt-4o" }) + .contextWindow; + const distinct = (catalog ?? 0) + 12345; + + mco.setModelContextOverride("openai", "gpt-4o", distinct); + assert.equal(caps.getModelContextLimit("openai", "gpt-4o"), distinct, "override must win"); + + mco.removeModelContextOverride("openai", "gpt-4o"); + assert.equal( + caps.getModelContextLimit("openai", "gpt-4o"), + catalog, + "absence must fall back to the catalog" + ); + }); + + it("an override surfaces a window for a model the catalog does not know", () => { + assert.equal(caps.getModelContextLimit("custom-local", "my-7b-128k"), null); + mco.setModelContextOverride("custom-local", "my-7b-128k", 131072); + assert.equal(caps.getModelContextLimit("custom-local", "my-7b-128k"), 131072); + }); + + it("leaves getResolvedModelCapabilities override-free (so the reconciler sees the catalog)", () => { + mco.setModelContextOverride("openai", "gpt-4o", 999999, "auto:discovery"); + const catalog = caps.getResolvedModelCapabilities({ provider: "openai", model: "gpt-4o" }) + .contextWindow; + assert.notEqual(catalog, 999999, "getResolvedModelCapabilities must not reflect the override"); + assert.equal(caps.getModelContextLimit("openai", "gpt-4o"), 999999, "but getModelContextLimit does"); + }); +});