diff --git a/changelog.d/fixes/7005-adaptive-context-budget-dial.md b/changelog.d/fixes/7005-adaptive-context-budget-dial.md new file mode 100644 index 0000000000..4b8e8aee5b --- /dev/null +++ b/changelog.d/fixes/7005-adaptive-context-budget-dial.md @@ -0,0 +1 @@ +- fix(compression): wire the adaptive context-budget "dial" (`contextBudget`) into the settings schema and DB so it can actually be persisted via `PUT /api/settings/compression`, instead of being silently rejected (#7005) diff --git a/docs/compression/COMPRESSION_GUIDE.md b/docs/compression/COMPRESSION_GUIDE.md index 472cf36b61..48bf546093 100644 --- a/docs/compression/COMPRESSION_GUIDE.md +++ b/docs/compression/COMPRESSION_GUIDE.md @@ -278,7 +278,8 @@ Every compressed request includes stats in the server logs: | Phase 1 | Off, Lite | ✅ Shipped | | Phase 2 | Standard, Aggressive, Ultra | ✅ Shipped | | Phase 3 | RTK, Stacked, Compression Combos | ✅ Shipped | -| Phase 4 | Output Styles, SLM-tier Ultra, adaptive context-budget, eval harness | ✅ Shipped | +| Phase 4 | Output Styles, SLM-tier Ultra, eval harness | ✅ Shipped | +| Phase 4C | Adaptive context-budget ("dial") — compute engine + API (`contextBudget` on `PUT /api/settings/compression`) | ✅ Shipped (API-configurable; dashboard controls not yet built, #7005) | --- diff --git a/src/lib/db/compression.ts b/src/lib/db/compression.ts index c1e92101fe..6bb7e0fd1b 100644 --- a/src/lib/db/compression.ts +++ b/src/lib/db/compression.ts @@ -28,6 +28,8 @@ import { type RtkConfig, type UltraConfig, } from "@omniroute/open-sse/services/compression/types.ts"; +import { DEFAULT_CONTEXT_BUDGET } from "@omniroute/open-sse/services/compression/adaptiveCompression/types.ts"; +import { normalizeContextBudgetConfig } from "./compressionContextBudget"; import { isPreserveSystemPromptMode, normalizePreserveSystemPromptMode, @@ -550,6 +552,7 @@ export async function getCompressionSettings(): Promise { stackedPipeline: normalizeStackedPipeline(undefined), aggressive: normalizeAggressiveConfig(undefined), ultra: normalizeUltraConfig(undefined), + contextBudget: normalizeContextBudgetConfig(undefined), contextEditing: { ...DEFAULT_CONTEXT_EDITING_CONFIG }, engines: {}, activeComboId: null, @@ -652,6 +655,9 @@ export async function getCompressionSettings(): Promise { case "ultraConfig": config.ultra = normalizeUltraConfig(parsed); break; + case "contextBudget": + config.contextBudget = normalizeContextBudgetConfig(parsed); + break; case "contextEditing": config.contextEditing = normalizeContextEditingConfig(parsed); break; diff --git a/src/lib/db/compressionContextBudget.ts b/src/lib/db/compressionContextBudget.ts new file mode 100644 index 0000000000..99155f822f --- /dev/null +++ b/src/lib/db/compressionContextBudget.ts @@ -0,0 +1,86 @@ +// Adaptive context-budget "dial" (#7005) DB normalizer, extracted out of compression.ts to keep +// that file under the file-size cap. The compute engine (computeTarget.ts / ladder.ts / +// resolveAdaptivePlan.ts) shipped in PR #4716 but this normalizer never existed, so the +// `contextBudget` setting could never be persisted. Mirrors normalizeUltraConfig/ +// normalizeAggressiveConfig in compression.ts. +import { + DEFAULT_CONTEXT_BUDGET, + type ContextBudgetConfig, + type ContextBudgetMode, + type ContextBudgetPolicy, + type LadderStage, +} from "@omniroute/open-sse/services/compression/adaptiveCompression/types.ts"; + +type JsonRecord = Record; + +function toRecord(value: unknown): JsonRecord { + return value && typeof value === "object" ? (value as JsonRecord) : {}; +} + +function boundedInt(value: unknown, fallback: number, min: number, max: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; + return Math.min(max, Math.max(min, Math.floor(value))); +} + +function boundedNumber(value: unknown, fallback: number, min: number, max: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; + return Math.min(max, Math.max(min, value)); +} + +const CONTEXT_BUDGET_MODES = new Set(["floor", "replace-autotrigger", "off"]); +const CONTEXT_BUDGET_POLICIES = new Set([ + "reserve-output", + "percentage", + "absolute", +]); + +function normalizeLadderOverride(value: unknown): LadderStage[] | undefined { + if (!Array.isArray(value)) return undefined; + const out: LadderStage[] = []; + for (const raw of value) { + const record = toRecord(raw); + if (typeof record.engine !== "string" || !record.engine.trim()) continue; + out.push({ + engine: record.engine, + ...(typeof record.intensity === "string" ? { intensity: record.intensity } : {}), + }); + } + return out.length > 0 ? out : undefined; +} + +export function normalizeContextBudgetConfig(value: unknown): ContextBudgetConfig { + const record = toRecord(value); + const ladderOverride = normalizeLadderOverride(record.ladderOverride); + return { + ...DEFAULT_CONTEXT_BUDGET, + mode: + typeof record.mode === "string" && CONTEXT_BUDGET_MODES.has(record.mode as ContextBudgetMode) + ? (record.mode as ContextBudgetMode) + : DEFAULT_CONTEXT_BUDGET.mode, + policy: + typeof record.policy === "string" && + CONTEXT_BUDGET_POLICIES.has(record.policy as ContextBudgetPolicy) + ? (record.policy as ContextBudgetPolicy) + : DEFAULT_CONTEXT_BUDGET.policy, + outputReserve: boundedInt( + record.outputReserve, + DEFAULT_CONTEXT_BUDGET.outputReserve, + 0, + Number.MAX_SAFE_INTEGER + ), + safetyMargin: boundedInt( + record.safetyMargin, + DEFAULT_CONTEXT_BUDGET.safetyMargin, + 0, + Number.MAX_SAFE_INTEGER + ), + pct: boundedNumber(record.pct, DEFAULT_CONTEXT_BUDGET.pct, 0, 1), + absoluteBudget: boundedInt( + record.absoluteBudget, + DEFAULT_CONTEXT_BUDGET.absoluteBudget, + 0, + Number.MAX_SAFE_INTEGER + ), + ...(ladderOverride ? { ladderOverride } : {}), + }; +} diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 46f2eb67cf..44075cabe2 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -90,9 +90,9 @@ export { reorderCombos, deleteCombo, } from "./db/combos"; - export * from "./db/compressionCacheStats"; export * from "./db/compressionCombos"; +export * from "./db/compressionContextBudget"; export * from "./db/compressionRunTelemetry"; export * from "./db/modelContextOverrides"; diff --git a/src/shared/validation/compressionConfigSchemas.ts b/src/shared/validation/compressionConfigSchemas.ts index c57be7dd7d..b640cf99ff 100644 --- a/src/shared/validation/compressionConfigSchemas.ts +++ b/src/shared/validation/compressionConfigSchemas.ts @@ -266,6 +266,32 @@ export const engineToggleSchema = z.object({ level: z.string().optional(), }); +export const contextBudgetModeSchema = z.enum(["floor", "replace-autotrigger", "off"]); +export const contextBudgetPolicySchema = z.enum(["reserve-output", "percentage", "absolute"]); + +export const contextBudgetLadderStageSchema = z + .object({ + engine: z.string().trim().min(1), + intensity: z.string().optional(), + }) + .strict(); + +// Adaptive context-budget "dial" (#7005): the compute engine shipped in PR #4716 but was +// never wired to this update schema, so any PUT containing `contextBudget` was rejected +// with 400. Mirrors ContextBudgetConfig (open-sse/services/compression/adaptiveCompression/ +// types.ts) and the `.strict()` pattern used by ultraConfigSchema/aggressiveConfigSchema. +export const contextBudgetConfigSchema = z + .object({ + mode: contextBudgetModeSchema.optional(), + policy: contextBudgetPolicySchema.optional(), + outputReserve: z.number().int().min(0).optional(), + safetyMargin: z.number().int().min(0).optional(), + pct: z.number().min(0).max(1).optional(), + absoluteBudget: z.number().int().min(0).optional(), + ladderOverride: z.array(contextBudgetLadderStageSchema).optional(), + }) + .strict(); + export const compressionSettingsUpdateSchema = z .object({ enabled: z.boolean().optional(), @@ -286,6 +312,7 @@ export const compressionSettingsUpdateSchema = z languageConfig: languageConfigSchema.optional(), aggressive: aggressiveConfigSchema.optional(), ultra: ultraConfigSchema.optional(), + contextBudget: contextBudgetConfigSchema.optional(), contextEditing: contextEditingConfigSchema.optional(), engines: z.record(z.string(), engineToggleSchema).optional(), enginesExplicit: z.boolean().optional(), diff --git a/tests/unit/compression/adaptive-context-budget-config.test.ts b/tests/unit/compression/adaptive-context-budget-config.test.ts new file mode 100644 index 0000000000..4dd4acb68c --- /dev/null +++ b/tests/unit/compression/adaptive-context-budget-config.test.ts @@ -0,0 +1,84 @@ +// Regression test for #7005 — adaptive context-budget dial not configurable. +// +// The compute engine for the adaptive context-budget ("dial") shipped in PR #4716 +// (Phase 4C), but it was never wired to persistence or the API: the PUT schema +// rejected any `contextBudget` payload (strict schema, no such key) and the DB-backed +// GET path never surfaced a `contextBudget` field. This test proves both halves of +// the wiring: the Zod schema accepts a `contextBudget` write, and the DB read/write +// path round-trips it. +import { describe, it, beforeEach, afterEach, 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"; + +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-adaptive-context-budget-db-") +); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../../src/lib/db/core.ts"); +const { getCompressionSettings, updateCompressionSettings } = await import( + "../../../src/lib/db/compression.ts" +); +const { compressionSettingsUpdateSchema } = await import( + "../../../src/shared/validation/compressionConfigSchemas.ts" +); +const { DEFAULT_CONTEXT_BUDGET } = await import( + "../../../open-sse/services/compression/adaptiveCompression/types.ts" +); + +beforeEach(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +}); + +afterEach(() => { + core.resetDbInstance(); +}); + +after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (ORIGINAL_DATA_DIR === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = ORIGINAL_DATA_DIR; + } +}); + +describe("bug #7005: adaptive context-budget dial is configurable", () => { + it("compressionSettingsUpdateSchema accepts a contextBudget write", () => { + const result = compressionSettingsUpdateSchema.safeParse({ + contextBudget: { + mode: "floor", + policy: "percentage", + outputReserve: 2048, + safetyMargin: 512, + pct: 0.75, + absoluteBudget: 0, + }, + }); + assert.equal(result.success, true, JSON.stringify("error" in result ? result.error : null)); + }); + + it("getCompressionSettings() defaults contextBudget to DEFAULT_CONTEXT_BUDGET when absent", async () => { + const settings = await getCompressionSettings(); + assert.deepEqual(settings.contextBudget, DEFAULT_CONTEXT_BUDGET); + }); + + it("updateCompressionSettings() persists a partial contextBudget merge", async () => { + await updateCompressionSettings({ + contextBudget: { ...DEFAULT_CONTEXT_BUDGET, mode: "floor", policy: "absolute", absoluteBudget: 8000 }, + }); + const settings = await getCompressionSettings(); + assert.equal(settings.contextBudget?.mode, "floor"); + assert.equal(settings.contextBudget?.policy, "absolute"); + assert.equal(settings.contextBudget?.absoluteBudget, 8000); + // Untouched fields keep their defaults (this is a JSON-column replace like ultra/aggressive, + // not a deep merge — the caller sends the full object, mirroring the existing pattern). + assert.equal(settings.contextBudget?.outputReserve, DEFAULT_CONTEXT_BUDGET.outputReserve); + }); +});