diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 05a0066434..2e31708636 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -136,7 +136,7 @@ "open-sse/services/claudeCodeCompatible.ts": 1202, "_rebaseline_pr4592_exclude_exhausted_auto": "Reconcile #4592 already-merged growth: combo.ts 2991->3036 (+45, terminal-status quota-cutoff exclusion in buildAutoCandidates + opt-in gate). Fast-gate PR->release does not run check:file-size.", "open-sse/services/combo.ts": 3036, - "open-sse/services/compression/strategySelector.ts": 818, + "open-sse/services/compression/strategySelector.ts": 848, "open-sse/services/rateLimitManager.ts": 1035, "open-sse/services/tokenRefresh.ts": 1997, "open-sse/services/usage.ts": 3450, @@ -304,5 +304,6 @@ "_rebaseline_2026_06_19_4293_codex_spark_scope": "PR #4293 (isolate Codex Spark quota scope) own growth, MEASURED on the actual merged tree (release/v3.8.30 + #4293). Production: auth.ts 2219->2279 (+60) threads requestedModel into Codex quota-policy/headroom/preflight/P2C scoring so normal Codex and GPT-5.3-Codex-Spark windows are evaluated independently; chatCore.ts 5116->5125 (+9) passes the failing model scope into Codex 429 failover (markCodexScopeRateLimited) instead of a connection-wide rateLimitedUntil write; accountFallback.ts 1727->1731 (+4) scopes Codex model-lock keys to codex vs spark. Heavy parsing/display logic lives in new leaf helpers under the cap (codexQuotaScopes.ts, codexUsageQuotas.ts, codexFailover.ts). Tests: account-fallback-service 1544->1569, executor-codex 1336->1339, sse-auth 1527->1553, usage-service-hardening 1612->1633 (added Spark-scope regression coverage). Cohesive wiring at existing selection/failover lockout boundaries; not extractable.", "_rebaseline_2026_06_20_4447_openai_gpt41mini_o_mini_pricing": "PR #4447 own growth: pricing.ts 1592->1620 (+28 = pure-data pricing rows closing the null/$0 gap for registry-exposed OpenAI ids gpt-4.1-mini, gpt-4.1-nano, o3-mini, o4-mini that tripped the catalog pricing gate; getPricingForModel does an exact lookup, so a missing key resolves to null. Official OpenAI per-1M prices + the table's derived-field convention (reasoning=output*1.5, cache_creation=input, cached=official). Restore-green for a pre-existing release/v3.8.32 red surfaced by #4432's __RUN_ALL__ run. Cohesive data; not extractable.", "_rebaseline_2026_06_20_web_cookie_validator_shadow_fix": "validation.ts 4518->4522 (+4 = move the generic web-cookie validateWebCookieProvider dispatch from the TOP of validateProviderApiKey to a FALLBACK after SPECIALTY_VALIDATORS, plus a comment, so #4023's generic AUTH_007 ping no longer shadows the rich per-provider validators (grok-web #3474 IP-reputation/Cloudflare, chatgpt-web cf-mitigated, claude/gemini/copilot/qwen/t3-web). Restores provider-validation-specialty.test.ts (112/112) while keeping web-cookie-auth007 (5/5). Behavior fix at an existing dispatch boundary; not extractable.", - "_rebaseline_2026_06_22_phase4b_slm_tier_ultra": "Compression Phase 4 (B) SLM tier own growth: open-sse/services/compression/strategySelector.ts 783->818 (+35 at the existing applyUltraAsync chokepoint). The no-modelPath ultra branch (previously a one-line passthrough to the sync applyCompression) now runs the two-tier resolver: it adapts the body, builds the ultraConfig (threading config.ultraEngine + preserveSystemPrompt), awaits the now-async ultraCompress (SLM Tier-B when ultraEngine===slm and the worker backend is available, else fail-open to the Tier-A heuristic), and threads result.stats.ultraTier into the returned CompressionStats so the resolved tier reaches the D0 telemetry persister. The sync applyCompression ultra branch is also re-pointed to the new pure ultraCompressHeuristic. The two-tier resolver + the pure heuristic live in open-sse/services/compression/ultra.ts and the thin SLM entry in engines/llmlingua/ultraEntry.ts (both 818 (+35 at the existing applyUltraAsync chokepoint). The no-modelPath ultra branch (previously a one-line passthrough to the sync applyCompression) now runs the two-tier resolver: it adapts the body, builds the ultraConfig (threading config.ultraEngine + preserveSystemPrompt), awaits the now-async ultraCompress (SLM Tier-B when ultraEngine===slm and the worker backend is available, else fail-open to the Tier-A heuristic), and threads result.stats.ultraTier into the returned CompressionStats so the resolved tier reaches the D0 telemetry persister. The sync applyCompression ultra branch is also re-pointed to the new pure ultraCompressHeuristic. The two-tier resolver + the pure heuristic live in open-sse/services/compression/ultra.ts and the thin SLM entry in engines/llmlingua/ultraEntry.ts (both 848 (+30 at the existing selectCompressionPlan dispatch chokepoint). selectCompressionPlan gains an 8th optional `adaptiveOptions` param (modelContextLimit/requestMaxTokens/onAdaptive sink) and, after resolveBasePlan and before the caching-aware pass, runs the PURE resolveAdaptivePlan when config.contextBudget.mode is floor|replace-autotrigger; the new adaptiveEnabled(config) helper also gates the legacy shouldAutoTrigger branch inside resolveBasePlan off when adaptive owns automatic-by-size escalation (D-C4). The escalation ladder, target computation, and the resolver itself live in open-sse/services/compression/adaptiveCompression/{computeTarget,ladder,resolveAdaptivePlan,types}.ts (all ; + // Adaptive context-budget (Sub-project C): model context window + request max_tokens drive + // the budget target. getTokenLimit is already imported; provider/effectiveModel resolved above. + const adaptiveModelContextLimit = + provider && effectiveModel ? getTokenLimit(provider, effectiveModel) : null; + const requestMaxTokens = + typeof (compressionInputBody as Record)?.max_tokens === "number" + ? ((compressionInputBody as Record).max_tokens as number) + : null; + let adaptiveTelemetry: import("../services/compression/adaptiveCompression/types.ts").AdaptiveTelemetry | null = null; const compressionPlan = selectCompressionPlan( config, compressionComboKey, @@ -1169,9 +1178,20 @@ export async function handleChatCore({ compressionInputBody, { provider, targetFormat, model: effectiveModel }, namedCombos, - compressionHeader + compressionHeader, + { + modelContextLimit: adaptiveModelContextLimit, + requestMaxTokens: requestMaxTokens, + onAdaptive: (t) => { adaptiveTelemetry = t; }, + } ); const mode = compressionPlan.mode as CompressionConfig["defaultMode"]; + if (adaptiveTelemetry && adaptiveTelemetry.fit === false) { + log?.warn?.( + "COMPRESSION", + `adaptive budget-exceeded: target=${adaptiveTelemetry.target} headroomAfter=${adaptiveTelemetry.headroomAfter} stages=${adaptiveTelemetry.stagesApplied.join(",")} (best-effort plan sent, content preserved)` + ); + } compressionResponseMeta = formatCompressionMeta(compressionPlan); // When the per-engine toggle map derives a stacked pipeline (and no named/routing // combo already set config.stackedPipeline), feed that derived pipeline through so @@ -1252,6 +1272,7 @@ export async function handleChatCore({ engineBreakdown: ensureEngineBreakdown(result.stats), validationWarnings: result.stats.validationWarnings, fallbackApplied: result.stats.fallbackApplied, + ...(adaptiveTelemetry ? { adaptive: adaptiveTelemetry } : {}), timestamp: Date.now(), }; emit("compression.completed", compressionCompletedPayload); diff --git a/open-sse/services/compression/adaptiveCompression/computeTarget.ts b/open-sse/services/compression/adaptiveCompression/computeTarget.ts new file mode 100644 index 0000000000..83cc3d2886 --- /dev/null +++ b/open-sse/services/compression/adaptiveCompression/computeTarget.ts @@ -0,0 +1,31 @@ +import type { ContextBudgetConfig, ContextBudgetPolicy } from "./types.ts"; + +/** + * Pure target-token computation (design D-C1). No clock, no DB, no tokenizer. + * + * @param policy active target policy + * @param modelContextLimit the resolved upstream model's context window (impure caller looks it up) + * @param requestMaxTokens request.max_tokens, if the client sent one (reserve-output only) + * @param config reserves / margin / pct / absoluteBudget + * @returns the maximum prompt-token target the compressed request should fit within + */ +export function computeTarget( + policy: ContextBudgetPolicy, + modelContextLimit: number, + requestMaxTokens: number | null, + config: Pick +): number { + if (policy === "absolute") { + return Math.max(0, Math.floor(config.absoluteBudget)); + } + if (policy === "percentage") { + const pct = config.pct > 0 && config.pct <= 1 ? config.pct : 1; + return Math.max(0, Math.floor(modelContextLimit * pct)); + } + // reserve-output (default): limit − output reservation − safety margin. + const reserve = + typeof requestMaxTokens === "number" && requestMaxTokens > 0 + ? requestMaxTokens + : config.outputReserve; + return Math.max(0, Math.floor(modelContextLimit - reserve - config.safetyMargin)); +} diff --git a/open-sse/services/compression/adaptiveCompression/ladder.ts b/open-sse/services/compression/adaptiveCompression/ladder.ts new file mode 100644 index 0000000000..3f7021b5f7 --- /dev/null +++ b/open-sse/services/compression/adaptiveCompression/ladder.ts @@ -0,0 +1,59 @@ +import type { LadderStage } from "./types.ts"; + +/** + * Default escalation ladder (design D-C2): cheapest/most-lossless → most aggressive. + * Ordered by the engine catalog's stackPriority. `ccr` and `llmlingua` are intentionally + * excluded from the AUTOMATIC ladder (ccr = retrieval markers, llmlingua = optional ONNX + * SLM tier wired through `ultra`); an operator can still add them via ladderOverride. + */ +export const DEFAULT_LADDER: LadderStage[] = [ + { engine: "session-dedup" }, // lossless cross-turn dedup (catalog pri 3) + { engine: "rtk", intensity: "standard" }, // command-output filtering (pri 10) + { engine: "headroom" }, // tabular JSON compaction (pri 15) + { engine: "lite" }, // whitespace/format cleanup (pri 5, but cheap prose pass) + { engine: "caveman", intensity: "full" }, // rule-based prose (pri 20) + { engine: "aggressive" }, // summarize + age old turns (pri 30) + { engine: "ultra" }, // heuristic token pruning + optional SLM (pri 40) +]; + +/** + * Aggressiveness rank used to know where a base plan sits so `floor` mode escalates + * BEYOND it (design §4.2). Keyed by engine id AND by the equivalent CompressionMode name + * ("standard" === caveman) so a base plan's `mode` string maps cleanly. + */ +const AGGRESSIVENESS: Record = { + off: 0, + "session-dedup": 1, + rtk: 2, + headroom: 3, + lite: 4, + caveman: 5, + standard: 5, // mode-name alias for caveman + stacked: 5, // a derived/stacked base plan sits at the prose tier; floor escalates past it + aggressive: 6, + ultra: 7, +}; + +export function aggressivenessOf(engineOrMode: string): number { + return AGGRESSIVENESS[engineOrMode] ?? 0; +} + +/** + * Cheap per-engine EXPECTED reduction factor (output/input). Used by the default injected + * estimator to model "apply this stage" WITHOUT a dry-run (design §9: no per-stage dry-run + * in the hot path). Conservative, monotonic with aggressiveness; never 0 (content preserved). + */ +const REDUCTION_FACTOR: Record = { + "session-dedup": 0.95, + rtk: 0.85, + headroom: 0.8, + lite: 0.92, + caveman: 0.7, + standard: 0.7, + aggressive: 0.55, + ultra: 0.4, +}; + +export function expectedReductionFactor(engine: string): number { + return REDUCTION_FACTOR[engine] ?? 0.9; +} diff --git a/open-sse/services/compression/adaptiveCompression/resolveAdaptivePlan.ts b/open-sse/services/compression/adaptiveCompression/resolveAdaptivePlan.ts new file mode 100644 index 0000000000..d7fcf7214b --- /dev/null +++ b/open-sse/services/compression/adaptiveCompression/resolveAdaptivePlan.ts @@ -0,0 +1,104 @@ +import type { DerivedPlan } from "../deriveDefaultPlan.ts"; +import type { AdaptiveTelemetry, ContextBudgetConfig, LadderStage } from "./types.ts"; +import { DEFAULT_LADDER, aggressivenessOf, expectedReductionFactor } from "./ladder.ts"; +import { computeTarget } from "./computeTarget.ts"; + +export interface ResolveAdaptiveInput { + basePlan: DerivedPlan; + estimatedTokens: number; + modelContextLimit: number | null; + requestMaxTokens: number | null; + config: ContextBudgetConfig; + /** + * Injected per-stage estimator (design §4.1/§9). Returns the NEW token estimate after a + * stage is applied to a prompt of `priorTokens`. Default models it with the cheap + * per-engine expected-reduction factor (no dry-run, no real tokenizer). + */ + estimate?: (priorTokens: number, stage: LadderStage) => number; +} + +export interface ResolveAdaptiveResult { + plan: DerivedPlan; + telemetry: AdaptiveTelemetry | null; +} + +const defaultEstimate = (prior: number, stage: LadderStage): number => + Math.round(prior * expectedReductionFactor(stage.engine)); + +/** + * Pure adaptive resolver (design §4.2). Floors/escalates a base plan so the (estimated) + * compressed prompt fits the context budget. Never drops content: if the ladder is + * exhausted while still over target, returns the best-effort plan with fit=false. + * + * Returns telemetry=null only when there is no usable target (unknown model limit) — the + * caller then skips adaptive entirely (design §6: skip, never throw). + */ +export function resolveAdaptivePlan(input: ResolveAdaptiveInput): ResolveAdaptiveResult { + const { basePlan, estimatedTokens, modelContextLimit, requestMaxTokens, config } = input; + const estimate = input.estimate ?? defaultEstimate; + + if (config.mode === "off") return { plan: basePlan, telemetry: null }; + if (!modelContextLimit || modelContextLimit <= 0) { + return { plan: basePlan, telemetry: null }; // unknown limit → skip (D-C / §6) + } + + const target = computeTarget(config.policy, modelContextLimit, requestMaxTokens, config); + const headroomBefore = target - estimatedTokens; + + // replace-autotrigger only acts on a bare Default/off base plan; an explicit choice wins. + const baseRank = aggressivenessOf(basePlan.mode); + if (config.mode === "replace-autotrigger" && baseRank > aggressivenessOf("off")) { + return { + plan: basePlan, + telemetry: { policy: config.policy, target, headroomBefore, stagesApplied: [], headroomAfter: headroomBefore, fit: headroomBefore >= 0 }, + }; + } + + // Already fits → never over-compress (D-C2). + if (headroomBefore >= 0) { + return { + plan: basePlan, + telemetry: { policy: config.policy, target, headroomBefore, stagesApplied: [], headroomAfter: headroomBefore, fit: true }, + }; + } + + // Escalation: start just ABOVE the base plan's aggressiveness (floor escalates beyond it). + const ladder = config.ladderOverride && config.ladderOverride.length > 0 ? config.ladderOverride : DEFAULT_LADDER; + const startTier = config.mode === "floor" ? baseRank : aggressivenessOf("off"); + const stages = ladder.filter((s) => aggressivenessOf(s.engine) > startTier); + + let current = estimatedTokens; + const applied: LadderStage[] = []; + for (const stage of stages) { + current = estimate(current, stage); + applied.push(stage); + if (current <= target) break; + } + + const headroomAfter = target - current; + const fit = headroomAfter >= 0; + return { + plan: planFromStages(basePlan, applied), + telemetry: { + policy: config.policy, + target, + headroomBefore, + stagesApplied: applied.map((s) => s.engine), + headroomAfter, + fit, + }, + }; +} + +/** + * Turn the applied ladder stages into a DerivedPlan. A single applied stage that is a + * single-mode engine collapses to that engine's mode; otherwise a stacked pipeline. + * The base plan's own pipeline is preserved as the prefix (floor escalates AFTER the base). + */ +function planFromStages(basePlan: DerivedPlan, applied: LadderStage[]): DerivedPlan { + if (applied.length === 0) return basePlan; + const basePipeline = + basePlan.mode === "stacked" ? basePlan.stackedPipeline : []; + const stackedPipeline = [...basePipeline, ...applied]; + return { mode: "stacked", stackedPipeline }; +} diff --git a/open-sse/services/compression/adaptiveCompression/types.ts b/open-sse/services/compression/adaptiveCompression/types.ts new file mode 100644 index 0000000000..a5903a27f6 --- /dev/null +++ b/open-sse/services/compression/adaptiveCompression/types.ts @@ -0,0 +1,61 @@ +/** + * Context-budget adaptive compression — shared types. + * + * Naming note: "adaptiveCompression"/"contextBudget" — NOT "headroom" (which is an + * unrelated existing engine). "headroom" here = the budget signal (target − prompt tokens). + */ + +/** Target-derivation policy (design D-C1). */ +export type ContextBudgetPolicy = "reserve-output" | "percentage" | "absolute"; + +/** + * Adaptive mode (design D-C3/C4): + * - "floor" : guarantee fit; escalate BEYOND any base plan. + * - "replace-autotrigger" : only acts when the base plan is bare Default/off (an explicit + * operator/client choice always wins, even if it overflows). + * - "off" : legacy binary auto-trigger (full backward-compat). + */ +export type ContextBudgetMode = "floor" | "replace-autotrigger" | "off"; + +/** One escalation stage = an engine id applied at an optional intensity. */ +export interface LadderStage { + engine: string; + intensity?: string; +} + +/** Persisted adaptive settings (design §4.4). All optional with safe defaults in computeTarget. */ +export interface ContextBudgetConfig { + mode: ContextBudgetMode; + policy: ContextBudgetPolicy; + /** reserve-output: tokens reserved for the model's output when request.max_tokens is absent. */ + outputReserve: number; + /** reserve-output: extra tokens shaved off the limit as a safety buffer. */ + safetyMargin: number; + /** percentage policy: fraction of the model context window to target (0 < pct <= 1). */ + pct: number; + /** absolute policy: a model-independent token budget. */ + absoluteBudget: number; + /** Operator override of the default escalation ladder (cheapest → most aggressive). */ + ladderOverride?: LadderStage[]; +} + +/** The `adaptive` block of the shared CompressionRunTelemetry contract (roadmap overview). */ +export interface AdaptiveTelemetry { + policy: ContextBudgetPolicy; + target: number; + headroomBefore: number; + stagesApplied: string[]; + headroomAfter: number; + /** false => budget-exceeded (best-effort plan sent as-is; content never dropped). */ + fit: boolean; +} + +/** Safe defaults applied when a field is absent (design §4.4 / §6). */ +export const DEFAULT_CONTEXT_BUDGET: ContextBudgetConfig = { + mode: "off", + policy: "reserve-output", + outputReserve: 4096, + safetyMargin: 1024, + pct: 0.85, + absoluteBudget: 0, +}; diff --git a/open-sse/services/compression/strategySelector.ts b/open-sse/services/compression/strategySelector.ts index 41fcb82b50..264d8ff086 100644 --- a/open-sse/services/compression/strategySelector.ts +++ b/open-sse/services/compression/strategySelector.ts @@ -29,6 +29,8 @@ import { deriveDefaultPlanFromConfig, buildNamedComboLookup, } from "./planResolution.ts"; +import { resolveAdaptivePlan } from "./adaptiveCompression/resolveAdaptivePlan.ts"; +import type { AdaptiveTelemetry } from "./adaptiveCompression/types.ts"; // Re-export so existing importers (resolver test + chatCore dynamic import) keep resolving. export { planFromHeader, formatCompressionMeta, buildNamedComboLookup }; @@ -68,6 +70,12 @@ export function shouldAutoTrigger(config: CompressionConfig, estimatedTokens: nu * `combos` defaults to `{}` so Phase-1 callers are unchanged; when supplied, chatCore passes * its DB-loaded named-combo map so the active profile can resolve here purely (no DB import). */ +/** True when the adaptive resolver owns automatic-by-size escalation (D-C4). */ +function adaptiveEnabled(config: CompressionConfig): boolean { + const mode = config.contextBudget?.mode; + return mode === "floor" || mode === "replace-autotrigger"; +} + function resolveBasePlan( config: CompressionConfig, comboId: string | null, @@ -101,7 +109,7 @@ function resolveBasePlan( ); } - if (shouldAutoTrigger(config, estimatedTokens)) { + if (!adaptiveEnabled(config) && shouldAutoTrigger(config, estimatedTokens)) { const mode = config.autoTriggerMode ?? "lite"; return withSource( mode === "stacked" @@ -154,6 +162,13 @@ export function getEffectiveMode( * The caching-aware mode adjustment is applied to `mode` exactly as in * {@link selectCompressionStrategy}. */ +/** Adaptive (Sub-project C) inputs + telemetry sink for selectCompressionPlan. */ +export interface AdaptiveSelectOptions { + modelContextLimit?: number | null; + requestMaxTokens?: number | null; + onAdaptive?: (telemetry: AdaptiveTelemetry) => void; +} + export function selectCompressionPlan( config: CompressionConfig, comboId: string | null, @@ -161,9 +176,24 @@ export function selectCompressionPlan( body?: Record, context?: CachingDetectionContext, combos: NamedCombos = {}, - header: string | null = null + header: string | null = null, + adaptiveOptions?: AdaptiveSelectOptions ): DerivedPlan { - const plan = resolveBasePlan(config, comboId, estimatedTokens, combos, header); + let plan = resolveBasePlan(config, comboId, estimatedTokens, combos, header); + + // Adaptive context-budget floor/escalation (D-C4): after the base plan, replacing the + // (now-bypassed) auto-trigger branch. Pure resolver; chatCore supplies the model limit. + if (adaptiveEnabled(config) && config.contextBudget) { + const { plan: adaptivePlan, telemetry } = resolveAdaptivePlan({ + basePlan: plan, + estimatedTokens, + modelContextLimit: adaptiveOptions?.modelContextLimit ?? null, + requestMaxTokens: adaptiveOptions?.requestMaxTokens ?? null, + config: config.contextBudget, + }); + plan = adaptivePlan; + if (telemetry && adaptiveOptions?.onAdaptive) adaptiveOptions.onAdaptive(telemetry); + } // Apply caching-aware adjustments to the mode if body is provided if (body) { diff --git a/open-sse/services/compression/types.ts b/open-sse/services/compression/types.ts index f02601e574..3331aa6f69 100644 --- a/open-sse/services/compression/types.ts +++ b/open-sse/services/compression/types.ts @@ -10,6 +10,7 @@ */ import { ENGINE_IDS } from "./engineCatalog.ts"; +import type { ContextBudgetConfig } from "./adaptiveCompression/types.ts"; // Re-export so consumers that already import from this module (e.g. src/lib/db/compression.ts) // can get ENGINE_IDS without a second bare `@omniroute/open-sse/...engineCatalog.ts` specifier. @@ -161,6 +162,13 @@ export interface CompressionConfig { * change for installs that predate the panel). Set by `getCompressionSettings`. */ enginesExplicit?: boolean; + /** + * Context-budget adaptive compression (Sub-project C). Absent / mode:"off" = legacy + * binary auto-trigger (byte-identical). When mode is "floor" or "replace-autotrigger" + * the adaptive resolver owns automatic-by-size escalation and the legacy + * shouldAutoTrigger branch is bypassed. + */ + contextBudget?: ContextBudgetConfig; /** * Phase 4 (B): which tier the `ultra` mode uses. * "heuristic" = Tier-A token pruner (`pruneByScore`, default, byte-identical to pre-B). diff --git a/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx b/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx index 56fed47ffc..192ba9f39b 100644 --- a/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx +++ b/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx @@ -28,6 +28,11 @@ import { outputStyleMeta, } from "../../../../../../open-sse/services/compression/outputStyles/catalog.ts"; import { deriveDefaultPlan } from "../../../../../../open-sse/services/compression/deriveDefaultPlan.ts"; +import { + DEFAULT_CONTEXT_BUDGET, + type ContextBudgetConfig, +} from "../../../../../../open-sse/services/compression/adaptiveCompression/types.ts"; +import { formatAdaptiveTarget } from "./adaptiveTargetLabel.ts"; type CavemanIntensity = "lite" | "full" | "ultra"; @@ -56,6 +61,10 @@ interface CompressionConfig { ultraEngine?: "heuristic" | "slm"; // Best-effort pre-warm of the SLM model on enable / cold restart. Default false. ultraSlmPrewarm?: boolean; + // Phase 4 (C): adaptive context-budget. Absent / mode:"off" = legacy auto-trigger. + // The panel currently surfaces the computed target read-only; mode/policy editors are a + // follow-up (the load/save path does not yet populate this field). + contextBudget?: ContextBudgetConfig; } const CAVEMAN_OUTPUT_LEVELS: CavemanIntensity[] = ["lite", "full", "ultra"]; @@ -248,6 +257,14 @@ export default function CompressionPanel() { Effective pipeline: {derivedText} + {/* Adaptive context-budget — read-only computed target (Phase 4C, D-C1 transparency) */} +
+ {formatAdaptiveTarget(config.contextBudget ?? DEFAULT_CONTEXT_BUDGET, 200000)} +
+ {/* Engine grid */}
{ENGINE_IDS.map((id) => { diff --git a/src/app/(dashboard)/dashboard/context/settings/adaptiveTargetLabel.ts b/src/app/(dashboard)/dashboard/context/settings/adaptiveTargetLabel.ts new file mode 100644 index 0000000000..11827a500a --- /dev/null +++ b/src/app/(dashboard)/dashboard/context/settings/adaptiveTargetLabel.ts @@ -0,0 +1,17 @@ +import { computeTarget } from "../../../../../../open-sse/services/compression/adaptiveCompression/computeTarget.ts"; +import type { ContextBudgetConfig } from "../../../../../../open-sse/services/compression/adaptiveCompression/types.ts"; + +/** + * Read-only label for the compression panel (design D-C1 transparency). Shows the active + * policy and the computed token target for a representative model context window. PURE — + * imports only the pure computeTarget leaf, no DB, no clock. The panel renders this string + * as an informational/diagnostic line, mirroring the derived-pipeline preview. + */ +export function formatAdaptiveTarget( + config: ContextBudgetConfig, + representativeModelContextLimit: number +): string { + if (config.mode === "off") return "Adaptive context budget: off (legacy auto-trigger)"; + const target = computeTarget(config.policy, representativeModelContextLimit, null, config); + return `Adaptive (${config.mode}, policy: ${config.policy}) — target ≈ ${target.toLocaleString()} tokens (for a ${representativeModelContextLimit.toLocaleString()}-token window)`; +} diff --git a/tests/unit/compression/adaptive-chatcore-source-guard.test.ts b/tests/unit/compression/adaptive-chatcore-source-guard.test.ts new file mode 100644 index 0000000000..0e0310c5c0 --- /dev/null +++ b/tests/unit/compression/adaptive-chatcore-source-guard.test.ts @@ -0,0 +1,20 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const chatCore = readFileSync( + fileURLToPath(new URL("../../../open-sse/handlers/chatCore.ts", import.meta.url)), + "utf8" +); + +test("chatCore threads modelContextLimit + requestMaxTokens into selectCompressionPlan", () => { + // the adaptive options object literal must reference both inputs + assert.match(chatCore, /modelContextLimit:/); + assert.match(chatCore, /requestMaxTokens:/); +}); + +test("chatCore emits the adaptive telemetry block via onAdaptive", () => { + assert.match(chatCore, /onAdaptive/); + assert.match(chatCore, /adaptiveTelemetry/); +}); diff --git a/tests/unit/compression/adaptive-compute-target.test.ts b/tests/unit/compression/adaptive-compute-target.test.ts new file mode 100644 index 0000000000..a0c8761fd2 --- /dev/null +++ b/tests/unit/compression/adaptive-compute-target.test.ts @@ -0,0 +1,30 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { computeTarget } from "@omniroute/open-sse/services/compression/adaptiveCompression/computeTarget.ts"; +import { DEFAULT_CONTEXT_BUDGET } from "@omniroute/open-sse/services/compression/adaptiveCompression/types.ts"; + +const base = { ...DEFAULT_CONTEXT_BUDGET, outputReserve: 4096, safetyMargin: 1024 }; + +test("reserve-output: request.max_tokens wins over outputReserve default", () => { + // 200000 − 8000 (request max_tokens) − 1024 (safetyMargin) = 190976 + const t = computeTarget("reserve-output", 200000, 8000, base); + assert.equal(t, 190976); +}); + +test("reserve-output: falls back to outputReserve when no max_tokens", () => { + // 200000 − 4096 (outputReserve) − 1024 (safetyMargin) = 194880 + assert.equal(computeTarget("reserve-output", 200000, null, base), 194880); + assert.equal(computeTarget("reserve-output", 200000, 0, base), 194880); // 0 is not a positive reserve +}); + +test("percentage policy: limit × pct, floored", () => { + assert.equal(computeTarget("percentage", 200000, null, { ...base, pct: 0.7 }), 140000); + // invalid pct (out of (0,1]) → treated as 1.0 (no shrink) + assert.equal(computeTarget("percentage", 200000, null, { ...base, pct: 0 }), 200000); + assert.equal(computeTarget("percentage", 200000, null, { ...base, pct: 1.5 }), 200000); +}); + +test("absolute policy: model-independent budget", () => { + assert.equal(computeTarget("absolute", 200000, 8000, { ...base, absoluteBudget: 50000 }), 50000); + assert.equal(computeTarget("absolute", 8000, null, { ...base, absoluteBudget: 50000 }), 50000); +}); diff --git a/tests/unit/compression/adaptive-panel-target.test.ts b/tests/unit/compression/adaptive-panel-target.test.ts new file mode 100644 index 0000000000..df5071abe1 --- /dev/null +++ b/tests/unit/compression/adaptive-panel-target.test.ts @@ -0,0 +1,31 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { formatAdaptiveTarget } from "../../../src/app/(dashboard)/dashboard/context/settings/adaptiveTargetLabel.ts"; + +test("formatAdaptiveTarget shows policy + computed target for reserve-output", () => { + const label = formatAdaptiveTarget( + { mode: "floor", policy: "reserve-output", outputReserve: 4096, safetyMargin: 1024, pct: 0.85, absoluteBudget: 0 }, + 200000 + ); + assert.match(label, /reserve-output/); + // 200000 − 4096 − 1024 = 194880; tolerate any digit-group separator (locale-dependent toLocaleString). + assert.match(label, /194[.,\s ]?880|194880/); +}); + +test("formatAdaptiveTarget shows off when disabled", () => { + const label = formatAdaptiveTarget( + { mode: "off", policy: "reserve-output", outputReserve: 4096, safetyMargin: 1024, pct: 0.85, absoluteBudget: 0 }, + 200000 + ); + assert.match(label, /off|disabled/i); +}); + +test("formatAdaptiveTarget reflects percentage policy target", () => { + const label = formatAdaptiveTarget( + { mode: "floor", policy: "percentage", outputReserve: 4096, safetyMargin: 1024, pct: 0.7, absoluteBudget: 0 }, + 200000 + ); + assert.match(label, /percentage/); + // 200000 × 0.7 = 140000 + assert.match(label, /140[.,\s ]?000|140000/); +}); diff --git a/tests/unit/compression/adaptive-resolve-plan.test.ts b/tests/unit/compression/adaptive-resolve-plan.test.ts new file mode 100644 index 0000000000..b294dbdbf0 --- /dev/null +++ b/tests/unit/compression/adaptive-resolve-plan.test.ts @@ -0,0 +1,150 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + DEFAULT_LADDER, + aggressivenessOf, + expectedReductionFactor, +} from "@omniroute/open-sse/services/compression/adaptiveCompression/ladder.ts"; + +test("default ladder is cheapest → most aggressive (stackPriority order)", () => { + assert.deepEqual( + DEFAULT_LADDER.map((s) => s.engine), + ["session-dedup", "rtk", "headroom", "lite", "caveman", "aggressive", "ultra"] + ); +}); + +test("aggressivenessOf increases monotonically along the ladder", () => { + const ranks = DEFAULT_LADDER.map((s) => aggressivenessOf(s.engine)); + for (let i = 1; i < ranks.length; i++) { + assert.ok(ranks[i] > ranks[i - 1], `rank must increase at index ${i}`); + } + // a base "lite" plan ranks below caveman/aggressive/ultra (so floor escalates beyond it) + assert.ok(aggressivenessOf("lite") < aggressivenessOf("caveman")); + assert.ok(aggressivenessOf("standard") === aggressivenessOf("caveman")); // mode-name alias +}); + +test("expectedReductionFactor is in (0,1) and heavier engines reduce more", () => { + assert.ok(expectedReductionFactor("rtk") < 1 && expectedReductionFactor("rtk") > 0); + assert.ok(expectedReductionFactor("ultra") < expectedReductionFactor("rtk")); +}); + +import { resolveAdaptivePlan } from "@omniroute/open-sse/services/compression/adaptiveCompression/resolveAdaptivePlan.ts"; +import { DEFAULT_CONTEXT_BUDGET } from "@omniroute/open-sse/services/compression/adaptiveCompression/types.ts"; + +const cfg = (over = {}) => ({ ...DEFAULT_CONTEXT_BUDGET, mode: "floor" as const, ...over }); +const basePlan = { mode: "off", stackedPipeline: [] as Array<{ engine: string; intensity?: string }> }; + +test("already fits → base plan unchanged, fit=true, no stages", () => { + const { plan, telemetry } = resolveAdaptivePlan({ + basePlan, + estimatedTokens: 1000, // well under target + modelContextLimit: 200000, + requestMaxTokens: 8000, + config: cfg(), + }); + assert.deepEqual(plan, basePlan); + assert.ok(telemetry); + assert.equal(telemetry!.fit, true); + assert.deepEqual(telemetry!.stagesApplied, []); + assert.equal(telemetry!.target, 200000 - 8000 - 1024); + assert.ok(telemetry!.headroomBefore > 0); + assert.equal(telemetry!.headroomAfter, telemetry!.headroomBefore); +}); + +test("over target → escalates and stops at first fitting stage (no over-escalation)", () => { + // Injected estimator: each stage halves the prompt. target = 200000-8000-1024 = 190976. + // Start over target at 400000: stage1 → 200000 (still over), stage2 → 100000 (fits) → STOP. + const halve = (prior: number) => Math.round(prior / 2); + const { plan, telemetry } = resolveAdaptivePlan({ + basePlan: { mode: "off", stackedPipeline: [] }, + estimatedTokens: 400000, + modelContextLimit: 200000, + requestMaxTokens: 8000, + config: cfg(), + estimate: halve, + }); + assert.equal(telemetry!.fit, true); + assert.equal(telemetry!.stagesApplied.length, 2); // stopped after the 2nd stage + assert.equal(plan.mode, "stacked"); + assert.equal(plan.stackedPipeline.length, 2); + // first two ladder engines above "off": session-dedup then rtk + assert.deepEqual(telemetry!.stagesApplied, ["session-dedup", "rtk"]); + assert.ok(telemetry!.headroomAfter >= 0); +}); + +test("floor escalates beyond a light base plan (base lite → starts above lite)", () => { + const halve = (prior: number) => Math.round(prior / 2); + const { telemetry } = resolveAdaptivePlan({ + basePlan: { mode: "lite", stackedPipeline: [] }, + estimatedTokens: 400000, + modelContextLimit: 200000, + requestMaxTokens: 8000, + config: cfg(), + estimate: halve, + }); + // base "lite" rank=4; ladder stages above rank 4 = caveman(5), aggressive(6), ultra(7). + assert.equal(telemetry!.stagesApplied[0], "caveman"); + assert.ok(!telemetry!.stagesApplied.includes("session-dedup")); // did NOT restart below lite + assert.ok(!telemetry!.stagesApplied.includes("rtk")); +}); + +test("ladder exhausted, still over target → fit=false, all stages applied, plan still set", () => { + const noop = (prior: number) => prior; // estimator never reduces → never fits + const { plan, telemetry } = resolveAdaptivePlan({ + basePlan: { mode: "off", stackedPipeline: [] }, + estimatedTokens: 999999, + modelContextLimit: 200000, + requestMaxTokens: 8000, + config: cfg(), + estimate: noop, + }); + assert.equal(telemetry!.fit, false); // budget-exceeded + assert.equal(telemetry!.stagesApplied.length, 7); // entire DEFAULT_LADDER above "off" + assert.equal(plan.mode, "stacked"); + assert.ok(plan.stackedPipeline.length >= 7); // best-effort plan, content NOT dropped + assert.ok(telemetry!.headroomAfter < 0); +}); + +test("replace-autotrigger: fires on bare off base, defers to an explicit base plan", () => { + const halve = (prior: number) => Math.round(prior / 2); + // bare off base → it acts + const acts = resolveAdaptivePlan({ + basePlan: { mode: "off", stackedPipeline: [] }, + estimatedTokens: 400000, modelContextLimit: 200000, requestMaxTokens: 8000, + config: cfg({ mode: "replace-autotrigger" }), estimate: halve, + }); + assert.ok(acts.telemetry!.stagesApplied.length > 0); + + // explicit aggressive base → defer (choice wins, may overflow) + const defers = resolveAdaptivePlan({ + basePlan: { mode: "aggressive", stackedPipeline: [] }, + estimatedTokens: 400000, modelContextLimit: 200000, requestMaxTokens: 8000, + config: cfg({ mode: "replace-autotrigger" }), estimate: halve, + }); + assert.deepEqual(defers.plan, { mode: "aggressive", stackedPipeline: [] }); + assert.deepEqual(defers.telemetry!.stagesApplied, []); + assert.equal(defers.telemetry!.fit, false); // 400000 > target, recorded as not fitting +}); + +test("unknown model context limit → skip adaptive (null telemetry, base unchanged)", () => { + for (const lim of [null, 0, -1]) { + const { plan, telemetry } = resolveAdaptivePlan({ + basePlan, estimatedTokens: 999999, modelContextLimit: lim, + requestMaxTokens: 8000, config: cfg(), + }); + assert.equal(telemetry, null); + assert.deepEqual(plan, basePlan); + } +}); + +test("hard-off: floor still escalates an overflowing 'off' base plan (spec §9)", () => { + const halve = (prior: number) => Math.round(prior / 2); + const { telemetry } = resolveAdaptivePlan({ + basePlan: { mode: "off", stackedPipeline: [] }, // explicit off (header off resolves to this) + estimatedTokens: 400000, modelContextLimit: 200000, requestMaxTokens: 8000, + config: cfg(), // mode: "floor" + estimate: halve, + }); + assert.ok(telemetry!.stagesApplied.length > 0); // floor escalated despite off + assert.equal(telemetry!.fit, true); +}); diff --git a/tests/unit/compression/adaptive-select-plan-wiring.test.ts b/tests/unit/compression/adaptive-select-plan-wiring.test.ts new file mode 100644 index 0000000000..c9a3e732a2 --- /dev/null +++ b/tests/unit/compression/adaptive-select-plan-wiring.test.ts @@ -0,0 +1,82 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + selectCompressionPlan, + shouldAutoTrigger, +} from "@omniroute/open-sse/services/compression/strategySelector.ts"; +import { getDefaultCompressionConfig } from "@omniroute/open-sse/services/compression/stats.ts"; + +function legacyCfg() { + return { + ...getDefaultCompressionConfig(), + enabled: true, + enginesExplicit: false, + defaultMode: "lite" as const, + autoTriggerMode: "aggressive" as const, + autoTriggerTokens: 100000, + }; +} + +test("no contextBudget → auto-trigger path is byte-identical to legacy", () => { + const cfg = legacyCfg(); // no contextBudget field + // under threshold → derived default ("lite") + const small = selectCompressionPlan(cfg, null, 1000); + assert.equal(small.mode, "lite"); + // over threshold → auto-trigger fires → "aggressive" + const big = selectCompressionPlan(cfg, null, 200000); + assert.equal(big.mode, "aggressive"); + assert.equal(shouldAutoTrigger(cfg, 200000), true); +}); + +test("contextBudget.mode='off' → identical to no contextBudget", () => { + const cfg = { ...legacyCfg(), contextBudget: { mode: "off" as const } }; + assert.equal(selectCompressionPlan(cfg as any, null, 200000).mode, "aggressive"); +}); + +test("adaptive floor: bypasses auto-trigger, escalates a base plan to fit", () => { + const cfg = { + ...legacyCfg(), + autoTriggerTokens: 100000, + autoTriggerMode: "lite" as const, + contextBudget: { + mode: "floor" as const, + policy: "reserve-output" as const, + outputReserve: 4096, + safetyMargin: 1024, + pct: 0.85, + absoluteBudget: 0, + }, + }; + const tel: { + value: import("@omniroute/open-sse/services/compression/adaptiveCompression/types.ts").AdaptiveTelemetry | null; + } = { value: null }; + // estimatedTokens far over the 200000-window target → adaptive must escalate. + const plan = selectCompressionPlan( + cfg as any, null, 5_000_000, undefined, undefined, {}, null, + { modelContextLimit: 200000, requestMaxTokens: 8000, onAdaptive: (t) => { tel.value = t; } } + ); + assert.equal(plan.mode, "stacked"); + assert.ok(plan.stackedPipeline.length > 0); + assert.ok(tel.value, "adaptive telemetry must be surfaced"); + assert.equal(tel.value!.policy, "reserve-output"); + assert.equal(tel.value!.target, 200000 - 8000 - 1024); + assert.ok(tel.value!.stagesApplied.length > 0); +}); + +test("adaptive escalation still respects caching downgrade (D-C / §6 cache-safety)", () => { + const cfg = { + ...legacyCfg(), + contextBudget: { mode: "floor" as const, policy: "reserve-output" as const, outputReserve: 4096, safetyMargin: 1024, pct: 0.85, absoluteBudget: 0 }, + }; + // A caching provider context downgrades aggressive/ultra → standard; the adaptive plan's + // mode is "stacked", which getCacheAwareStrategy passes through unchanged, but the + // pipeline engines remain those that the existing apply path already cache-guards. + const body = { model: "openai/gpt-5", messages: [{ role: "user", content: "x" }] }; + const plan = selectCompressionPlan( + cfg as any, null, 5_000_000, body, { provider: "openai", model: "openai/gpt-5" }, {}, null, + { modelContextLimit: 200000, requestMaxTokens: 8000 } + ); + // mode is still a valid CompressionMode string after the cache-aware pass + assert.ok(typeof plan.mode === "string"); + assert.ok(plan.stackedPipeline.length > 0); +});