mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 15:22:12 +03:00
* chore(release): open v3.8.38 development cycle
* fix(executors): strip client_metadata for cerebras and mistral (#4727)
Integrated into release/v3.8.38 (leva 5)
* fix(codebuddy): only send reasoning params when client requests reasoning (#5019)
Integrated into release/v3.8.38 (leva 5)
* fix(sse): keep streaming for forceStream providers when client requests JSON (#5021)
Integrated into release/v3.8.38 (leva 5)
* fix(sse): guard non-JSON SSE lines and duplicate [DONE] (#4937)
Integrated into release/v3.8.38 (leva 5)
* feat(blackbox): refresh provider model catalog (#4935)
Integrated into release/v3.8.38 (leva 5)
* fix(sse): dedupe case-variant Anthropic version/beta headers (#4846)
Integrated into release/v3.8.38 (leva 5)
* feat(sse): Kiro inline <thinking> stream splitter (#4911)
Integrated into release/v3.8.38 (leva 5)
* feat(cursor): parse Composer DeepSeek-style inline tool calls (#4912)
Integrated into release/v3.8.38 (leva 5)
* feat(proxy): auth-less host:port batch import (#4938)
Integrated into release/v3.8.38 (leva 5)
* fix(oauth): support Kiro IDC (organization) token import (#4944)
Integrated into release/v3.8.38 (leva 5)
* fix(translator): preserve cache_control for DashScope OpenAI-compat providers (port from 9router#2069) (#5013)
Integrated into release/v3.8.38 (leva 5)
* fix(tts): resolve Gemini TTS models from catalog (#4934)
Integrated into release/v3.8.38 (leva 5)
* fix(sse): don't cool down the connection on a self-inflicted upstream timeout (504) (#5064)
Integrated into release/v3.8.38 (leva 5)
* fix(sse): robust Anthropic /v1/messages streaming — real ping keepalive + client-disconnect guard (#5063)
Integrated into release/v3.8.38 (leva 5)
* feat(video): add Alibaba DashScope (wan2.7-t2v) provider (#5051)
Integrated into release/v3.8.38 (leva 5)
* fix: preserve model hidden flags (isHidden) across model sync (#5086)
Integrated into release/v3.8.38 (leva 5)
* fix(models): derive model discovery config from registry modelsUrl (#5087)
Integrated into release/v3.8.38 (leva 5)
* fix(compression): replace fileURLToPath(import.meta.url) with runtime anchors for standalone bundle (#5089)
Integrated into release/v3.8.38 (leva 5)
* feat(cc): add summarized thinking display toggle (#5055)
Integrated into release/v3.8.38 (leva 5)
* Harden selected API error responses (#5032)
Integrated into release/v3.8.38 (leva 5)
* chore(quality): rebaseline file-size for leva 5 PR batch drift
6 frozen files grew from merged leva-5 PRs (cursor #4912, kiro #4911,
videoGeneration #5051, default #4727, base #4846, chat #5064); all covered
by per-PR tests. See _rebaseline_2026_06_26_leva5 in the baseline.
* feat(compression): compression playground (Play + Compare tabs) in the studio (#5080)
Integrated into release/v3.8.38
* fix(combo): fail over on empty-content 502 instead of exhausting the provider (#5085) (#5104)
* fix(dashboard): surface detailed credential-validation error in add-connection modal (#5088) (#5106)
* feat(providers): allow local/private provider URLs by default with scoped metadata-safe guard (#5066) (#5107)
* fix(diagnostics): treat non-streaming Claude messages shape as valid output (#5108) (#5116)
* fix(db): translate pt-BR SQLite driver-fallback log lines to English (#5103) (#5115)
* fix(sse): repair release base-reds — malformed-response false positives + header casing + stale tests (#5117)
Repairs the release/v3.8.38 base-reds; unblocks #5078.
* chore(quality): rebaseline file-size for responseSanitizer (#5117) + AddApiKeyModal drift
* fix(translator): forward image tool_result blocks as image_url (#5100)
Base-reds fixed (#5117); image tool_result→image_url. Integrated into release/v3.8.38.
* fix(responses): default text.format for openai-compatible responses providers (#5101)
Base-reds fixed (#5117); default text.format + file-size rebaseline. Integrated into release/v3.8.38.
* feat(dashboard): expose Fusion judgeModel + fusionTuning in the combo editor (#5074)
Base-reds fixed (#5117); Fusion editor + file-size rebaseline. Integrated into release/v3.8.38.
* feat(quota): add opt-in Codex/Claude auto-ping keepalive (#5102)
Base-reds fixed (#5117); auto-ping keepalive + file-size rebaseline. Integrated into release/v3.8.38.
* test(release): relocate 2 orphan test files into the collected flat tests/unit dir (#5120)
Unblocks Lint (test-discovery) on #5078. Integrated into release/v3.8.38.
* fix(translator): preserve reasoning-replay reasoning_content + repair 3 release-green test reds (#5122)
Repairs 3 release-green test reds + test-masking; unblocks #5078.
* test(golden): redact live Node version from provider translate-path snapshot (#5125)
Final golden unblock for #5078.
* test(golden): redact OmniRoute app version from translate-path snapshot (#5126)
Coverage shard golden unblock for #5078.
* Ignore disconnect races during in-band stream error handling (#5007)
Integrated into release/v3.8.38
* Track final connection IDs in failover logs (#5016)
Integrated into release/v3.8.38
* fix(sse): convert Gemini body to OpenAI format in antigravity MITM handler (#4845)
Integrated into release/v3.8.38 (rebased on tip, CHANGELOG re-injected)
* feat(providers): add ZenMux Free session-cookie provider (#5105)
Integrated into release/v3.8.38 (rebased on tip, CHANGELOG re-injected)
* feat(dashboard): click-to-edit model alias in provider page (#5119)
Integrated into release/v3.8.38 (rebased on tip, i18n scope verified, CHANGELOG re-injected)
* feat(mcp): web-session robustness — cookie dedup (PR6) + browser-pool observability (PR7) (#3368) (#5121)
Integrated into release/v3.8.38 (rebased on tip; cookie-dedup branch extracted to findExistingCookieConnection helper → complexity-neutral; CHANGELOG added)
* fix(usage): dedupe request-usage logging and debounce stats (#4940)
Integrated into release/v3.8.38 (rebased on tip; DB-handle hang was stale-base artifact — resetDbInstance already closes the handle, test green 5/5; file-size drift consolidated at release; CHANGELOG re-injected)
* fix(dashboard): key model visibility toggle on canonical providerId (#5091)
Integrated into release/v3.8.38 (retargeted main→release; .tsx visibility-key test green 2/2)
* chore(deps): bump actions/cache from 5.0.5 to 6.0.0 (#5112)
Integrated into release/v3.8.38 (retargeted main→release; workflow-only actions/cache bump — unit failures were stale main base-reds)
* fix(streaming): harden long OpenAI-compatible SSE streams (#5124)
Integrated into release/v3.8.38 (rebased on tip; streamHandler conflict with #5007 disconnect-guard resolved — both coexist, stream-handler 22/22 green)
* feat: Add Grok Build (xAI) provider with OAuth import-token flow (#5020)
Integrated into release/v3.8.38 (rebased on tip; Hard Rule #11 fix — Grok public client_id now via resolvePublicCred(grok_id), 3 literals removed; grok-oauth 7/7 + check:public-creds green)
* feat(providers): add Factory (factory.ai) as a subscription gateway provider (#5065)
Integrated into release/v3.8.38 (rebased on tip; added factory registry test for PR Test Policy + fixed check:env-doc-sync phantom FACTORY_API_KEY; factory loads in PROVIDERS, no Zod issue — that flag was a false positive)
* chore(test): reconcile golden snapshot + apikey count for new providers
#5020 (grok-cli), #5065 (factory), #5105 (zenmux-free) added providers but did
not regenerate tests/snapshots/provider/translate-path.json (now +3 entries) nor
bump the APIKEY_PROVIDERS count (159->160 for the factory gateway). Test-only
reconciliation; no production change.
* fix(resilience): harden quota and model lockout edge cases (#5093)
Integrated into release/v3.8.38 (rebased on tip). TRUST-BUT-VERIFY: dropped the PR's 0dd7df641 'fix unit gates' commit which reverted #5122 reasoning-replay (preserveReasoningContent) + re-introduced #4849 O(n^2) growth, and restored 5 tests it had realigned. Kept only the 3 declared resilience fixes (quota cutoff guard, gemini MIME, model-lockout maxCooldownMs); 23/23 green.
* Hydrate quota cache and scope auto combo candidates (#5015)
Integrated into release/v3.8.38 (rebased on tip). Kept core quota-cache hydration + auto-combo candidate scoping + combos UI; dropped out-of-scope toolCloaking refactor (conflicted with #4813 stripEnumDescriptions — took tip) and the unrelated sse-auth test split. Added quota-cache-hydrate-5015 regression test (Rule #18); combo-account-allowlist 8/8 + hydration 2/2 green.
* chore(quality): reconcile complexity + file-size baselines for v3.8.38 owner-PR batch
complexity 1972->1978 (+6) and file-size providers.ts 1093->1107 / usageHistory.ts
934->983 — drift from the /review-prs merge batch (#4845/#5105/#5020/#4940/#5093/
#5015 + #5121 cookie-dedup helper extraction). check:complexity/check:file-size do
not run on the PR->release fast-path, so the branch accrued unmeasured; all legit
feature/fix growth, not regression. See per-key justifications in each baseline.
* fix(security): exact-host Anthropic baseUrl check (CodeQL js/incomplete-url-substring-sanitization #674) (#5130)
The anthropic-compatible Bearer-fallback gate decided whether a configured baseUrl
targeted the official api.anthropic.com host via a substring `.includes("api.anthropic.com")`.
A look-alike upstream such as `https://api.anthropic.com.evil.test` or
`https://evil.test/?x=api.anthropic.com` matched the substring and was wrongly treated as
official, suppressing the Bearer fallback meant for third-party gateways
(CodeQL #674, js/incomplete-url-substring-sanitization, high).
Replace the substring test with an exported `isOfficialAnthropicBaseUrl()` helper that
parses the URL and compares the hostname for exact equality. Empty baseUrl stays official;
scheme-less hosts are parsed with an assumed https://; an unparseable baseUrl falls back to
third-party (Bearer emitted) as the safer default. Behavior for legitimate official/third-party
baseUrls is unchanged.
Adds tests/unit/anthropic-official-baseurl-host.test.ts covering official, look-alike,
scheme-less, and unparseable inputs plus a static guard that the substring pattern is gone.
* fix(proxy): repair one-click Deno & Cloudflare relay deployments (#5128) (#5132)
* fix(services): embed WS proxy honours LIVE_WS_HOST; reject empty messages early (#5110) (#5133)
* fix(api): resolve /v1/models/{id} case-insensitively (#5082) (#5135)
* fix(providers): add MiniMax M3 & Nemotron 3 Ultra to Cline catalog (#3321) (#5136)
* fix(proxy): make SOCKS5 handshake timeout tunable via SOCKS_HANDSHAKE_TIMEOUT_MS (#5109) (#5137)
* feat(sidebar): add support for colored menu icons (#3812)
Integrated into release/v3.8.38 (recreated on tip — fork had unrelated history; added getSidebarIconAccent regression test, Rule #18). Clean 2-file UI feature.
* fix(providers): complete grok-cli OAuth wiring + zenmux-free web-session metadata
Base-red repair for #5020 (grok-cli) and #5105 (zenmux-free), surfaced by the
full CI on the release PR (#5078) — the PR->release fast-path does not run the
oauth-providers-config / web-session-credentials / provider-consistency gates.
- grok-cli: register in OAUTH_PROVIDERS (providers.ts canonical list, fixes
check:provider-consistency), add OAUTH_PROVIDER_IDS.GROK_CLI + GROK_CLI_CONFIG
in oauth constants (provider config now sourced there, not a local literal),
align oauth-providers-config.test.ts (EXPECTED_PROVIDER_KEYS + config map).
- zenmux-free: declare its web-session credential requirement (full Cookie header)
in WEB_SESSION_CREDENTIAL_REQUIREMENTS.
Local: oauth-providers-config 27/27, web-session-credentials 4/4, grok-cli-oauth
7/7, check:provider-consistency OK, +115 OAUTH_PROVIDERS tests green.
* Fix resilience settings page response mapping (#5139)
Integrated into release/v3.8.38. Thanks @rdself for the fix and the regression test.
* fix(kiro): retire claude-sonnet-4.5 from catalog + pin 400 model-unavailable test (#5140)
Extracted the real change from #5140 (the bot PR regenerated the entire
freeModelCatalog.data.ts + touched package-lock.json; only the targeted
edits are kept here):
- remove claude-sonnet-4.5 from the Kiro registry entry
- remove the matching kiro free-model catalog row
- pin Kiro's verbatim 400 "Invalid model..." to isModelUnavailableError
Closes #4484
* fix(sidebar): drop orphan `settings` accent color (typecheck:core red) (#5142)
SIDEBAR_ICON_ACCENTS is typed Partial<Record<HideableSidebarItemId, string>>,
but `settings` is not a hideable item id (only `settings-general`,
`settings-appearance`, … and `context-settings` exist; there is no item with
`id: "settings"`), so the accent was unreachable. It broke `typecheck:core`
on the release tip ("'settings' does not exist in type …", introduced by
#3812 colored menu icons). Removing the orphan key restores a clean
typecheck:core (rc=0).
* feat: salvage batch 2 — diagnostics null-guard (#5096) + observed quota reset windows (#5025) (#5141)
* fix(diagnostics): null-guard content blocks in detectMalformedNonStream
A null (or non-object) entry in a Claude-native `content` array made the
non-stream classifier throw `TypeError: Cannot read properties of null
(reading 'type')`, crashing the malformed-response detection path. Guard
before type-asserting each block: a null/non-object block is simply skipped.
Two regression tests added (null block among valid blocks → null; only-null
blocks → empty_choices).
Salvaged from closed PR #5096 (base-stale; only the defensive guard — the
Claude-shape recognition it also carried already landed via #5108).
Co-authored-by: herjarsa <herjarsa@users.noreply.github.com>
* feat(quota): persist observed provider quota reset windows
Adds `provider_quota_reset_events` (migration 108) + `db/quotaResetEvents.ts`
to record real upstream weekly-quota window transitions whenever a quota
refresh shows the reset rolling to a new cycle (different day, later resetAt).
`apiKeyUsageLimits` now prefers the observed window start over the inferred
`resetAt − 7d`, falling back to snapshot inference when no event is recorded
yet. `quotaCache.setQuotaCache` records the transition opportunistically.
`recordProviderQuotaResetEventIfChanged` only fires for the primary weekly
window (not daily/sonnet), is idempotent (INSERT OR IGNORE on the unique
window key), and no-ops when the reset didn't actually roll. 4 unit tests
(tests/unit/lib/quota-reset-events.test.ts).
Salvaged from closed PR #5025 (which bundled this with two unrelated
features + a colliding migration 104). Renumbered to 108; module re-exported
from localDb (Rule #2).
Co-authored-by: Witroch4 <175152067+Witroch4@users.noreply.github.com>
---------
Co-authored-by: herjarsa <herjarsa@users.noreply.github.com>
Co-authored-by: Witroch4 <175152067+Witroch4@users.noreply.github.com>
* docs(i18n): sync 3.8.38 CHANGELOG section to 41 mirrors (unblock docs-accuracy) (#5144)
The root CHANGELOG [3.8.38] section grew with this cycle's merged PRs, but the
docs/i18n/<lang>/CHANGELOG.md mirrors were not re-synced — drifting >25% in body
size and failing check:docs-sync (the "Docs accuracy" fast-gate step) for every
open PR against the release.
Ran scripts/release/sync-changelog-i18n.mjs 3.8.38 3.8.37 to copy the root
[3.8.38] section into all 41 mirrors. check:docs-all now passes (exit 0).
Sections are copied verbatim; the per-language translation pass runs at release
time via i18n:run — this only restores the size-sync the gate enforces.
* feat(compression): pure per-step fidelity checker (4 invariants, fail-open)
* feat(compression): fidelityGate config + rejected breakdown fields
* feat(compression): wire per-step fidelity gate into stacked pipeline (opt-in)
* feat(compression): preview route accepts fidelityGate flag (playground)
* feat(compression): playground fidelity-gate toggle + lane rejection display
* docs(compression): note fidelityGate advanced thresholds are intentionally API-omitted
* refactor(compression): extract fidelity-gate step helpers to shrink strategySelector (file-size gate)
bodyToText and gateAdvance moved to fidelityGateStep.ts; StackAccumulator exported.
strategySelector: 889->854 (-35). Residual +6 vs pre-Milestone-B frozen 848 is the
irreducible StackOptions.fidelityGate field + two stacked-loop dispatch reads + import.
Baseline updated to 854 with justification. No cycle introduced (import type only).
940 compression tests pass; typecheck clean.
* test(usage): wire usageHistoryDedup under unit runner brace-list (#5145)
Integrated into release/v3.8.38.
* feat: salvage batch from closed stale PRs (#5038, #5057, #5076) (#5138)
Integrated into release/v3.8.38.
* test(combo): deterministic routing-decision matrix for all 17 strategies (#5146)
Integrated into release/v3.8.38.
* feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass + playground toggle) (#5143)
Integrated into release/v3.8.38.
* chore(quality): rebaseline file-size for sidebarVisibility.ts + chat.ts drift (#5147)
Mid-cycle drift on release/v3.8.38 from already-merged PRs that the fast-path
(PR->release skips check:file-size) let accumulate without a bump:
- src/shared/constants/sidebarVisibility.ts 1100->1198 (#3812 colored menu
icons, per-item accent map; #5142 dropped one orphan, net still above frozen)
- src/sse/handlers/chat.ts 1560->1575 (#5064 self-inflicted-timeout cooldown
skip + #5124 long OpenAI-compatible SSE hardening + #5110 embed-WS
LIVE_WS_HOST honour / early empty-message reject)
Each covered by its own PR tests; structural shrink of chat.ts tracked in #3501.
Unblocks the Fast Quality Gates for PRs targeting release/v3.8.38.
* chore(release): finalize v3.8.38 CHANGELOG + cycle reconciliation
- Reconcile [3.8.38]: +18 bullets (compression fidelity-gate/fuzzy-dedup #5143,
quota keepalive #5102, web-session robustness #5121, MiniMax/Nemotron #5136,
model-visibility #5091, failover logs #5016, disconnect races #5007, sidebar
orphan #5142, SRE playbooks salvage #5138, new Security #5130 + Maintenance roll-up)
- Credit salvaged-PR authors (@JxnLexn / @KooshaPari / @herjarsa / @Witroch4)
- Remove phantom bullet for CLOSED-not-merged #5092 (setup aggregator never landed)
- Fix isHidden bullet PR citation #4389 -> #5086 (@herjarsa)
- Back-fill forgotten v3.8.36 bullet: #5026 crypto.randomUUID ID-gen (@hamsa0x7)
- Sync 41 i18n CHANGELOG mirrors; README What's New -> v3.8.38
- Rebaseline cycle drift: eslint 3987->4002, cognitive 833->841, dead-exports
345->346, cyclomatic 1978->1980 (file-size handled by #5147)
* fix(i18n): add missing English UI labels (#5153)
Integrated into release/v3.8.38
* Preserve non-stream reasoning fields for compatible clients (#5155)
Integrated into release/v3.8.38
* feat(compression): ionizer engine — lossy JSON-array sampling reversible via CCR (#5148)
Integrated into release/v3.8.38
* test(combo): gated live smoke for combo strategies (in-process + VPS HTTP) (#5151)
Integrated into release/v3.8.38
* test: refresh release expectations to match current code (#5150)
Integrated into release/v3.8.38 (test-only base-red alignment extracted from #5150)
---------
Co-authored-by: Éder Costa <eder.almeida.costa@gmail.com>
Co-authored-by: José Victor Ferreira <root@josevictor.me>
Co-authored-by: Hernan Javier Ardila Sanchez <hjasgr@gmail.com>
Co-authored-by: fulorgnas <46461624+fulorgnas@users.noreply.github.com>
Co-authored-by: Randi <55005611+rdself@users.noreply.github.com>
Co-authored-by: Jan Leon <Jan.gaschler@gmail.com>
Co-authored-by: R. Beltran <rbeltran8000@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: KooshaPari <42529354+KooshaPari@users.noreply.github.com>
Co-authored-by: Ramel Tecnologia - Rafa Martins <146174365+rafacpti23@users.noreply.github.com>
Co-authored-by: herjarsa <herjarsa@users.noreply.github.com>
Co-authored-by: Witroch4 <175152067+Witroch4@users.noreply.github.com>
854 lines
32 KiB
TypeScript
854 lines
32 KiB
TypeScript
import type {
|
|
CompressionConfig,
|
|
CompressionMode,
|
|
CompressionPipelineStep,
|
|
CompressionResult,
|
|
CompressionStats,
|
|
} from "./types.ts";
|
|
import { type FidelityGateConfig } from "./fidelityGate.ts";
|
|
import { gateAdvance } from "./fidelityGateStep.ts";
|
|
import type { CompressionEngineApplyOptions } from "./engines/types.ts";
|
|
import { applyLiteCompression } from "./lite.ts";
|
|
import { cavemanCompress } from "./caveman.ts";
|
|
import { compressAggressive } from "./aggressive.ts";
|
|
import { ultraCompress, ultraCompressHeuristic } from "./ultra.ts";
|
|
import { createCompressionStats } from "./stats.ts";
|
|
import { registerBuiltinCompressionEngines } from "./engines/index.ts";
|
|
import { getCompressionEngine, getEngineEntry } from "./engines/registry.ts";
|
|
import { applyRtkCompression } from "./engines/rtk/index.ts";
|
|
import { adaptBodyForCompression } from "./bodyAdapter.ts";
|
|
import {
|
|
detectCachingContext,
|
|
getCacheAwareStrategy,
|
|
type CachingDetectionContext,
|
|
} from "./cachingAware.ts";
|
|
import { resolveCompressionPlan } from "./resolveCompressionPlan.ts";
|
|
import { deriveDefaultPlan, type DerivedPlan } from "./deriveDefaultPlan.ts";
|
|
import {
|
|
withSource,
|
|
planFromHeader,
|
|
formatCompressionMeta,
|
|
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 };
|
|
|
|
/** Named-combo map: combo id → its stacked pipeline (operator-defined profiles). */
|
|
type NamedCombos = Record<string, CompressionPipelineStep[]>;
|
|
|
|
export function checkComboOverride(
|
|
config: CompressionConfig,
|
|
comboId: string | null
|
|
): CompressionMode | null {
|
|
if (!comboId || !config.comboOverrides) return null;
|
|
return config.comboOverrides[comboId] ?? null;
|
|
}
|
|
|
|
export function shouldAutoTrigger(config: CompressionConfig, estimatedTokens: number): boolean {
|
|
return config.autoTriggerTokens > 0 && estimatedTokens >= config.autoTriggerTokens;
|
|
}
|
|
|
|
/**
|
|
* Resolves the effective compression plan (mode + derived stacked pipeline) WITHOUT
|
|
* the caching-aware mode adjustment (that is layered on by {@link selectCompressionPlan}).
|
|
*
|
|
* Precedence — preserved from the historical {@link getEffectiveMode} ordering:
|
|
* 1. master off → off
|
|
* 2. routing-combo override (comboId)→ that mode (resolver honors it via ctx.comboId)
|
|
* 3. active named profile (Phase 2) → that combo's stacked pipeline (manual operator choice)
|
|
* 4. auto-trigger (large prompt) → autoTriggerMode, BEFORE the plain derived default
|
|
* 5. derived default → resolveCompressionPlan (engines map → mode/pipeline)
|
|
*
|
|
* Step 3 is an EXPLICIT operator selection (`config.activeComboId` resolved against the
|
|
* `combos` map): it beats auto-trigger (a manual choice outranks automatic escalation) but
|
|
* stays below a routing-combo override (route-scoped is more specific). Step 4 mirrors the
|
|
* historical behaviour: auto-trigger precedes the plain derived default but never a routing
|
|
* override.
|
|
*
|
|
* `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,
|
|
estimatedTokens: number,
|
|
combos: NamedCombos = {},
|
|
header: string | null = null
|
|
): DerivedPlan {
|
|
if (!config.enabled) return withSource({ mode: "off", stackedPipeline: [] }, "off");
|
|
|
|
// Phase 3: an explicit, recognized header wins over every operator layer (Decision B).
|
|
// The master switch above is the hard kill: a header cannot turn compression on.
|
|
if (header) {
|
|
const fromHeader = planFromHeader(config, header, combos);
|
|
if (fromHeader) return fromHeader; // already tagged "request-header"
|
|
}
|
|
|
|
const comboMode = checkComboOverride(config, comboId);
|
|
if (comboMode) {
|
|
// A routing-combo "stacked" override still wants the configured stacked pipeline,
|
|
// so route it through the resolver (which reads config.stackedPipeline for stacked).
|
|
return withSource(resolveCompressionPlan(config, { comboId, combos }), "routing-override");
|
|
}
|
|
|
|
// Active profile: an EXPLICIT operator choice. Resolves regardless of enginesExplicit and
|
|
// above auto-trigger (manual choice beats automatic escalation), but below a routing-combo
|
|
// override (route-scoped is more specific).
|
|
if (config.activeComboId && combos[config.activeComboId]) {
|
|
return withSource(
|
|
{ mode: "stacked", stackedPipeline: combos[config.activeComboId] },
|
|
"active-profile"
|
|
);
|
|
}
|
|
|
|
if (!adaptiveEnabled(config) && shouldAutoTrigger(config, estimatedTokens)) {
|
|
const mode = config.autoTriggerMode ?? "lite";
|
|
return withSource(
|
|
mode === "stacked"
|
|
? { mode, stackedPipeline: config.stackedPipeline ?? [] }
|
|
: { mode, stackedPipeline: [] },
|
|
"auto-trigger"
|
|
);
|
|
}
|
|
|
|
const plan = deriveDefaultPlanFromConfig(config, comboId, combos);
|
|
return withSource(plan, plan.mode === "off" ? "off" : "default");
|
|
}
|
|
|
|
/**
|
|
* True when the EXPLICITLY-configured engines map (panel-saved) derives a multi-engine
|
|
* stacked pipeline. chatCore uses this to know the panel's derived pipeline is authoritative
|
|
* and the legacy default-combo fallback must NOT override it. Returns false for legacy
|
|
* (non-explicit) installs so their historical default-combo path is preserved untouched.
|
|
*/
|
|
export function enginesMapDerivesStackedPipeline(config: CompressionConfig): boolean {
|
|
if (!config.enginesExplicit) return false;
|
|
const plan = deriveDefaultPlan(config.engines ?? {}, config.enabled !== false);
|
|
return plan.mode === "stacked" && plan.stackedPipeline.length > 0;
|
|
}
|
|
|
|
/**
|
|
* True when the config has an active named-combo selection that exists in the supplied combos
|
|
* map. chatCore uses this to keep the legacy default-combo fallback from shadowing the
|
|
* operator's active profile.
|
|
*/
|
|
export function activeComboResolves(config: CompressionConfig, combos: NamedCombos = {}): boolean {
|
|
return Boolean(config.activeComboId && combos[config.activeComboId]);
|
|
}
|
|
|
|
export function getEffectiveMode(
|
|
config: CompressionConfig,
|
|
comboId: string | null,
|
|
estimatedTokens: number,
|
|
combos: NamedCombos = {},
|
|
header: string | null = null
|
|
): CompressionMode {
|
|
return resolveBasePlan(config, comboId, estimatedTokens, combos, header).mode as CompressionMode;
|
|
}
|
|
|
|
/**
|
|
* Like {@link selectCompressionStrategy} but returns the full derived plan
|
|
* (effective `mode` + `stackedPipeline`). When the resolver derives a `stacked`
|
|
* plan from the per-engine toggle map, the pipeline is exposed here so the caller
|
|
* can feed it to {@link applyCompressionAsync} (which reads config.stackedPipeline).
|
|
* 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,
|
|
estimatedTokens: number,
|
|
body?: Record<string, unknown>,
|
|
context?: CachingDetectionContext,
|
|
combos: NamedCombos = {},
|
|
header: string | null = null,
|
|
adaptiveOptions?: AdaptiveSelectOptions
|
|
): DerivedPlan {
|
|
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) {
|
|
const ctx = detectCachingContext(body, context);
|
|
const cacheAware = getCacheAwareStrategy(plan.mode as CompressionMode, ctx);
|
|
return { ...plan, mode: cacheAware.strategy as CompressionMode }; // ...plan preserves source
|
|
}
|
|
|
|
return plan;
|
|
}
|
|
|
|
export function selectCompressionStrategy(
|
|
config: CompressionConfig,
|
|
comboId: string | null,
|
|
estimatedTokens: number,
|
|
body?: Record<string, unknown>,
|
|
context?: CachingDetectionContext,
|
|
combos: NamedCombos = {},
|
|
header: string | null = null
|
|
): CompressionMode {
|
|
return selectCompressionPlan(config, comboId, estimatedTokens, body, context, combos, header)
|
|
.mode as CompressionMode;
|
|
}
|
|
|
|
/**
|
|
* #3890: honor the cache-aware `skipSystemPrompt` decision that `getCacheAwareStrategy`
|
|
* already computes but that `selectCompressionStrategy` (which can only return a mode
|
|
* string) previously discarded. In a caching context the system prompt is part of the
|
|
* cacheable prefix, so compressing it breaks the upstream prompt cache. This forces
|
|
* `preserveSystemPrompt` on for caching requests even when the operator turned it off,
|
|
* and leaves non-caching requests untouched.
|
|
*/
|
|
export function resolveCacheAwareConfig(
|
|
config: CompressionConfig,
|
|
body?: Record<string, unknown>,
|
|
context?: CachingDetectionContext
|
|
): CompressionConfig {
|
|
if (!body) return config;
|
|
const ctx = detectCachingContext(body, context);
|
|
// Only `skipSystemPrompt` is consumed here, and it depends solely on `ctx.isCachingProvider`
|
|
// (NOT on the strategy arg — see getCacheAwareStrategy), so the stored `defaultMode` is a safe
|
|
// input even though it may be "off" for a panel-configured install. If getCacheAwareStrategy is
|
|
// ever extended to key `skipSystemPrompt` on the mode, pass the resolved effective mode instead.
|
|
const cacheAware = getCacheAwareStrategy(config.defaultMode, ctx);
|
|
if (cacheAware.skipSystemPrompt && config.preserveSystemPrompt === false) {
|
|
return { ...config, preserveSystemPrompt: true };
|
|
}
|
|
return config;
|
|
}
|
|
|
|
export function applyCompression(
|
|
body: Record<string, unknown>,
|
|
mode: CompressionMode,
|
|
options?: {
|
|
model?: string;
|
|
supportsVision?: boolean | null;
|
|
config?: CompressionConfig;
|
|
principalId?: string;
|
|
/**
|
|
* Opt into the TV1 stacked bail-out (skip-on-throw + min-gain). Default off keeps the
|
|
* legacy behavior. The combo proactive-fallback path enables it so a throwing engine is
|
|
* skipped instead of silently dropping the target. Flows through to applyStackedCompression.
|
|
*/
|
|
bailout?: BailoutConfig;
|
|
}
|
|
): CompressionResult {
|
|
if (mode === "off") {
|
|
return { body, compressed: false, stats: null };
|
|
}
|
|
if (mode === "rtk") {
|
|
return applyRtkCompression(body, {
|
|
// Selecting the "rtk" mode IS the enable signal — run it even if the per-engine
|
|
// rtkConfig.enabled flag is off (that flag gates stacked steps). (B-MODE-ENGINE-DECOUPLE)
|
|
config: { ...(options?.config?.rtkConfig ?? {}), enabled: true },
|
|
});
|
|
}
|
|
const adapter = adaptBodyForCompression(body);
|
|
const compressionBody = adapter.body;
|
|
if (mode === "lite") {
|
|
const result = applyLiteCompression(compressionBody, {
|
|
...options,
|
|
preserveSystemPrompt: options?.config?.preserveSystemPrompt !== false,
|
|
});
|
|
return adapter.adapted ? { ...result, body: adapter.restore(result.body) } : result;
|
|
}
|
|
if (mode === "stacked") {
|
|
const result = applyStackedCompression(
|
|
compressionBody,
|
|
options?.config?.stackedPipeline,
|
|
options
|
|
);
|
|
return adapter.adapted ? { ...result, body: adapter.restore(result.body) } : result;
|
|
}
|
|
if (mode === "standard") {
|
|
const cavemanConfig = {
|
|
...(options?.config?.cavemanConfig ?? {}),
|
|
...(options?.config?.languageConfig?.enabled
|
|
? {
|
|
language: options.config.languageConfig.defaultLanguage,
|
|
autoDetectLanguage: options.config.languageConfig.autoDetect,
|
|
enabledLanguagePacks: options.config.languageConfig.enabledPacks,
|
|
}
|
|
: {}),
|
|
...(options?.config?.preserveSystemPrompt !== false
|
|
? {
|
|
compressRoles: (options?.config?.cavemanConfig?.compressRoles ?? ["user"]).filter(
|
|
(role) => role !== "system"
|
|
),
|
|
}
|
|
: {}),
|
|
// Selecting the "standard" mode runs caveman regardless of the per-engine
|
|
// cavemanConfig.enabled flag (that flag gates stacked steps). (B-MODE-ENGINE-DECOUPLE)
|
|
enabled: true,
|
|
};
|
|
const result = cavemanCompress(
|
|
compressionBody as Parameters<typeof cavemanCompress>[0],
|
|
cavemanConfig
|
|
);
|
|
return adapter.adapted ? { ...result, body: adapter.restore(result.body) } : result;
|
|
}
|
|
if (mode === "aggressive") {
|
|
const messages = (compressionBody.messages ?? []) as Array<{
|
|
role: string;
|
|
content?: string | Array<{ type: string; text?: string }>;
|
|
[key: string]: unknown;
|
|
}>;
|
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
return { body, compressed: false, stats: null };
|
|
}
|
|
const aggressiveConfig = {
|
|
...(options?.config?.aggressive ?? {}),
|
|
preserveSystemPrompt: options?.config?.preserveSystemPrompt !== false,
|
|
};
|
|
const result = compressAggressive(messages, aggressiveConfig);
|
|
const compressedBody = { ...compressionBody, messages: result.messages };
|
|
return {
|
|
body: adapter.restore(compressedBody),
|
|
compressed: result.stats.savingsPercent > 0,
|
|
stats: createCompressionStats(
|
|
compressionBody,
|
|
compressedBody,
|
|
mode,
|
|
["aggressive"],
|
|
result.stats.rulesApplied,
|
|
result.stats.durationMs
|
|
),
|
|
};
|
|
}
|
|
if (mode === "ultra") {
|
|
const messages = (compressionBody.messages ?? []) as Array<{
|
|
role: string;
|
|
content?: string | unknown[];
|
|
[key: string]: unknown;
|
|
}>;
|
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
return { body, compressed: false, stats: null };
|
|
}
|
|
const ultraConfig = {
|
|
...(options?.config?.ultra ?? {}),
|
|
preserveSystemPrompt: options?.config?.preserveSystemPrompt !== false,
|
|
};
|
|
const result = ultraCompressHeuristic(messages, ultraConfig);
|
|
const compressedBody = { ...compressionBody, messages: result.messages };
|
|
return {
|
|
body: adapter.restore(compressedBody),
|
|
compressed: result.stats.savingsPercent > 0,
|
|
stats: {
|
|
...createCompressionStats(
|
|
compressionBody,
|
|
compressedBody,
|
|
mode,
|
|
["ultra"],
|
|
result.stats.rulesApplied,
|
|
result.stats.durationMs
|
|
),
|
|
ultraTier: result.stats.ultraTier,
|
|
},
|
|
};
|
|
}
|
|
return { body, compressed: false, stats: null };
|
|
}
|
|
|
|
/**
|
|
* Async entry point mirroring {@link applyCompression}. Only the stacked mode
|
|
* can host async engines, so it routes through {@link applyStackedCompressionAsync};
|
|
* every other mode delegates to the synchronous path unchanged. Call sites that
|
|
* already run in an async context (e.g. chatCore) await this so a future
|
|
* worker-thread engine can await without changing the surrounding code.
|
|
*/
|
|
export async function applyCompressionAsync(
|
|
body: Record<string, unknown>,
|
|
mode: CompressionMode,
|
|
options?: {
|
|
model?: string;
|
|
supportsVision?: boolean | null;
|
|
config?: CompressionConfig;
|
|
principalId?: string;
|
|
onEngineStep?: (step: StackedCompressionStep) => void;
|
|
}
|
|
): Promise<CompressionResult> {
|
|
if (mode === "stacked") {
|
|
const adapter = adaptBodyForCompression(body);
|
|
const result = await applyStackedCompressionAsync(
|
|
adapter.body,
|
|
options?.config?.stackedPipeline,
|
|
options
|
|
);
|
|
return adapter.adapted ? { ...result, body: adapter.restore(result.body) } : result;
|
|
}
|
|
// Ultra's optional SLM (model) tier is async — route it here when a model is configured.
|
|
if (mode === "ultra") {
|
|
return applyUltraAsync(body, options);
|
|
}
|
|
return applyCompression(body, mode, options);
|
|
}
|
|
|
|
/**
|
|
* Ultra mode with the optional local SLM (model) tier.
|
|
*
|
|
* When `config.ultra.modelPath` is set, the prose is routed through the llmlingua engine
|
|
* (the real local-model compressor). The llmlingua backend fail-opens when the model is
|
|
* absent (e.g. the ONNX model is not provisioned), so this degrades gracefully:
|
|
* - model present and it compresses → return the SLM result (tagged "ultra-slm");
|
|
* - model absent / no gain / failure → fall back to `aggressive` when
|
|
* `slmFallbackToAggressive` is set, otherwise the heuristic ultra (`pruneByScore`).
|
|
*
|
|
* Without `modelPath` the behavior is byte-identical to the synchronous heuristic ultra.
|
|
*/
|
|
async function applyUltraAsync(
|
|
body: Record<string, unknown>,
|
|
options?: {
|
|
model?: string;
|
|
supportsVision?: boolean | null;
|
|
config?: CompressionConfig;
|
|
principalId?: string;
|
|
onEngineStep?: (step: StackedCompressionStep) => void;
|
|
}
|
|
): Promise<CompressionResult> {
|
|
const ultraConfig = options?.config?.ultra;
|
|
const modelPath = typeof ultraConfig?.modelPath === "string" ? ultraConfig.modelPath.trim() : "";
|
|
|
|
// No explicit modelPath → run the two-tier ultra resolver (heuristic, or SLM when
|
|
// config.ultraEngine === "slm" and the worker backend is available). This is the
|
|
// Phase-4 (B) path; it fail-opens to the heuristic and records the resolved tier.
|
|
if (!modelPath) {
|
|
const adapter = adaptBodyForCompression(body);
|
|
const messages = (adapter.body.messages ?? []) as Array<{
|
|
role: string;
|
|
content?: string | unknown[];
|
|
[key: string]: unknown;
|
|
}>;
|
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
return { body, compressed: false, stats: null };
|
|
}
|
|
const ultraConfig = {
|
|
...(options?.config?.ultra ?? {}),
|
|
preserveSystemPrompt: options?.config?.preserveSystemPrompt !== false,
|
|
ultraEngine: options?.config?.ultraEngine,
|
|
};
|
|
const result = await ultraCompress(messages, ultraConfig);
|
|
const compressedBody = { ...adapter.body, messages: result.messages };
|
|
return {
|
|
body: adapter.restore(compressedBody),
|
|
compressed: result.stats.savingsPercent > 0,
|
|
stats: {
|
|
...createCompressionStats(
|
|
adapter.body,
|
|
compressedBody,
|
|
"ultra",
|
|
result.stats.techniquesUsed,
|
|
result.stats.rulesApplied,
|
|
result.stats.durationMs
|
|
),
|
|
ultraTier: result.stats.ultraTier,
|
|
},
|
|
};
|
|
}
|
|
|
|
registerBuiltinCompressionEngines();
|
|
const slmEngine = getCompressionEngine("llmlingua");
|
|
if (slmEngine?.applyAsync) {
|
|
const engineOptions: CompressionEngineApplyOptions = {
|
|
model: options?.model,
|
|
supportsVision: options?.supportsVision,
|
|
config: options?.config,
|
|
principalId: options?.principalId,
|
|
stepConfig: {
|
|
modelPath,
|
|
...(typeof ultraConfig?.compressionRate === "number"
|
|
? { compressionRate: ultraConfig.compressionRate }
|
|
: {}),
|
|
},
|
|
};
|
|
try {
|
|
const slm = await slmEngine.applyAsync(body, engineOptions);
|
|
if (slm.compressed && slm.stats) {
|
|
// Attribute the result to ultra (the selected mode) while marking the SLM tier.
|
|
return {
|
|
...slm,
|
|
stats: {
|
|
...slm.stats,
|
|
mode: "ultra",
|
|
techniquesUsed: Array.from(new Set([...(slm.stats.techniquesUsed ?? []), "ultra-slm"])),
|
|
},
|
|
};
|
|
}
|
|
} catch {
|
|
// llmlingua fail-opens internally, but guard anyway and use the configured fallback.
|
|
}
|
|
}
|
|
|
|
// SLM tier unavailable or produced no gain → fall back per slmFallbackToAggressive.
|
|
return applyCompression(
|
|
body,
|
|
ultraConfig?.slmFallbackToAggressive ? "aggressive" : "ultra",
|
|
options
|
|
);
|
|
}
|
|
|
|
function normalizePipelineStep(step: CompressionPipelineStep | string): CompressionPipelineStep {
|
|
if (typeof step !== "string") return step;
|
|
if (step === "standard") return { engine: "caveman" };
|
|
if (step === "rtk") return { engine: "rtk" };
|
|
if (step === "lite" || step === "aggressive" || step === "ultra") return { engine: step };
|
|
return { engine: "caveman" };
|
|
}
|
|
|
|
/**
|
|
* TV1 — Opt-in bail-out configuration for the stacked pipeline.
|
|
* When enabled: a step that throws is silently skipped (verbatim kept);
|
|
* a step whose gain is below minGainPercent is also skipped.
|
|
* DEFAULT = disabled — behaviour is byte-identical to pre-TV1 when absent.
|
|
*/
|
|
interface BailoutConfig {
|
|
enabled: boolean;
|
|
/** Minimum savings percent required to advance currentBody. Default: 10. */
|
|
minGainPercent?: number;
|
|
}
|
|
|
|
/** Per-engine progress emitted mid-pipeline by the stacked loops (F3.3 live streaming). */
|
|
export interface StackedCompressionStep {
|
|
stepIndex: number;
|
|
totalSteps: number;
|
|
engine: string;
|
|
state: "done" | "skipped";
|
|
originalTokens: number;
|
|
compressedTokens: number;
|
|
savingsPercent: number;
|
|
durationMs?: number;
|
|
}
|
|
|
|
interface StackOptions {
|
|
model?: string;
|
|
supportsVision?: boolean | null;
|
|
config?: CompressionConfig;
|
|
compressionComboId?: string | null;
|
|
/** TV1 bail-out discipline (opt-in, default disabled). */
|
|
bailout?: BailoutConfig;
|
|
/** Opt-in per-step fidelity gate (default disabled). */
|
|
fidelityGate?: FidelityGateConfig;
|
|
/** Authenticated principal id — threaded through to CCR engine for store scoping. */
|
|
principalId?: string;
|
|
/** F3.3: called once per engine as it completes (live per-engine streaming). */
|
|
onEngineStep?: (step: StackedCompressionStep) => void;
|
|
}
|
|
|
|
/** Emit a per-engine step to the live streaming callback (best-effort, no-op when unset). */
|
|
function reportEngineStep(
|
|
onStep: ((step: StackedCompressionStep) => void) | undefined,
|
|
stepIndex: number,
|
|
totalSteps: number,
|
|
engine: string,
|
|
result: CompressionResult
|
|
): void {
|
|
if (!onStep) return;
|
|
const s = result.stats;
|
|
onStep({
|
|
stepIndex,
|
|
totalSteps,
|
|
engine,
|
|
state: result.compressed ? "done" : "skipped",
|
|
originalTokens: s?.originalTokens ?? 0,
|
|
compressedTokens: s?.compressedTokens ?? s?.originalTokens ?? 0,
|
|
savingsPercent: s?.savingsPercent ?? 0,
|
|
...(s?.durationMs !== undefined ? { durationMs: s.durationMs } : {}),
|
|
});
|
|
}
|
|
|
|
/** Accumulates per-step telemetry across a stacked run (shared sync/async). */
|
|
export interface StackAccumulator {
|
|
techniques: Set<string>;
|
|
rules: Set<string>;
|
|
breakdown: NonNullable<CompressionStats["engineBreakdown"]>;
|
|
rtkRawOutputPointers: NonNullable<CompressionStats["rtkRawOutputPointers"]>;
|
|
validationWarnings: Set<string>;
|
|
validationErrors: Set<string>;
|
|
fallbackApplied: boolean;
|
|
}
|
|
|
|
function createStackAccumulator(): StackAccumulator {
|
|
return {
|
|
techniques: new Set<string>(),
|
|
rules: new Set<string>(),
|
|
breakdown: [],
|
|
rtkRawOutputPointers: [],
|
|
validationWarnings: new Set<string>(),
|
|
validationErrors: new Set<string>(),
|
|
fallbackApplied: false,
|
|
};
|
|
}
|
|
|
|
function resolveStackSteps(
|
|
pipeline?: Array<CompressionPipelineStep | string>
|
|
): CompressionPipelineStep[] {
|
|
return pipeline && pipeline.length > 0
|
|
? pipeline.map(normalizePipelineStep)
|
|
: [
|
|
{ engine: "rtk", intensity: "standard" },
|
|
{ engine: "caveman", intensity: "full" },
|
|
];
|
|
}
|
|
|
|
function buildStepOptions(
|
|
step: CompressionPipelineStep,
|
|
options?: StackOptions
|
|
): CompressionEngineApplyOptions {
|
|
return {
|
|
...options,
|
|
compressionComboId: options?.compressionComboId ?? options?.config?.compressionComboId,
|
|
principalId: options?.principalId,
|
|
stepConfig: {
|
|
...(step.config ?? {}),
|
|
...(step.intensity ? { intensity: step.intensity } : {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* TV1 — Pure helper that decides whether a completed step should advance
|
|
* `currentBody`. Called only when bailout is ENABLED; the sync/async loops
|
|
* bypass this entirely on the default-off path (zero cost, zero behaviour change).
|
|
*
|
|
* Returns `{ advance: true }` when the step should be accepted, or
|
|
* `{ advance: false }` when it should be skipped (verbatim kept).
|
|
*/
|
|
function decideStep(result: CompressionResult, bailout: BailoutConfig): { advance: boolean } {
|
|
if (!result.compressed) return { advance: false };
|
|
// Clamp: a negative minGainPercent would mean "always advance" (invalid state).
|
|
const minGain = Math.max(0, bailout.minGainPercent ?? 10);
|
|
const gain = result.stats?.savingsPercent ?? 0;
|
|
if (gain < minGain) return { advance: false };
|
|
return { advance: true };
|
|
}
|
|
|
|
/** Folds one engine result into the accumulator (telemetry + breakdown entry). */
|
|
function mergeStackStep(acc: StackAccumulator, engineId: string, result: CompressionResult): void {
|
|
if (!result.stats) return;
|
|
result.stats.techniquesUsed.forEach((technique) => acc.techniques.add(technique));
|
|
result.stats.rulesApplied?.forEach((rule) => acc.rules.add(rule));
|
|
result.stats.rtkRawOutputPointers?.forEach((pointer) => acc.rtkRawOutputPointers.push(pointer));
|
|
result.stats.validationWarnings?.forEach((warning) => acc.validationWarnings.add(warning));
|
|
result.stats.validationErrors?.forEach((error) => acc.validationErrors.add(error));
|
|
acc.fallbackApplied = acc.fallbackApplied || result.stats.fallbackApplied === true;
|
|
acc.breakdown.push({
|
|
engine: engineId,
|
|
originalTokens: result.stats.originalTokens,
|
|
compressedTokens: result.stats.compressedTokens,
|
|
savingsPercent: result.stats.savingsPercent,
|
|
techniquesUsed: result.stats.techniquesUsed,
|
|
...(result.stats.rulesApplied ? { rulesApplied: result.stats.rulesApplied } : {}),
|
|
...(result.stats.durationMs !== undefined ? { durationMs: result.stats.durationMs } : {}),
|
|
});
|
|
}
|
|
|
|
function finalizeStackedResult(
|
|
originalBody: Record<string, unknown>,
|
|
currentBody: Record<string, unknown>,
|
|
compressed: boolean,
|
|
acc: StackAccumulator,
|
|
start: number,
|
|
compressionComboId: string | null | undefined
|
|
): CompressionResult {
|
|
const stats = createCompressionStats(
|
|
originalBody,
|
|
currentBody,
|
|
"stacked",
|
|
Array.from(acc.techniques),
|
|
acc.rules.size > 0 ? Array.from(acc.rules) : undefined,
|
|
Math.round((performance.now() - start) * 100) / 100
|
|
);
|
|
stats.engine = "stacked";
|
|
stats.compressionComboId = compressionComboId ?? null;
|
|
stats.engineBreakdown = acc.breakdown;
|
|
if (acc.validationWarnings.size > 0) {
|
|
stats.validationWarnings = Array.from(acc.validationWarnings);
|
|
}
|
|
if (acc.validationErrors.size > 0) {
|
|
stats.validationErrors = Array.from(acc.validationErrors);
|
|
}
|
|
if (acc.fallbackApplied) {
|
|
stats.fallbackApplied = true;
|
|
}
|
|
if (acc.rtkRawOutputPointers.length > 0) {
|
|
const seenPointers = new Set<string>();
|
|
stats.rtkRawOutputPointers = acc.rtkRawOutputPointers.filter((pointer) => {
|
|
if (seenPointers.has(pointer.id)) return false;
|
|
seenPointers.add(pointer.id);
|
|
return true;
|
|
});
|
|
}
|
|
return { body: currentBody, compressed, stats };
|
|
}
|
|
|
|
export function applyStackedCompression(
|
|
body: Record<string, unknown>,
|
|
pipeline?: Array<CompressionPipelineStep | string>,
|
|
options?: StackOptions
|
|
): CompressionResult {
|
|
const steps = resolveStackSteps(pipeline);
|
|
registerBuiltinCompressionEngines();
|
|
|
|
let currentBody = body;
|
|
let compressed = false;
|
|
const acc = createStackAccumulator();
|
|
const start = performance.now();
|
|
|
|
const bailout = options?.bailout;
|
|
const fidelityGate = options?.fidelityGate ?? options?.config?.fidelityGate;
|
|
const onStep = options?.onEngineStep;
|
|
const totalSteps = steps.length;
|
|
let stepIdx = 0;
|
|
|
|
for (const step of steps) {
|
|
const engine = getCompressionEngine(step.engine);
|
|
if (!engine) continue;
|
|
// Respect the registry enabled flag: a step naming a disabled engine is skipped, so an
|
|
// operator can turn an engine off (setEngineEnabled) without editing every pipeline.
|
|
if (getEngineEntry(step.engine)?.enabled === false) continue;
|
|
|
|
// TV1: when bail-out is ENABLED, wrap apply() and apply skip rules.
|
|
// When DISABLED (default), the code path below is identical to pre-TV1.
|
|
if (bailout?.enabled) {
|
|
let result: CompressionResult;
|
|
try {
|
|
result = engine.apply(currentBody, buildStepOptions(step, options));
|
|
} catch (err) {
|
|
// Failure bail-out: keep the verbatim body for this step, but RECORD the
|
|
// failure so a crashing engine is visible in telemetry (not silently gone).
|
|
acc.validationErrors.add(
|
|
`${step.engine}: bailed out — ${err instanceof Error ? err.message : String(err)}`
|
|
);
|
|
acc.fallbackApplied = true;
|
|
continue;
|
|
}
|
|
mergeStackStep(acc, step.engine, result);
|
|
if (decideStep(result, bailout).advance && gateAdvance(result, currentBody, fidelityGate, acc, step.engine)) {
|
|
currentBody = result.body;
|
|
compressed = true;
|
|
}
|
|
} else {
|
|
const result = engine.apply(currentBody, buildStepOptions(step, options));
|
|
mergeStackStep(acc, step.engine, result);
|
|
if (result.compressed && gateAdvance(result, currentBody, fidelityGate, acc, step.engine)) {
|
|
currentBody = result.body;
|
|
compressed = true;
|
|
}
|
|
reportEngineStep(onStep, stepIdx++, totalSteps, step.engine, result);
|
|
}
|
|
}
|
|
|
|
return finalizeStackedResult(
|
|
body,
|
|
currentBody,
|
|
compressed,
|
|
acc,
|
|
start,
|
|
options?.compressionComboId ?? options?.config?.compressionComboId
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Async sibling of {@link applyStackedCompression} (H10). Awaits engines that
|
|
* expose `applyAsync` (e.g. worker-thread models) and runs synchronous engines
|
|
* inline. Behaviour is otherwise identical: same step order, same accumulated
|
|
* telemetry, same final stats — so sync-only pipelines yield the same result.
|
|
*/
|
|
export async function applyStackedCompressionAsync(
|
|
body: Record<string, unknown>,
|
|
pipeline?: Array<CompressionPipelineStep | string>,
|
|
options?: StackOptions
|
|
): Promise<CompressionResult> {
|
|
const steps = resolveStackSteps(pipeline);
|
|
registerBuiltinCompressionEngines();
|
|
|
|
let currentBody = body;
|
|
let compressed = false;
|
|
const acc = createStackAccumulator();
|
|
const start = performance.now();
|
|
|
|
const bailout = options?.bailout;
|
|
const fidelityGate = options?.fidelityGate ?? options?.config?.fidelityGate;
|
|
const onStep = options?.onEngineStep;
|
|
const totalSteps = steps.length;
|
|
let stepIdx = 0;
|
|
|
|
for (const step of steps) {
|
|
const engine = getCompressionEngine(step.engine);
|
|
if (!engine) continue;
|
|
// Respect the registry enabled flag (same as the sync loop) — keep both in lockstep.
|
|
if (getEngineEntry(step.engine)?.enabled === false) continue;
|
|
const stepOptions = buildStepOptions(step, options);
|
|
|
|
// TV1: same bail-out discipline as the sync loop (opt-in, default off).
|
|
if (bailout?.enabled) {
|
|
let result: CompressionResult;
|
|
try {
|
|
result = engine.applyAsync
|
|
? await engine.applyAsync(currentBody, stepOptions)
|
|
: engine.apply(currentBody, stepOptions);
|
|
} catch (err) {
|
|
// Failure bail-out: keep the verbatim body, but RECORD the failure so a
|
|
// crashing engine is visible in telemetry (not silently gone).
|
|
acc.validationErrors.add(
|
|
`${step.engine}: bailed out — ${err instanceof Error ? err.message : String(err)}`
|
|
);
|
|
acc.fallbackApplied = true;
|
|
continue;
|
|
}
|
|
mergeStackStep(acc, step.engine, result);
|
|
if (decideStep(result, bailout).advance && gateAdvance(result, currentBody, fidelityGate, acc, step.engine)) {
|
|
currentBody = result.body;
|
|
compressed = true;
|
|
}
|
|
} else {
|
|
const result = engine.applyAsync
|
|
? await engine.applyAsync(currentBody, stepOptions)
|
|
: engine.apply(currentBody, stepOptions);
|
|
mergeStackStep(acc, step.engine, result);
|
|
if (result.compressed && gateAdvance(result, currentBody, fidelityGate, acc, step.engine)) {
|
|
currentBody = result.body;
|
|
compressed = true;
|
|
}
|
|
reportEngineStep(onStep, stepIdx++, totalSteps, step.engine, result);
|
|
}
|
|
}
|
|
|
|
return finalizeStackedResult(
|
|
body,
|
|
currentBody,
|
|
compressed,
|
|
acc,
|
|
start,
|
|
options?.compressionComboId ?? options?.config?.compressionComboId
|
|
);
|
|
}
|