diff --git a/.env.example b/.env.example index 919db23428..3335784a6c 100644 --- a/.env.example +++ b/.env.example @@ -1640,6 +1640,14 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis # QUOTA_CONSUMPTION_RETENTION_DAYS=14 # GC de buckets quota_consumption.updated_at antigos # QUOTA_PREFLIGHT_CUTOFF_ENABLED=false # opt-in (default OFF): hard quota cutoff drops low-quota candidates before auto-routing scoring +# ─── Auto-Combo tier filter (#4517) ─────────────────────────────────────── +# When an `auto/:free` (or any `:`) request matches NO connected +# candidates, OmniRoute returns an EMPTY pool by default — so `:free` really means +# "free tier only" and a paid model is never picked just because no free provider is +# connected. Set this to `true`/`1` to restore the legacy behavior of falling back to +# the full (unfiltered) pool with a warning. Source: open-sse/services/autoCombo/virtualFactory.ts +# OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL=false + # ─── OpenCode config regeneration (scripts/ad-hoc/regen-opencode-config.ts) ─── # Base URL of the OmniRoute instance to query for /v1/models when regenerating # an opencode.json with accurate limit.context values. Used by: @@ -1729,21 +1737,3 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis # BIFROST_API_KEY= # BIFROST_STREAMING_ENABLED=true # BIFROST_TIMEOUT_MS=30000 - -# ── Scheduled VACUUM (Issue #4437) ──────────────────────────────────────── -# Master switch for the scheduled SQLite VACUUM job. -# - OMNIROUTE_VACUUM_ENABLED=1 (default) → start a setInterval timer at boot -# that runs VACUUM once every OMNIROUTE_VACUUM_INTERVAL_HOURS. -# - 0 → disable the scheduler entirely (manual "Vacuum Now" still works). -# Source: src/lib/db/vacuumScheduler.ts -# OMNIROUTE_VACUUM_ENABLED=1 - -# How often to run VACUUM. Default: 24 hours. Minimum: 1. Must be an integer. -# The actual interval is computed lazily from OMNIROUTE_VACUUM_INTERVAL_HOURS -# at boot time, so changes here require a restart (or `omniroute vacuum restart`). -# OMNIROUTE_VACUUM_INTERVAL_HOURS=24 - -# Window in which the first VACUUM is allowed to run (cron-like start window). -# Default: 02:00-04:00 (local server time). Outside the window the scheduler -# waits until the window opens. Format: HH:MM-HH:MM (24-hour). -# OMNIROUTE_VACUUM_WINDOW=02:00-04:00 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93e71bc2f8..0da08470d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -227,7 +227,7 @@ jobs: runs-on: ubuntu-latest steps: # fetch-depth: 0 — the OpenAPI breaking-change gate (oasdiff) reads the base - # spec via `git show :docs/reference/openapi.yaml`; a shallow clone + # spec via `git show :docs/openapi.yaml`; a shallow clone # would lack the base ref and the gate would self-skip (base-unresolved). - uses: actions/checkout@v7 with: @@ -318,7 +318,7 @@ jobs: - name: Workflow lint (actionlint+zizmor, ratchet, blocking) run: npm run check:workflows -- --ratchet # OpenAPI breaking-change detection (oasdiff). Diffs the PR's public API - # contract (docs/reference/openapi.yaml) against the base branch's spec. + # contract (docs/openapi.yaml) against the base branch's spec. # BLOCKING ratchet (Fase 9 Onda 0): reads metrics.openapiBreaking.value and # exits 1 ONLY on a measured regression (count > baseline). It SKIPs (exit 0) # when oasdiff is absent or the base spec can't be resolved — a missing diff --git a/.github/workflows/dast-smoke.yml b/.github/workflows/dast-smoke.yml index e06968cf61..5d8676cea7 100644 --- a/.github/workflows/dast-smoke.yml +++ b/.github/workflows/dast-smoke.yml @@ -42,7 +42,7 @@ jobs: - run: pip install schemathesis - name: Schemathesis smoke (high-risk endpoints, blocking) run: | - schemathesis run docs/reference/openapi.yaml --url http://localhost:20128 \ + schemathesis run docs/openapi.yaml --url http://localhost:20128 \ --include-path-regex '^/v1/(chat/completions|models)$|^/api/(auth|keys)' \ --max-examples 8 --workers 4 --checks all --max-response-time 30 \ --request-timeout 20 --suppress-health-check all --no-color diff --git a/.github/workflows/nightly-schemathesis.yml b/.github/workflows/nightly-schemathesis.yml index 0ee4149c8e..e430677638 100644 --- a/.github/workflows/nightly-schemathesis.yml +++ b/.github/workflows/nightly-schemathesis.yml @@ -45,7 +45,7 @@ jobs: # PROVE the contract is fuzzable and surface regressions, not to gate the build. continue-on-error: true run: | - schemathesis run docs/reference/openapi.yaml \ + schemathesis run docs/openapi.yaml \ --url http://localhost:20128 \ --max-examples 20 \ --workers 4 \ diff --git a/AGENTS.md b/AGENTS.md index cf7ec1b4a6..b06fc9b2d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -554,7 +554,7 @@ For any non-trivial change, read the matching deep-dive first: | Agent protocols (A2A / ACP / Cloud) | [`docs/frameworks/AGENT_PROTOCOLS_GUIDE.md`](docs/frameworks/AGENT_PROTOCOLS_GUIDE.md) | | MCP server | [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md) | | A2A server | [`docs/frameworks/A2A-SERVER.md`](docs/frameworks/A2A-SERVER.md) | -| API reference | [`docs/reference/API_REFERENCE.md`](docs/reference/API_REFERENCE.md) + [`docs/reference/openapi.yaml`](docs/reference/openapi.yaml) | +| API reference | [`docs/reference/API_REFERENCE.md`](docs/reference/API_REFERENCE.md) + [`docs/openapi.yaml`](docs/openapi.yaml) | | Provider catalog (auto-generated) | [`docs/reference/PROVIDER_REFERENCE.md`](docs/reference/PROVIDER_REFERENCE.md) | | Tunnels | [`docs/ops/TUNNELS_GUIDE.md`](docs/ops/TUNNELS_GUIDE.md) | | Electron desktop | [`docs/guides/ELECTRON_GUIDE.md`](docs/guides/ELECTRON_GUIDE.md) | diff --git a/CHANGELOG.md b/CHANGELOG.md index 89ce6ab1ad..8b4175f354 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,36 @@ --- +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features @@ -18,6 +48,8 @@ ### 🐛 Fixed +- **fix(tier): noAuth providers count as free; `auto/:free` returns an empty pool when no free candidate matches** — `freeProviders` is now the union of the legacy explicit list and every chat-tier `noAuth` provider derived from `NOAUTH_PROVIDERS` (so opencode / mimocode / duckduckgo-web are correctly classified free), the task-fitness lookup inherits a base model's `arena_elo` for its `-free` variant, and the `auto/:` filter no longer silently falls back to the full pool — a `:free` request that matches no connected free model returns empty instead of billing a paid model (opt back into the legacy fallback with `OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL=true`). Corrupted/invalid `tier_config` rows now log a structured warning and fall back to defaults instead of throwing. ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753), [#4517](https://github.com/diegosouzapw/OmniRoute/issues/4517) — thanks @megamen32) +- **fix(db): scheduled VACUUM follows Storage settings** — the SQLite VACUUM scheduler now uses the existing Storage page `scheduledVacuum` / `vacuumHour` configuration as its single source of truth, refreshes immediately when those settings are saved, and no longer exposes a separate environment-variable control path. - **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) - **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) - **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) @@ -115,7 +147,7 @@ - **fix(dashboard): migrate ManualConfigModal copy to the shared `useCopyToClipboard` hook** ([#4502](https://github.com/diegosouzapw/OmniRoute/pull/4502) — thanks @diegosouzapw) - **fix(sse): skip disabled providers in combo fallback** ([#4500](https://github.com/diegosouzapw/OmniRoute/pull/4500) — thanks @diegosouzapw) - **fix(usage): parse numeric-string quota reset timestamps as Unix sec/ms** ([#4493](https://github.com/diegosouzapw/OmniRoute/pull/4493) — thanks @diegosouzapw) -- **fix(db): scheduled VACUUM + persist `lastVacuumAt`** — a new `vacuumScheduler.ts` persists the last run timestamp and last error to the `key_value` table (migration 102) and feeds the database settings panel; wired into the Next.js lifecycle (default 24h, window 02:00–04:00 local). New env flags: `OMNIROUTE_VACUUM_ENABLED`, `OMNIROUTE_VACUUM_INTERVAL_HOURS`, `OMNIROUTE_VACUUM_WINDOW`. ([#4480](https://github.com/diegosouzapw/OmniRoute/pull/4480) — thanks @KooshaPari / @oyi77) +- **fix(db): scheduled VACUUM + persist `lastVacuumAt`** — a new `vacuumScheduler.ts` persists the last run timestamp and last error to the `key_value` table (migration 102) and feeds the database settings panel; wired into the Next.js lifecycle (default 24h, window 02:00–04:00 local). The initial env-flag control path from this entry is superseded in v3.8.34 by the Storage page settings. ([#4480](https://github.com/diegosouzapw/OmniRoute/pull/4480) — thanks @KooshaPari / @oyi77) - **perf(quota): stop writing redundant `quota_snapshots` rows from idle connections** — the 60s background refresh persisted a snapshot for every window of every connection regardless of change, generating 400K+ rows/day from idle accounts. `setQuotaCache` now skips the write when a window's `remaining_percentage`/`is_exhausted` is unchanged from the last cached observation; the first observation and every real change still persist. ([#4565](https://github.com/diegosouzapw/OmniRoute/pull/4565), [#4438](https://github.com/diegosouzapw/OmniRoute/issues/4438) — thanks @oyi77) ### 🔒 Security @@ -1609,7 +1641,7 @@ And thank you to the OmniRoute community for the bug reports, reproductions, and `tests/e2e/traffic-inspector.spec.ts`, `tests/e2e/agent-bridge-traffic-cross.spec.ts` (skip-gated on CI by `RUN_AGENT_BRIDGE_E2E` / `RUN_TRAFFIC_INSPECTOR_E2E` / `RUN_CROSS_E2E`). - **Documentation** — `docs/frameworks/AGENTBRIDGE.md` and `docs/frameworks/TRAFFIC_INSPECTOR.md`; - `docs/architecture/REPOSITORY_MAP.md` updated; `docs/reference/openapi.yaml` updated with + `docs/architecture/REPOSITORY_MAP.md` updated; `docs/openapi.yaml` updated with ~28 new routes and 20+ new schemas. - **i18n:** translate Ukrainian (uk-UA) menu and UI strings, plus complete uk-UA UI coverage (#2981 / #2988 — thanks @Lion-killer) - **providers:** add SiliconFlow endpoint selector (#2975 — thanks @xz-dev) diff --git a/CLAUDE.md b/CLAUDE.md index 86763792a1..75df82dab0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -311,7 +311,7 @@ connection continue serving other models. 4. Create 7 API endpoints under `src/app/api/services/{name}/` (`_lib.ts`, `install`, `start`, `stop`, `restart`, `update`, `status`, `auto-start`). All delegate errors through `createErrorResponse()`. The shared `logs` endpoint is already wired via `[name]/logs/route.ts`. 5. Verify `/api/services/` is in `LOCAL_ONLY_API_PREFIXES` in `src/server/authz/routeGuard.ts`; add a test asserting `isLocalOnlyPath()` returns `true` for the new prefix if you add one (hard rule #17). 6. Add a UI tab in `src/app/(dashboard)/dashboard/providers/services/tabs/` reusing `ServiceStatusCard`, `ServiceLifecycleButtons`, `ServiceLogsPanel`. -7. Document in `docs/frameworks/EMBEDDED-SERVICES.md` (update §1 service table + §4 API reference) and `docs/reference/openapi.yaml`. +7. Document in `docs/frameworks/EMBEDDED-SERVICES.md` (update §1 service table + §4 API reference) and `docs/openapi.yaml`. 8. Write tests: unit (`tests/unit/services/`), integration (`tests/integration/services/`, gated by `RUN_SERVICES_INT=1`), and update `docs/ops/RELEASE_CHECKLIST.md` smoke section. ### Adding a New Guardrail / Eval / Skill / Webhook event @@ -349,7 +349,7 @@ For any non-trivial change, read the matching deep-dive first: | Agent protocols (A2A / ACP / Cloud) | `docs/frameworks/AGENT_PROTOCOLS_GUIDE.md` | | MCP server | `docs/frameworks/MCP-SERVER.md` | | A2A server | `docs/frameworks/A2A-SERVER.md` | -| API reference + OpenAPI | `docs/reference/API_REFERENCE.md` + `docs/reference/openapi.yaml` | +| API reference + OpenAPI | `docs/reference/API_REFERENCE.md` + `docs/openapi.yaml` | | Provider catalog (auto-generated) | `docs/reference/PROVIDER_REFERENCE.md` | | Release flow | `docs/ops/RELEASE_CHECKLIST.md` | | Embedded services | `docs/frameworks/EMBEDDED-SERVICES.md` | diff --git a/README.md b/README.md index 987022a121..150f630fd5 100644 --- a/README.md +++ b/README.md @@ -287,12 +287,12 @@ Result: 4 layers of fallback = zero downtime -> Recent highlights from **v3.8.20 → v3.8.33**. Full history in [`CHANGELOG.md`](CHANGELOG.md). +> Recent highlights from **v3.8.20 → v3.8.35**. Full history in [`CHANGELOG.md`](CHANGELOG.md). - **🤖 One-command CLI/agent setup** — a dedicated `setup-*` command configures each coding tool to route through OmniRoute (Claude Code, Codex, Cline, Continue, Cursor, Roo Code, Kilo Code, Crush, Goose, Qwen Code, Aider, OpenCode, Gemini CLI); `omniroute launch` / `omniroute launch-codex` are zero-config launchers. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md) - **🛰️ Remote mode** — drive a remote OmniRoute from any machine with scoped access tokens (`omniroute connect` / `omniroute contexts` / `omniroute tokens`). → [Remote Mode](docs/guides/REMOTE-MODE.md) - **🧭 Smarter auto-routing** — OpenRouter-style `auto/:` combos (e.g. `auto/coding:fast`, `auto/reasoning:pro`), live Arena-ELO + models.dev model intelligence, per-step account allowlists, provider-wildcard combo steps, nested combo-ref execution, sticky weighted selection, and `web_search`-aware routing to a configured model. → [Auto-Combo](docs/routing/AUTO-COMBO.md) -- **🗜️ Pluggable compression** — an async compression pipeline with Compression Studios, a stable LLMLingua-2 ONNX engine, RTK, delegated Anthropic Context Editing, and a unified settings panel with named profiles + an active-profile selector as the single source for per-engine on/off + level. → [Compression](docs/compression/COMPRESSION_ENGINES.md) +- **🗜️ Pluggable compression** — an async pipeline of **9 composable engines** with Compression Studios, an LLMLingua-2 ONNX engine and a heuristic/SLM two-tier **Ultra**, RTK, delegated Anthropic Context Editing, **Output Styles** (output-axis steering: terse-prose / less-code / terse-CJK), an **adaptive context-budget dial** (escalate only as far as needed to fit the context window), per-request `x-omniroute-compression` control, an opt-in offline eval harness, and a unified panel with named profiles + an active-profile selector. → [Compression](docs/compression/COMPRESSION_ENGINES.md) - **🕵️ Transparent MITM decrypt (TPROXY)** — capture & translate traffic from CLIs that ignore proxy env vars, with a per-SNI certificate authority and a trust-store installer. → [MITM/TPROXY](docs/security/MITM-TPROXY-DECRYPT.md) - **💸 Cost telemetry everywhere** — `X-OmniRoute-*` cost/usage headers on every endpoint (including media), a non-token cost engine, a cache-HIT `X-OmniRoute-Cost-Saved` header, and per-key USD spend quotas. → [API Reference](docs/reference/API_REFERENCE.md) - **🧠 Memory you control** — opt-in int8 vector quantization (Qdrant + sqlite-vec), memory off by default, and a per-request `x-omniroute-no-memory` header. → [Memory](docs/frameworks/MEMORY.md) @@ -538,7 +538,20 @@ average = 1 − (1 − 0.80) × (1 − 0.46) = 89.2% range = 78.4 – 94.6% ``` -Code blocks, URLs, JSON and structured data are **always protected** by the preservation engine. Auto-trigger compression by token threshold, or assign a compression pipeline per routing combo. +Code blocks, URLs, JSON and structured data are **always protected** by the preservation engine. + +### 🎚️ Beyond the engines — output styles, the adaptive dial & per-request control + +The 9 engines above shrink what goes **in**. Three more layers shape **how**, **when**, and what comes **out**: + +- **🪄 Output Styles** *(output-axis steering)* — inject deterministic, cache-safe response-shaping instructions; combinable, each at `lite` / `full` / `ultra` intensity. Adding a style is a one-line registry entry: + - **Terse prose** — drop filler / articles / hedging; keep technical substance exact. + - **Less code** — "lazy senior dev" YAGNI: smallest working change, no unrequested scaffolding. + - **Terse CJK (文言)** — classical-Chinese ultra-terse style (locale-gated to `zh`). +- **🎯 Adaptive context-budget** *(the dial)* — instead of one on/off token threshold, escalate the cheapest, most-lossless engines only as far as needed to **fit the model's context window**. Policy: `reserve-output` (default, model-aware) · `percentage` · `absolute`. Mode: `floor` (guarantee fit) · `replace-autotrigger` (your explicit choice wins) · `off` (legacy threshold). +- **🎛️ Where compression is decided** *(precedence, high → low)* — per-request `x-omniroute-compression` header › routing-combo override › active named profile › adaptive / auto-trigger › panel default › off. The applied plan echoes back in the `X-OmniRoute-Compression: ; source=` response header. + +Auto-trigger by token threshold, flip on the adaptive dial, pin a named profile, set a one-off per request, or assign a pipeline per routing combo — whichever fits the workload. An opt-in offline **eval harness** (`npm run eval:compression`) scores fidelity vs. savings on a pinned corpus before you promote a change. 📖 [`COMPRESSION_GUIDE.md`](docs/compression/COMPRESSION_GUIDE.md) · [`RTK_COMPRESSION.md`](docs/compression/RTK_COMPRESSION.md) · [`COMPRESSION_ENGINES.md`](docs/compression/COMPRESSION_ENGINES.md) @@ -921,7 +934,7 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo | Document | Description | | ------------------------------------------------- | --------------------------------------------------- | | [API Reference](docs/reference/API_REFERENCE.md) | All endpoints with examples | -| [OpenAPI Spec](docs/reference/openapi.yaml) | OpenAPI 3.0 specification | +| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification | | [MCP Server](open-sse/mcp-server/README.md) | 87 MCP tools, IDE configs, Python/TS/Go clients | | [MCP Server Guide](docs/frameworks/MCP-SERVER.md) | MCP installation, transports, and tool reference | | [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt | @@ -1084,6 +1097,7 @@ OmniRoute stands on the shoulders of giants. It started as a fork of **[9router] | **[LLMLingua](https://github.com/microsoft/LLMLingua)** · Microsoft | 6.3k | Prompt-compression research (LLMLingua / LLMLingua-2) — inspired our async, code-safe, fail-open `llmlingua` engine. | | **[llmlingua-2-js](https://github.com/atjsh/llmlingua-2-js)** · atjsh | 27 | The JS/ONNX port (MobileBERT / XLM-RoBERTa) used as the worker-thread backend for our LLMLingua engine. | | **[Troglodita](https://github.com/leninejunior/troglodita)** · Lenine Júnior | 15 | PT-BR token compression — powers our pt-BR language pack: pleonasm reduction and filler removal tuned for Brazilian-Portuguese grammar. | +| **[ponytail](https://github.com/DietrichGebert/ponytail)** · DietrichGebert | 51.4k | The viral "lazy senior dev" YAGNI-coder skill — inspired our **less-code** Output Style: smallest-working-change steering that cuts *generated* code (the output-axis sibling to Caveman's terse prose). | ### 🧩 Compact formats, token research & code-aware tooling @@ -1096,6 +1110,7 @@ OmniRoute stands on the shoulders of giants. It started as a fork of **[9router] | **[token-saver](https://github.com/ppgranger/token-saver)** · ppgranger | 103 | Content-aware, per-file-type output compression with failure-aware bail-out — validated our per-type dispatch and minimum-gain skip. | | **[token-optimizer](https://github.com/alexgreensh/token-optimizer)** · alexgreensh | 1.4k | "Find the ghost tokens" — its offload + recoverable-handle pattern informed our CCR offload thinking. | | **[TokenMizer](https://github.com/Shweta-Mishra-ai/tokenmizer)** · Shweta-Mishra-ai | 1 | A session-graph + cross-turn line-dedup blueprint that informed our session-dedup design. | +| **[OmniCompress](https://github.com/jessefreitas/OmniCompress)** · jessefreitas | 2 | Rust columnar-JSON + content-addressed retrieve + cross-message dedup — validated our `headroom`/`ccr`/`session-dedup` engine design and the cache-stable "compressed form is position-independent" invariant. | | **[mcp-compressor](https://github.com/atlassian-labs/mcp-compressor)** · Atlassian Labs | 80 | MCP tool-schema/description compression — informed our MCP tool-manifest cardinality reduction. | | **[RepoMapper](https://github.com/pdavis68/RepoMapper)** · pdavis68 | 182 | Aider-style repo-map ranking — informed our repo-map / retrieval-ranking exploration. | | **[quiet-shell-mcp](https://github.com/mrsimpson/quiet-shell-mcp)** · mrsimpson | 4 | Declarative shell-output reduction over MCP — validated our declarative bash-output compaction. | diff --git a/bin/cli/api-commands/agent-skills.mjs b/bin/cli/api-commands/agent-skills.mjs index 34b7f9e8fa..233d318f8d 100644 --- a/bin/cli/api-commands/agent-skills.mjs +++ b/bin/cli/api-commands/agent-skills.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/agentbridge.mjs b/bin/cli/api-commands/agentbridge.mjs index 6622291383..6d51e57b21 100644 --- a/bin/cli/api-commands/agentbridge.mjs +++ b/bin/cli/api-commands/agentbridge.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/api-keys.mjs b/bin/cli/api-commands/api-keys.mjs index 445df84546..21a64437db 100644 --- a/bin/cli/api-commands/api-keys.mjs +++ b/bin/cli/api-commands/api-keys.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/audio.mjs b/bin/cli/api-commands/audio.mjs index 4465f65bc3..74c0b0fa35 100644 --- a/bin/cli/api-commands/audio.mjs +++ b/bin/cli/api-commands/audio.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/chat.mjs b/bin/cli/api-commands/chat.mjs index 9b6497beb6..4d99413b4e 100644 --- a/bin/cli/api-commands/chat.mjs +++ b/bin/cli/api-commands/chat.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/cli-tools.mjs b/bin/cli/api-commands/cli-tools.mjs index 40c879e825..49df272def 100644 --- a/bin/cli/api-commands/cli-tools.mjs +++ b/bin/cli/api-commands/cli-tools.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/cloud.mjs b/bin/cli/api-commands/cloud.mjs index e942307196..eaebb1f7cc 100644 --- a/bin/cli/api-commands/cloud.mjs +++ b/bin/cli/api-commands/cloud.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/combos.mjs b/bin/cli/api-commands/combos.mjs index 2c3647f94b..e4e4ff62f5 100644 --- a/bin/cli/api-commands/combos.mjs +++ b/bin/cli/api-commands/combos.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/compression.mjs b/bin/cli/api-commands/compression.mjs index 9f96e0f8b0..3eb6a0af2a 100644 --- a/bin/cli/api-commands/compression.mjs +++ b/bin/cli/api-commands/compression.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/embedded-services.mjs b/bin/cli/api-commands/embedded-services.mjs index 0baa58b8a7..e53f019977 100644 --- a/bin/cli/api-commands/embedded-services.mjs +++ b/bin/cli/api-commands/embedded-services.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/embeddings.mjs b/bin/cli/api-commands/embeddings.mjs index 8ae1235a88..67db58e434 100644 --- a/bin/cli/api-commands/embeddings.mjs +++ b/bin/cli/api-commands/embeddings.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/fallback.mjs b/bin/cli/api-commands/fallback.mjs index b769ec8032..d790e85f63 100644 --- a/bin/cli/api-commands/fallback.mjs +++ b/bin/cli/api-commands/fallback.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/images.mjs b/bin/cli/api-commands/images.mjs index 14cc850844..cd80da2507 100644 --- a/bin/cli/api-commands/images.mjs +++ b/bin/cli/api-commands/images.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/memory.mjs b/bin/cli/api-commands/memory.mjs index 6cf8f68679..4fb9151f1c 100644 --- a/bin/cli/api-commands/memory.mjs +++ b/bin/cli/api-commands/memory.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/messages.mjs b/bin/cli/api-commands/messages.mjs index 6618f31a9e..9aeb2135be 100644 --- a/bin/cli/api-commands/messages.mjs +++ b/bin/cli/api-commands/messages.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/models.mjs b/bin/cli/api-commands/models.mjs index 09d0fa0e8a..1cec5a1b72 100644 --- a/bin/cli/api-commands/models.mjs +++ b/bin/cli/api-commands/models.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/moderations.mjs b/bin/cli/api-commands/moderations.mjs index 0c716202f6..2c039f5fc1 100644 --- a/bin/cli/api-commands/moderations.mjs +++ b/bin/cli/api-commands/moderations.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/oauth.mjs b/bin/cli/api-commands/oauth.mjs index 1645bc3220..e21c7b7224 100644 --- a/bin/cli/api-commands/oauth.mjs +++ b/bin/cli/api-commands/oauth.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/playground.mjs b/bin/cli/api-commands/playground.mjs index 3866409833..d5b6ba018f 100644 --- a/bin/cli/api-commands/playground.mjs +++ b/bin/cli/api-commands/playground.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/pricing.mjs b/bin/cli/api-commands/pricing.mjs index b790e24665..4e67966a2a 100644 --- a/bin/cli/api-commands/pricing.mjs +++ b/bin/cli/api-commands/pricing.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/provider-nodes.mjs b/bin/cli/api-commands/provider-nodes.mjs index 1444f8d267..d9f51a23e3 100644 --- a/bin/cli/api-commands/provider-nodes.mjs +++ b/bin/cli/api-commands/provider-nodes.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/providers.mjs b/bin/cli/api-commands/providers.mjs index d0e56e2f84..f362860385 100644 --- a/bin/cli/api-commands/providers.mjs +++ b/bin/cli/api-commands/providers.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/quota.mjs b/bin/cli/api-commands/quota.mjs index 5f72dad905..13a0e44eeb 100644 --- a/bin/cli/api-commands/quota.mjs +++ b/bin/cli/api-commands/quota.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/rerank.mjs b/bin/cli/api-commands/rerank.mjs index 522999ede6..1f14b74ce1 100644 --- a/bin/cli/api-commands/rerank.mjs +++ b/bin/cli/api-commands/rerank.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/responses.mjs b/bin/cli/api-commands/responses.mjs index 45ee00080e..d4546a4c4c 100644 --- a/bin/cli/api-commands/responses.mjs +++ b/bin/cli/api-commands/responses.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/settings.mjs b/bin/cli/api-commands/settings.mjs index 2a2d5ee7b5..fd9758b9c2 100644 --- a/bin/cli/api-commands/settings.mjs +++ b/bin/cli/api-commands/settings.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/system.mjs b/bin/cli/api-commands/system.mjs index c0def12050..18a3b1bfab 100644 --- a/bin/cli/api-commands/system.mjs +++ b/bin/cli/api-commands/system.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/telemetry.mjs b/bin/cli/api-commands/telemetry.mjs index 68a22de0c1..173582dead 100644 --- a/bin/cli/api-commands/telemetry.mjs +++ b/bin/cli/api-commands/telemetry.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/traffic-inspector.mjs b/bin/cli/api-commands/traffic-inspector.mjs index 1572090b54..ea8f926f49 100644 --- a/bin/cli/api-commands/traffic-inspector.mjs +++ b/bin/cli/api-commands/traffic-inspector.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/translator.mjs b/bin/cli/api-commands/translator.mjs index 10c1a03e22..d2cdf24cb3 100644 --- a/bin/cli/api-commands/translator.mjs +++ b/bin/cli/api-commands/translator.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/bin/cli/api-commands/usage.mjs b/bin/cli/api-commands/usage.mjs index 40a8b1afb1..b00d35dd2b 100644 --- a/bin/cli/api-commands/usage.mjs +++ b/bin/cli/api-commands/usage.mjs @@ -1,4 +1,4 @@ -// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +// AUTO-GENERATED from docs/openapi.yaml. Do not edit. import { apiFetch } from "../api.mjs"; import { emit } from "../output.mjs"; import { readFileSync } from "node:fs"; diff --git a/config/quality/complexity-baseline.json b/config/quality/complexity-baseline.json index 677745792a..36de290a52 100644 --- a/config/quality/complexity-baseline.json +++ b/config/quality/complexity-baseline.json @@ -1,6 +1,7 @@ { "_comment": "Catraca de complexidade (check-complexity.mjs, ESLint core rules complexity>=15 e max-lines-per-function>80 sobre src+open-sse+electron+bin via eslint.complexity.config.mjs). Conta total de violacoes; so pode cair. --update ratcheta.", - "count": 1916, + "count": 1920, + "_rebaseline_2026_06_23_v3835_release": "Reconciliacao release-volatil 1916->1920 (+4) no fechamento do ciclo v3.8.35, surfada pelo pre-flight check:release-green (a catraca de complexidade NAO roda no fast-path PR->release, so release->main, entao o ramo acumula sem rebaselinar). O +4 e drift de condicionais NOVOS dos merges de contribuidor/feature deste ciclo (Compression Phase 4 #4694/#4707/#4716/#4720, combos auto-promote #4774, tier no-auth #4753, deepseek-web tool-fold #4756, dedupe provider nodes #4768). Verificado que o trabalho de release-finalize desta sessao toca SO docs/*.md (THREAT_MODEL), CHANGELOG.md, baselines e 1 linha de string em scripts/check/check-fabricated-docs.mjs (fora do escopo src+open-sse+electron+bin que o gate varre) — contribui 0. Mesma familia dos rebaselines anteriores — crescimento de feature legitimo recem-TDD'd, nao regressao; reducao estrutural fica como debt (#3501).", "_rebaseline_2026_06_23_v3834_release": "Reconciliacao release-volatil 1915->1916 (+1) no fechamento do ciclo v3.8.34. check:complexity NAO roda no fast-path PR->release (so release->main), entao o ramo acumula sem rebaselinar; surfou no full CI do release PR (run em c98e7ff6d). O +1 e drift de condicional NOVO de merge de contribuidor do ciclo (features quota/usage/opencode-go/M365). Verificado que o commit de release-finalize NAO adiciona complexity: toca CHANGELOG/baseline/mirrors/3 testes + 1 linha de regex em opencodeOllamaUsage.ts (sem novo ramo) + reorder de dados no reka registry — local mede 1916 com ou sem essa mudanca. Mesma familia dos rebaselines anteriores — crescimento de feature legitimo, nao regressao; reducao estrutural fica como debt (#3501).", "_rebaseline_2026_06_21_v3833_4537_nested_combo": "Reconciliacao 1913->1915 (+2) do PR #4537 (nestedComboMode execute — black-box combo-ref execution). O +2 vem do branch novo de dispatch nested em handleComboChat (combo.ts: normalizeNestedComboMode + executeModeUnits/hasExecutableComboRef + o ramo simpleExecuteStrategies que roda resolveComboRuntimeUnits com recursion caps depth/cycle/budget) — ramos condicionais no chokepoint de dispatch do combo. Medido com `node scripts/check/check-complexity.mjs` no estado merged. Crescimento de feature legitimo recem-TDD'd, nao regressao; reducao estrutural fica como debt (#3501).", "_rebaseline_2026_06_21_v3833_reviewprs_r4_owner": "Reconciliacao release-volatil 1911->1913 (+2) apos o lote de PRs do owner desta rodada (#4560 RTK cache_control, #4552 Cursor auto-import macOS, #4551 Codex /responses probe, #4554 Cursor Composer decode + o stack #3501 #4538/#4544/#4548). O fast-path do release nao roda check:complexity (so release->main). As 3 extracoes chatCore #3501 (#4538 recupera 4 leaves, #4544 failureUsage, #4548 claudeSystemRole+upstreamExecuteHeaders) sao PURAS/complexity-neutras (movem codigo p/ leaves sob o teto, chatCore ENCOLHE); o +2 vem dos condicionais NOVOS das features .32-portadas (#4554 decode de bloco visivel do Composer + #4551 probe do endpoint real Codex /responses). Medido com `node scripts/check/check-complexity.mjs` no tip 4b34a75fe. Crescimento de feature legitimo recem-TDD'd, nao regressao; reducao estrutural fica como debt (#3501).", diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 46108fa893..29575e70f9 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,5 +1,6 @@ { "_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.", + "_rebaseline_2026_06_23_4774_combo_legacy_strip": "PR #4774 (KooshaPari, #4382 round-trip) own growth: src/app/(dashboard)/dashboard/combos/page.tsx 4434->4456 (+22 = the client-side LEGACY_COMBO_RESILIENCE_KEYS Set gains queueTimeoutMs + the 12 v3.8.31-era removed keys (queueDepth/fallbackDelayMs/handoffProviders/maxComboDepth/manifestRouting/complexityAwareRouting/pipeline_enabled/pipelineConcurrency/shadowRouting/evalRouting/resetAwareEnabled/resetAwareWindow) with explanatory comments, mirroring the server-side strip list in src/app/api/combos/[id]/route.ts so the modal never re-introduces removed keys on Save). Functional strip list, not a movable block; combos/page.tsx structural shrink tracked in #3501. Covered by tests/unit/combo-config.test.ts (auto-promote + passthrough + legacy-key round-trip).", "_rebaseline_2026_06_21_4421_node_lookup": "Issue #4421 own growth: src/lib/db/providers.ts 1050->1063 (+13 = resolveProviderNodeForConnection at the existing provider-node lookup — resolves a connection node by exact id OR the bare derived type when unambiguous, + import). Pure selection logic in the new src/lib/db/providerNodeSelect.ts (2289 (+10): #4530 só fiou maxCooldownMs nos 3 sites de combo.ts; os 4 sites de markAccountUnavailable (per-model quota, grok-web 403, per-model 403, local 404) nunca passavam o cap → resolvo mlSettings uma vez e passo maxCooldownMs em todos. tests/unit/db-core-init.test.ts 864->867 (+3): comentário explicando o cap intencional busy_timeout 5s->2s do v3.8.32. Wiring necessário ao chokepoint de lockout; não extraível. Coberto por model-lockout-max-cooldown.test.ts + db-core-init.test.ts. (Demais base-reds — áudio mp3 #912/#913 dedup de handler em geminiHelper.ts, e closure #3578 src/models/ no package.json files — não cresceram arquivo congelado.)", "_rebaseline_2026_06_21_v3833_cycle_open_latent_filesize": "Abertura do ciclo v3.8.33: 4 arquivos cresceram no ciclo v3.8.32 sem bump de baseline e o drift escapou do fast-path do release (check:file-size não roda nas fast-gates p/ release/*, só no PR→main full CI, e o crescimento veio de commits entre fca66c644 e o head do merge 912239f46 — ex. #4475 targetFormat). Medido em origin/main (idêntico, cherry-picks deste ciclo NÃO tocam estes 4): open-sse/services/usage.ts 3408->3414, src/lib/db/core.ts 1820->1825, src/lib/usage/providerLimits.ts 949->950, src/shared/constants/providers.ts 3242->3243. Reconcílio ao valor real de main p/ abrir o .33 verde; shrink estrutural rastreado em #3501.", @@ -106,7 +107,7 @@ "_rebaseline_2026_06_20_4389_thinking_toolchoice": "Re-baseline base.ts 1387->1399 (#4389): tool_choice-forced thinking guard at the existing Claude wire-image injection chokepoint (effThinking gate avoids the Anthropic 400 when tool_choice forces a tool). Cohesive guard; structural shrink tracked in #3501.", "cap": 800, "frozen": { - "open-sse/translator/request/openai-to-kiro.ts": 807, + "_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) — provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) — extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).", "open-sse/config/providerRegistry.ts": 4731, "open-sse/executors/antigravity.ts": 1696, "open-sse/executors/base.ts": 1414, @@ -114,13 +115,13 @@ "open-sse/executors/claude-web.ts": 1057, "open-sse/executors/codex.ts": 1449, "open-sse/executors/cursor.ts": 1453, - "open-sse/executors/deepseek-web.ts": 1125, + "open-sse/executors/deepseek-web.ts": 1148, "_rebaseline_2026_06_22_4644_deepseek_web_tools": "PR #4644 (BugsBag/robust deepseek-web tool-call parsing): open-sse/executors/deepseek-web.ts 1117->1125 (+8). The new agentic tool-call path emits surrounding text + reasoning before tool_calls and swaps to the dedicated deepseekWebTools.ts parser; the +8 lines are cohesive wiring at the existing transformSSE chokepoint (the parser itself lives in the new deepseekWebTools.ts file, already under cap). The PR's own fast-gate (PR->release) does not run check:file-size, so this surfaced only at release reconcile. Covered by tests/unit/deepseek-web-tools-variants.test.ts + deepseek-web-tools-execute.test.ts.", + "_rebaseline_2026_06_23_4712_deepseek_web_tool_results": "PR for #4712 (deepseek-web drops role:tool): open-sse/executors/deepseek-web.ts 1125->1148 (+23). messagesToPrompt() now folds role:\"tool\" results into the single-prompt transcript (recovering the tool name from the preceding assistant tool_calls by tool_call_id) instead of silently dropping them; the lines are cohesive wiring inside the existing function. Covered by tests/unit/deepseek-web-tool-result-prompt-4712.test.ts.", "open-sse/executors/duckduckgo-web.ts": 925, "open-sse/executors/grok-web.ts": 1871, "open-sse/executors/muse-spark-web.ts": 1284, "open-sse/executors/perplexity-web.ts": 1013, - "_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) — provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) — extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).", "open-sse/handlers/audioSpeech.ts": 1061, "open-sse/handlers/chatCore.ts": 5125, "open-sse/handlers/imageGeneration.ts": 3777, @@ -137,10 +138,12 @@ "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": 848, "open-sse/services/rateLimitManager.ts": 1035, "open-sse/services/tokenRefresh.ts": 1997, "open-sse/services/usage.ts": 3450, "open-sse/translator/request/openai-to-gemini.ts": 864, + "open-sse/translator/request/openai-to-kiro.ts": 807, "open-sse/translator/response/openai-responses.ts": 922, "open-sse/utils/cursorAgentProtobuf.ts": 1521, "open-sse/utils/stream.ts": 2710, @@ -151,7 +154,7 @@ "src/app/(dashboard)/dashboard/cache/page.tsx": 841, "src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx": 900, "src/app/(dashboard)/dashboard/cloud-agents/page.tsx": 922, - "src/app/(dashboard)/dashboard/combos/page.tsx": 4434, + "src/app/(dashboard)/dashboard/combos/page.tsx": 4456, "src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1495, "src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": 1007, "src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2570, @@ -302,5 +305,7 @@ "_rebaseline_2026_06_20_4355_gpt5x_pro_pricing": "PR #4355 own growth: pricing.ts 1581->1592 (+11 = pure-data pricing rows for openai gpt-5.5-pro + gpt-5.4-pro, closing the $0 gap that tripped the catalog pricing gate after the #4324 sweep added them to the registry; -pro mirrors its base family tier). provider-models-route.test.ts 1616->1618 (+2 = test-only alignment to the intentional opencode-go discovery behavior: owned_by stamp + T39 two-endpoint fail-path fetchCalls). Both are data/test-only; not extractable.", "_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_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 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 `; per-connection scope check | +| **T** | M — WS message frames tampered | TLS-only (`wss://`); reject `ws://` in prod | +| **R** | L | Connection ID + auth principal recorded at handshake | +| **I** | H — Long-lived connection = larger blast radius | 1-hour max connection; heartbeat every 30s; auto-disconnect on auth revoke | +| **D** | H — Many WS = connection table exhaustion | Per-IP WS cap 5; per-key cap 20; rate limit per message | +| **E** | L | | + +--- + +## 4. Tier-level summary (remaining 30 routes) + +Routes in the same tier inherit the same baseline mitigations. Per-route +deltas are documented in `docs/openapi.yaml` (request schema constraints). + +| Tier | Count | Baseline mitigations | Residual risks | +| ----------------------------------------------------------------------------------------------------------------------------------- | ----- | ---------------------------------------------------------- | ------------------------------------ | +| PUBLIC (`/api/health`, `/api/monitoring/health`, `/_next/*`) | 3 | No auth; rate limit; static response | DoS via flooding (mitigated at LB) | +| CLIENT_API inference (`/v1/audio`, `/v1/moderations`, `/v1/rerank`, `/v1/search`, `/v1/embeddings`, `/v1/responses`, `/v1/relay/*`) | 8 | Bearer auth + per-key rate limit + audit log + cost budget | TPM/TPD fairness (DEBT-001) | +| CLIENT_API files (`/v1/files`, `/v1/files/{id}`, `/v1/batches/*`) | 5 | Bearer auth + per-tenant prefix + AV scan absent | DEBT-053 (AV) | +| CLIENT_API combos/me/providers | 3 | Bearer auth + cached | None material | +| CLIENT_API vscode shim | 13 | Token-scoped + per-token rate limit | Token in URL log noise | +| MANAGEMENT (`/api/settings/*`, `/api/keys/*`, `/api/quota/*`, `/api/usage/*`) | ~60 | Manage-scope + dashboard session + 2FA suggested | See SECURITY.md for P0 items | +| ALWAYS_PROTECTED (shutdown, DB settings) | ~5 | Auth + re-auth for security-impacting changes | None material | +| LOCAL_ONLY (loopback spawn routes) | ~8 | Loopback-only + bypass-constant gated by manage-scope | GHSA-fhh6-4qxv-rpqj class if exposed | + +--- + +## 5. Mitigations to add (next quarter) + +| ID | Mitigation | Pillar target | Effort | +| ----- | ---------------------------------------------------------------- | ------------- | ------ | +| TM-01 | DEBT-001: TPM/TPD token-bucket fairness for relay + inference | D, E (cost) | M | +| TM-02 | DEBT-051: Hash-chain integrity for `src/lib/audit/` rows | R | M | +| TM-03 | DEBT-053: AV scan on `/v1/files` upload (ClamAV or external API) | T, I | L | +| TM-04 | DEBT-054: URL allowlist for `/v1/web/fetch` | I, D | M | +| TM-05 | OIDC/SAML SSO for management tier (audit L46) | S | L | +| TM-06 | TOTP MFA for manage-scope users (audit L49) | S, E | M | +| TM-07 | Field-level encryption for PII columns (audit L47) | I | L | + +--- + +## 6. Review log + +| Date | Reviewer | Change | +| -------------------- | ------------------------- | ---------------------------------------------------------------------- | +| 2026-06-18 | @KooshaPari/core (L5-118) | Initial STRIDE per endpoint, top-20 highest-risk routes + tier summary | +| 2026-06-25 (planned) | security-circle | Re-score after DEBT-001, DEBT-051 close-outs | +| 2026-09-18 (planned) | security-circle | Quarterly full re-review; refresh mitigations list | diff --git a/docs/architecture/CODEBASE_DOCUMENTATION.md b/docs/architecture/CODEBASE_DOCUMENTATION.md index 12c822ade3..4340edd251 100644 --- a/docs/architecture/CODEBASE_DOCUMENTATION.md +++ b/docs/architecture/CODEBASE_DOCUMENTATION.md @@ -755,7 +755,7 @@ See [RESILIENCE_GUIDE.md](./RESILIENCE_GUIDE.md) and the dedicated section in 4. If management-only: add the path to `src/shared/constants/publicApiRoutes.ts` (denylist for the public API surface). 5. Add tests under `tests/unit/`. -6. Update `docs/reference/API_REFERENCE.md` and `docs/reference/openapi.yaml`. +6. Update `docs/reference/API_REFERENCE.md` and `docs/openapi.yaml`. ### Add a new DB module diff --git a/docs/architecture/QUALITY_GATES.md b/docs/architecture/QUALITY_GATES.md index cd41b1ad2c..89b3890658 100644 --- a/docs/architecture/QUALITY_GATES.md +++ b/docs/architecture/QUALITY_GATES.md @@ -139,7 +139,7 @@ These run on a cron schedule (and `workflow_dispatch`), never on PRs. All are ad | `nightly-property` | fast-check property tests with a random seed + high run count | **Advisory** | | `nightly-resilience` | heap-growth gate, chaos fault-injection, k6 load/soak | **Advisory** | | `nightly-llm-security` | promptfoo injection guard (block mode) + garak probes (skipped without a provider secret) | **Advisory** | -| `nightly-schemathesis` | OpenAPI contract fuzzing (schemathesis) against a live OmniRoute using `docs/reference/openapi.yaml` — surfaces spec violations / unhandled 500s (Fase 8 B.4) | **Advisory** | +| `nightly-schemathesis` | OpenAPI contract fuzzing (schemathesis) against a live OmniRoute using `docs/openapi.yaml` — surfaces spec violations / unhandled 500s (Fase 8 B.4) | **Advisory** | --- diff --git a/docs/compression/COMPRESSION_GUIDE.md b/docs/compression/COMPRESSION_GUIDE.md index 5655aea48e..1665cfef7e 100644 --- a/docs/compression/COMPRESSION_GUIDE.md +++ b/docs/compression/COMPRESSION_GUIDE.md @@ -278,7 +278,7 @@ Every compressed request includes stats in the server logs: | Phase 1 | Off, Lite | ✅ Shipped | | Phase 2 | Standard, Aggressive, Ultra | ✅ Shipped | | Phase 3 | RTK, Stacked, Compression Combos | ✅ Shipped | -| Phase 4 | Per-model adaptive, ML-based pruning | 🗓️ Planned | +| Phase 4 | Output Styles, SLM-tier Ultra, adaptive context-budget, eval harness | ✅ Shipped | --- diff --git a/docs/frameworks/AGENT-SKILLS.md b/docs/frameworks/AGENT-SKILLS.md index ab787b9c62..b76e0a7e02 100644 --- a/docs/frameworks/AGENT-SKILLS.md +++ b/docs/frameworks/AGENT-SKILLS.md @@ -31,7 +31,7 @@ src/shared/constants/agentSkills.ts — 42-entry curated list (name/desc/cate src/lib/agentSkills/ catalog.ts — getCatalog(), getSkillById(), filterCatalog(), computeCoverage() generator.ts — generateAgentSkills() writes SKILL.md to skills/{id}/ - openapiParser.ts — extracts REST endpoints from docs/reference/openapi.yaml + openapiParser.ts — extracts REST endpoints from docs/openapi.yaml cliRegistryParser.ts — extracts CLI subcommands from bin/cli-registry.ts schemas.ts — Zod schemas: AgentSkillSchema, SkillCoverageSchema, etc. types.ts — TypeScript interfaces: AgentSkill, SkillCoverage, etc. diff --git a/docs/frameworks/AGENTBRIDGE.md b/docs/frameworks/AGENTBRIDGE.md index ecf024be0e..29cc6b8ce5 100644 --- a/docs/frameworks/AGENTBRIDGE.md +++ b/docs/frameworks/AGENTBRIDGE.md @@ -448,7 +448,7 @@ Base path: `/api/tools/agent-bridge/` | POST | `/api/tools/agent-bridge/upstream-ca/test` | Validate-only (dry-run) an upstream CA path — does not persist | | GET / POST / DELETE | `/api/tools/agent-bridge/tproxy` | TPROXY transparent-decrypt capture mode — see [`docs/security/MITM-TPROXY-DECRYPT.md`](../security/MITM-TPROXY-DECRYPT.md) | -Full OpenAPI schemas: `docs/reference/openapi.yaml` → tag `AgentBridge`. +Full OpenAPI schemas: `docs/openapi.yaml` → tag `AgentBridge`. --- diff --git a/docs/frameworks/EMBEDDED-SERVICES.md b/docs/frameworks/EMBEDDED-SERVICES.md index 25daa4f7c9..6a8042c990 100644 --- a/docs/frameworks/EMBEDDED-SERVICES.md +++ b/docs/frameworks/EMBEDDED-SERVICES.md @@ -623,7 +623,7 @@ If the embedded service exposes an OpenAI-compatible `/v1/chat/completions` endp table in §1 and any new endpoints to §4. 2. Add unit tests in `tests/unit/services/` (lifecycle, installer, API shape). 3. Add integration test in `tests/integration/services/` (behind `RUN_SERVICES_INT=1`). -4. Update `docs/reference/openapi.yaml` with the new endpoints. +4. Update `docs/openapi.yaml` with the new endpoints. --- @@ -791,5 +791,5 @@ the most recent lines within the `tail` limit. Logs are not persisted to disk un - `docs/security/ROUTE_GUARD_TIERS.md` — LOCAL_ONLY tier details - `docs/architecture/CODEBASE_DOCUMENTATION.md` — §3.2 Embedded Services module mapping - `docs/architecture/ARCHITECTURE.md` — system-level context -- `docs/reference/openapi.yaml` — machine-readable endpoint definitions +- `docs/openapi.yaml` — machine-readable endpoint definitions - `CLAUDE.md` §"Adding a New Embedded Service" — quick-reference checklist diff --git a/docs/frameworks/TRAFFIC_INSPECTOR.md b/docs/frameworks/TRAFFIC_INSPECTOR.md index eff615c830..9b88442ca0 100644 --- a/docs/frameworks/TRAFFIC_INSPECTOR.md +++ b/docs/frameworks/TRAFFIC_INSPECTOR.md @@ -477,4 +477,4 @@ Base path: `/api/tools/traffic-inspector/` |--------|------|-------------| | POST | `/internal/ingest` | Accepts intercepted request from `server.cjs` passthrough path; requires `INSPECTOR_INTERNAL_INGEST_TOKEN` header | -Full OpenAPI schemas: `docs/reference/openapi.yaml` → tag `Traffic Inspector`. +Full OpenAPI schemas: `docs/openapi.yaml` → tag `Traffic Inspector`. diff --git a/docs/guides/REMOTE-MODE.md b/docs/guides/REMOTE-MODE.md index dbec59c0c7..63f0bb952e 100644 --- a/docs/guides/REMOTE-MODE.md +++ b/docs/guides/REMOTE-MODE.md @@ -284,4 +284,4 @@ omniroute contexts remove 192-168-0-15 --yes # drop the local context (even if | POST | `/api/cli/tokens` | access token | admin | | DELETE | `/api/cli/tokens/:id` | access token | admin | -See [openapi.yaml](../reference/openapi.yaml) for full schemas. +See [openapi.yaml](../openapi.yaml) for full schemas. diff --git a/docs/i18n/ar/CHANGELOG.md b/docs/i18n/ar/CHANGELOG.md index 103912cc7c..3fb1aaae7c 100644 --- a/docs/i18n/ar/CHANGELOG.md +++ b/docs/i18n/ar/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/az/CHANGELOG.md b/docs/i18n/az/CHANGELOG.md index 6eb006d22f..befb8a4bdd 100644 --- a/docs/i18n/az/CHANGELOG.md +++ b/docs/i18n/az/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/bg/CHANGELOG.md b/docs/i18n/bg/CHANGELOG.md index 6eb006d22f..befb8a4bdd 100644 --- a/docs/i18n/bg/CHANGELOG.md +++ b/docs/i18n/bg/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/bn/CHANGELOG.md b/docs/i18n/bn/CHANGELOG.md index 739c7da81b..4f51520a96 100644 --- a/docs/i18n/bn/CHANGELOG.md +++ b/docs/i18n/bn/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/cs/CHANGELOG.md b/docs/i18n/cs/CHANGELOG.md index f88d500299..4b1318a093 100644 --- a/docs/i18n/cs/CHANGELOG.md +++ b/docs/i18n/cs/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/da/CHANGELOG.md b/docs/i18n/da/CHANGELOG.md index 578925e232..fb1402cf2a 100644 --- a/docs/i18n/da/CHANGELOG.md +++ b/docs/i18n/da/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/de/CHANGELOG.md b/docs/i18n/de/CHANGELOG.md index 34e6ddf822..5282b4d980 100644 --- a/docs/i18n/de/CHANGELOG.md +++ b/docs/i18n/de/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/es/CHANGELOG.md b/docs/i18n/es/CHANGELOG.md index 38f35f9fb9..09c3fcd90f 100644 --- a/docs/i18n/es/CHANGELOG.md +++ b/docs/i18n/es/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/fa/CHANGELOG.md b/docs/i18n/fa/CHANGELOG.md index 8c6186898d..5e6d805209 100644 --- a/docs/i18n/fa/CHANGELOG.md +++ b/docs/i18n/fa/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/fi/CHANGELOG.md b/docs/i18n/fi/CHANGELOG.md index f820a06f1e..c834444ed4 100644 --- a/docs/i18n/fi/CHANGELOG.md +++ b/docs/i18n/fi/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/fr/CHANGELOG.md b/docs/i18n/fr/CHANGELOG.md index 3d040ada91..89e5fffecb 100644 --- a/docs/i18n/fr/CHANGELOG.md +++ b/docs/i18n/fr/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/gu/CHANGELOG.md b/docs/i18n/gu/CHANGELOG.md index fd2d6ebac7..bf599d7ca2 100644 --- a/docs/i18n/gu/CHANGELOG.md +++ b/docs/i18n/gu/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/he/CHANGELOG.md b/docs/i18n/he/CHANGELOG.md index 5c12c9ff6d..2ababd3676 100644 --- a/docs/i18n/he/CHANGELOG.md +++ b/docs/i18n/he/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/hi/CHANGELOG.md b/docs/i18n/hi/CHANGELOG.md index 424762243b..0994d0bc6d 100644 --- a/docs/i18n/hi/CHANGELOG.md +++ b/docs/i18n/hi/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/hu/CHANGELOG.md b/docs/i18n/hu/CHANGELOG.md index f5d01b81e6..24a4e835ae 100644 --- a/docs/i18n/hu/CHANGELOG.md +++ b/docs/i18n/hu/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/id/CHANGELOG.md b/docs/i18n/id/CHANGELOG.md index 3e241d703a..aeff9316ab 100644 --- a/docs/i18n/id/CHANGELOG.md +++ b/docs/i18n/id/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/in/CHANGELOG.md b/docs/i18n/in/CHANGELOG.md index 5551f4c104..e69e52b781 100644 --- a/docs/i18n/in/CHANGELOG.md +++ b/docs/i18n/in/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/it/CHANGELOG.md b/docs/i18n/it/CHANGELOG.md index 9f6eec413b..bb5efc0ede 100644 --- a/docs/i18n/it/CHANGELOG.md +++ b/docs/i18n/it/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/ja/CHANGELOG.md b/docs/i18n/ja/CHANGELOG.md index d7e7dff8e4..1d899e787d 100644 --- a/docs/i18n/ja/CHANGELOG.md +++ b/docs/i18n/ja/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/ko/CHANGELOG.md b/docs/i18n/ko/CHANGELOG.md index 8db5a1c736..edb856a759 100644 --- a/docs/i18n/ko/CHANGELOG.md +++ b/docs/i18n/ko/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/mr/CHANGELOG.md b/docs/i18n/mr/CHANGELOG.md index 02decd4d48..6aadafcb8b 100644 --- a/docs/i18n/mr/CHANGELOG.md +++ b/docs/i18n/mr/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/ms/CHANGELOG.md b/docs/i18n/ms/CHANGELOG.md index 9127c8b702..714553f8b6 100644 --- a/docs/i18n/ms/CHANGELOG.md +++ b/docs/i18n/ms/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/nl/CHANGELOG.md b/docs/i18n/nl/CHANGELOG.md index 59b26ce4c7..c4009f11c9 100644 --- a/docs/i18n/nl/CHANGELOG.md +++ b/docs/i18n/nl/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/no/CHANGELOG.md b/docs/i18n/no/CHANGELOG.md index 569dd5f6fa..d10ce083b8 100644 --- a/docs/i18n/no/CHANGELOG.md +++ b/docs/i18n/no/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/phi/CHANGELOG.md b/docs/i18n/phi/CHANGELOG.md index 66de70d787..659de62030 100644 --- a/docs/i18n/phi/CHANGELOG.md +++ b/docs/i18n/phi/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/pl/CHANGELOG.md b/docs/i18n/pl/CHANGELOG.md index 32d87f6973..cd2a237e0d 100644 --- a/docs/i18n/pl/CHANGELOG.md +++ b/docs/i18n/pl/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/pt-BR/CHANGELOG.md b/docs/i18n/pt-BR/CHANGELOG.md index 753496e05d..867ad9df5e 100644 --- a/docs/i18n/pt-BR/CHANGELOG.md +++ b/docs/i18n/pt-BR/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/pt/CHANGELOG.md b/docs/i18n/pt/CHANGELOG.md index 226f172cc9..f6389d6d11 100644 --- a/docs/i18n/pt/CHANGELOG.md +++ b/docs/i18n/pt/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/ro/CHANGELOG.md b/docs/i18n/ro/CHANGELOG.md index 61c0fd11fc..2cf563eaa1 100644 --- a/docs/i18n/ro/CHANGELOG.md +++ b/docs/i18n/ro/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/ru/CHANGELOG.md b/docs/i18n/ru/CHANGELOG.md index 47f0666baf..a472d9f1b6 100644 --- a/docs/i18n/ru/CHANGELOG.md +++ b/docs/i18n/ru/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/sk/CHANGELOG.md b/docs/i18n/sk/CHANGELOG.md index 25e7e2a022..e880533c56 100644 --- a/docs/i18n/sk/CHANGELOG.md +++ b/docs/i18n/sk/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/sv/CHANGELOG.md b/docs/i18n/sv/CHANGELOG.md index 91e351bcbc..a4bbbad3cb 100644 --- a/docs/i18n/sv/CHANGELOG.md +++ b/docs/i18n/sv/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/sw/CHANGELOG.md b/docs/i18n/sw/CHANGELOG.md index 4276520d65..ad39acd71a 100644 --- a/docs/i18n/sw/CHANGELOG.md +++ b/docs/i18n/sw/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/ta/CHANGELOG.md b/docs/i18n/ta/CHANGELOG.md index 0ebe6abe6f..88ec475e41 100644 --- a/docs/i18n/ta/CHANGELOG.md +++ b/docs/i18n/ta/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/te/CHANGELOG.md b/docs/i18n/te/CHANGELOG.md index a79ddea747..967431a1fb 100644 --- a/docs/i18n/te/CHANGELOG.md +++ b/docs/i18n/te/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/th/CHANGELOG.md b/docs/i18n/th/CHANGELOG.md index 84af575dd2..e6db8ab2b9 100644 --- a/docs/i18n/th/CHANGELOG.md +++ b/docs/i18n/th/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/tr/CHANGELOG.md b/docs/i18n/tr/CHANGELOG.md index 21b097a57c..246f00f4cf 100644 --- a/docs/i18n/tr/CHANGELOG.md +++ b/docs/i18n/tr/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/uk-UA/CHANGELOG.md b/docs/i18n/uk-UA/CHANGELOG.md index 1b245aab5b..a5982c4203 100644 --- a/docs/i18n/uk-UA/CHANGELOG.md +++ b/docs/i18n/uk-UA/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/ur/CHANGELOG.md b/docs/i18n/ur/CHANGELOG.md index 03f94dbb09..c18abff9a6 100644 --- a/docs/i18n/ur/CHANGELOG.md +++ b/docs/i18n/ur/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/vi/CHANGELOG.md b/docs/i18n/vi/CHANGELOG.md index 4f9a8084b8..93f997a436 100644 --- a/docs/i18n/vi/CHANGELOG.md +++ b/docs/i18n/vi/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/i18n/zh-CN/CHANGELOG.md b/docs/i18n/zh-CN/CHANGELOG.md index 231023c17d..d6b101062d 100644 --- a/docs/i18n/zh-CN/CHANGELOG.md +++ b/docs/i18n/zh-CN/CHANGELOG.md @@ -6,6 +6,36 @@ ## [3.8.31] — 2026-06-20 +## [3.8.35] — 2026-06-23 + +### ✨ New Features + +- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract. +- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw) + +### 🔧 Bug Fixes + +- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw). +- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw). +- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). +- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). +- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). +- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). + +### 📝 Maintenance + +- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw) +- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw). +- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw). +- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari). +- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn). +- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw). +- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw). + +--- + ## [3.8.34] — 2026-06-23 ### ✨ New Features diff --git a/docs/reference/openapi.yaml b/docs/openapi.yaml similarity index 99% rename from docs/reference/openapi.yaml rename to docs/openapi.yaml index d1114dcfa8..ca00d23623 100644 --- a/docs/reference/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.8.34 + version: 3.8.35 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, diff --git a/docs/ops/DOCUMENTATION_AUDIT_REPORT.md b/docs/ops/DOCUMENTATION_AUDIT_REPORT.md index 8155dd5a21..7204713364 100644 --- a/docs/ops/DOCUMENTATION_AUDIT_REPORT.md +++ b/docs/ops/DOCUMENTATION_AUDIT_REPORT.md @@ -84,7 +84,7 @@ lastUpdated: 2026-06-13 - **Como funciona:** `docs//*.md` → `source.config.ts` (globs) → `.source/server.ts` (gerado) → `src/lib/source.ts` → `src/app/docs/layout.tsx` (sidebar = `pageTree` dos `meta.json`) → `[...slug]/page.tsx`. **60 docs em inglês** entram no site. - **Navegação curada por `meta.json`** → arquivo novo em `/docs` **não aparece** até ser adicionado manualmente ao `meta.json` da seção. Hoje há 4 arquivos importados mas fora da sidebar (acima). - **i18n no site:** `[...slug]/page.tsx` lê cookie `NEXT_LOCALE`; se ≠ en, tenta `docs/i18n//docs//.md` via `marked.parse()`, com fallback para o MDX inglês. Seletor: `LanguageSelector.tsx` (40 idiomas em `LANGUAGES`). -- **API Explorer:** `openapi.generated.ts` é gerado por `scripts/docs/gen-openapi-module.mjs` a partir de `docs/reference/openapi.yaml` no `prebuild:docs`. +- **API Explorer:** `openapi.generated.ts` é gerado por `scripts/docs/gen-openapi-module.mjs` a partir de `docs/openapi.yaml` no `prebuild:docs`. - **Riscos de drift:** (a) `meta.json` manual; (b) traduções não atualizam quando o inglês muda; (c) `openapi.yaml` precisa de regen; (d) `LANGUAGES` no app diz 40, config diz 42 → **divergência app vs config**. ### 2.4 Wiki do GitHub (`/wiki`) — **mais defasada de todas** diff --git a/docs/ops/RELEASE_CHECKLIST.md b/docs/ops/RELEASE_CHECKLIST.md index c0e7546997..fffe182769 100644 --- a/docs/ops/RELEASE_CHECKLIST.md +++ b/docs/ops/RELEASE_CHECKLIST.md @@ -52,7 +52,7 @@ npm run test:e2e # optional but recommended - [ ] Manually review CHANGELOG.md and clean up commit messages if needed - [ ] Ensure the latest semver section in `CHANGELOG.md` equals `package.json` version - [ ] Keep `## [Unreleased]` as the first changelog section for upcoming work -- [ ] Update `docs/reference/openapi.yaml` → `info.version` must equal `package.json` version +- [ ] Update `docs/openapi.yaml` → `info.version` must equal `package.json` version ### Code Quality @@ -104,7 +104,7 @@ Breaking changes: add `BREAKING CHANGE:` footer or `!` after the scope (e.g. `fe - [ ] `docs/guides/TROUBLESHOOTING.md` reviewed for env var and operational drift - [ ] If `.env.example` changed: `docs/reference/ENVIRONMENT.md` updated - [ ] If new feature has a UI: `docs/guides/USER_GUIDE.md` mentions it -- [ ] If new feature has API: `docs/reference/API_REFERENCE.md` + `docs/reference/openapi.yaml` updated +- [ ] If new feature has API: `docs/reference/API_REFERENCE.md` + `docs/openapi.yaml` updated - [ ] If new feature is a module: dedicated `docs/.md` exists - [ ] If breaking change: `docs/guides/TROUBLESHOOTING.md` has migration note diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index ce1f812143..ffebb7a27d 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -643,7 +643,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | `CALL_LOG_RETENTION_DAYS` | `7` | Days to keep request/call log entries in the database. | | `CALL_LOG_MAX_ENTRIES` | `10000` | Max call log entries in the in-memory buffer. | | `CALL_LOGS_TABLE_MAX_ROWS` | `100000` | Max rows in the `call_logs` SQLite table before pruning. | -| `MAX_PENDING_REQUEST_AGE_MS` | `3600000` (1 hour) | Max age for orphaned active request log entries before in-memory cleanup. | +| `MAX_PENDING_REQUEST_AGE_MS` | `3600000` (1 hour) | Max age for orphaned active request log entries before in-memory cleanup. | | `CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS` | `true` | Store stream chunks in pipeline artifacts when `call_log_pipeline_enabled=true`. | | `CALL_LOG_PIPELINE_MAX_SIZE_KB` | `512` | Max pipeline call log artifact size in KB when `call_log_pipeline_enabled=true`. | | `PROXY_LOGS_TABLE_MAX_ROWS` | `100000` | Max rows in the `proxy_logs` SQLite table before pruning. | @@ -968,6 +968,7 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `QUOTA_SOFT_DEPRIORITIZE_FACTOR` | `0.7` | `open-sse/services/combo.ts` | Score multiplier (0..1) applied to a target when the soft quota policy deprioritizes it. | | `QUOTA_CONSUMPTION_RETENTION_DAYS` | `14` | `src/lib/db/quotaConsumption.ts` | Retention window (days) for `quota_consumption` buckets before GC (`gcQuotaConsumption`). | | `QUOTA_PREFLIGHT_CUTOFF_ENABLED` | `false` | `src/lib/resilience/settings.ts` | Opt-in (default OFF): enables the auto-routing hard quota cutoff that drops low-quota candidates before scoring. | +| `OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL` | `false` | `open-sse/services/autoCombo/virtualFactory.ts` | Opt-in (default OFF): when an `auto/:` filter matches no connected candidates, restore the legacy behavior of falling back to the full (unfiltered) pool instead of returning an empty pool. Default OFF makes `:free` mean "free tier only". | | `AGENTBRIDGE_UPSTREAM_CA_CERT` | _(unset)_ | `src/mitm/manager.ts` | Extra CA certificate (PEM) trusted for AgentBridge upstream TLS connections. | | `INSPECTOR_BUFFER_SIZE` | `1000` | `src/mitm/inspector/buffer.ts` | Max captured requests held in the Traffic Inspector ring buffer. | | `INSPECTOR_MAX_BODY_KB` | `1024` | `src/mitm/inspector/buffer.ts` | Max captured request/response body size (KB) before truncation. | @@ -1081,9 +1082,3 @@ is developer tooling only. | `OMNIROUTE_URL` | `http://localhost:20128` | `scripts/ad-hoc/regen-opencode-config.ts` | Base URL of the OmniRoute instance to query for `/v1/models`. | | `OMNIROUTE_KEY` | _(unset)_ | `scripts/ad-hoc/regen-opencode-config.ts` | API key to authenticate against the OmniRoute `/v1/models` endpoint. Falls back to `OPENCODE_API_KEY` when unset. | | `OPENCODE_API_KEY` | _(unset)_ | `scripts/ad-hoc/regen-opencode-config.ts` | OpenCode-style API key (`sk-...`) written into the regenerated `opencode.json`. Falls back to `OMNIROUTE_KEY` when unset. | - -| Variable | Default | Source File | Description | -| --------------------------------- | ------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `OMNIROUTE_VACUUM_ENABLED` | `1` | `src/lib/db/vacuumScheduler.ts` | Master switch for the scheduled SQLite VACUUM job. When `1` (default), starts a `setInterval` timer at boot that runs `VACUUM` once every `OMNIROUTE_VACUUM_INTERVAL_HOURS`. When `0`, disables the scheduler entirely (manual "Vacuum Now" still works). | -| `OMNIROUTE_VACUUM_INTERVAL_HOURS` | `24` | `src/lib/db/vacuumScheduler.ts` | How often to run scheduled `VACUUM`, in hours. Minimum `1`. Must be an integer. Changes require a restart (or `omniroute vacuum restart`). | -| `OMNIROUTE_VACUUM_WINDOW` | `02:00-04:00` | `src/lib/db/vacuumScheduler.ts` | Local-time window during which the first VACUUM is allowed to run, in `HH:MM-HH:MM` (24-hour) format. Outside the window the scheduler waits until the window opens. Setting to `00:00-23:59` disables the window check. | diff --git a/docs/security/ROUTE_GUARD_TIERS.md b/docs/security/ROUTE_GUARD_TIERS.md index 4a4329eed5..da7eb1d0c2 100644 --- a/docs/security/ROUTE_GUARD_TIERS.md +++ b/docs/security/ROUTE_GUARD_TIERS.md @@ -136,7 +136,7 @@ expired DB doesn't silently downgrade to "deny". ## Documenting Security Tiers in OpenAPI -When adding a new route to `docs/reference/openapi.yaml`, apply the corresponding +When adding a new route to `docs/openapi.yaml`, apply the corresponding vendor extension if the route is classified by `routeGuard.ts`: | routeGuard.ts classification | YAML annotation | Enforcement | diff --git a/electron/package-lock.json b/electron/package-lock.json index 89d9bf9a65..fd59e11874 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute-desktop", - "version": "3.8.34", + "version": "3.8.35", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute-desktop", - "version": "3.8.34", + "version": "3.8.35", "license": "MIT", "dependencies": { "electron-updater": "^6.8.9" diff --git a/electron/package.json b/electron/package.json index a063e9a03e..c66f0d6b51 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.8.34", + "version": "3.8.35", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/open-sse/executors/deepseek-web.ts b/open-sse/executors/deepseek-web.ts index 4e2546dce1..c9b81ddad7 100644 --- a/open-sse/executors/deepseek-web.ts +++ b/open-sse/executors/deepseek-web.ts @@ -511,13 +511,14 @@ function extractMessageText(content: unknown): string { * is the last line of the transcript. */ export function messagesToPrompt( - messages: Array<{ role: string; content: string }>, + messages: Array<{ role: string; content: string; tool_call_id?: string; name?: string }>, historyWindow = 0 ): string { if (messages.length === 0) return ""; const systemParts: string[] = []; const conversation: Array<{ role: string; text: string }> = []; + const callNameById = new Map(); let lastUserContent = ""; for (const m of messages) { const text = extractMessageText(m.content).trim(); @@ -526,6 +527,22 @@ export function messagesToPrompt( } else if (m.role === "user" || m.role === "assistant") { if (text) conversation.push({ role: m.role, text }); if (m.role === "user") lastUserContent = text; + const calls = Array.isArray((m as { tool_calls?: unknown }).tool_calls) + ? (m as { tool_calls: Array<{ id?: string; function?: { name?: string } }> }).tool_calls + : []; + for (const c of calls) { + if (c?.id && typeof c.function?.name === "string") callNameById.set(c.id, c.function.name); + } + } else if (m.role === "tool") { + // Tool results have no native slot in deepseek-web's single-prompt format. Without + // this branch they were silently dropped (#4712) — the model never saw the tool + // output and either re-called the tool endlessly or answered "I don't have that + // information". Fold them into the transcript as plain text, mirroring the agentic + // buildToolConversationPrompt() path. + if (text) { + const name = (m.tool_call_id && callNameById.get(m.tool_call_id)) || m.name || "tool"; + conversation.push({ role: "tool", text: `(${name}) ${text}` }); + } } } @@ -538,7 +555,13 @@ export function messagesToPrompt( // Rolling-window transcript of the most recent turns (#2942). const recent = conversation.slice(-historyWindow); const transcript = recent - .map((turn) => `${turn.role === "assistant" ? "Assistant" : "User"}: ${turn.text}`) + .map((turn) => + turn.role === "assistant" + ? `Assistant: ${turn.text}` + : turn.role === "tool" + ? `Tool result ${turn.text}` + : `User: ${turn.text}` + ) .join("\n\n"); parts.push(transcript); } else if (lastUserContent) { diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 6268c77d6f..2344be85c5 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -6,14 +6,13 @@ export { extractSystemRoleMessages } from "./chatCore/claudeSystemRole.ts"; import { checkIdempotencyCache } from "./chatCore/idempotency.ts"; import { checkSemanticCache } from "./chatCore/semanticCache.ts"; import { sanitizeChatRequestBody } from "./chatCore/sanitization.ts"; -import { cloneBoundedChatLogPayload, truncateForLog } from "./chatCore/logTruncation.ts"; import { getHeaderValueCaseInsensitive, isNoMemoryRequested, resolveCompressionHeader, } from "./chatCore/headers.ts"; import { markCodexScopeRateLimited } from "./chatCore/codexFailover.ts"; -import { getCombosCached, getUpstreamProxyConfigCached } from "./chatCore/comboContextCache.ts"; +import { getCombosCached } from "./chatCore/comboContextCache.ts"; export { clearCombosCache, clearUpstreamProxyConfigCache } from "./chatCore/comboContextCache.ts"; import { resolveAccountSemaphoreKey, @@ -107,7 +106,6 @@ import { COOLDOWN_MS, HTTP_STATUS, FETCH_BODY_TIMEOUT_MS, - MAX_TOOLS_LIMIT, PROVIDER_MAX_TOKENS, SSE_HEARTBEAT_INTERVAL_MS, STREAM_IDLE_TIMEOUT_MS, @@ -126,6 +124,27 @@ import { updateProviderConnection, getProviderConnectionById } from "@/lib/db/pr import { wasRefreshTokenRotated } from "@omniroute/open-sse/services/refreshSerializer.ts"; import { connectionHasExtraKeys } from "../services/apiKeyRotator.ts"; import { recordKeyHealthStatus as recordKeyHealthStatusFor } from "./chatCore/keyHealth.ts"; +import { getSkillsModelIdForFormat } from "./chatCore/skillsFormat.ts"; +import { readNonStreamingResponseBody } from "./chatCore/nonStreamingResponseBody.ts"; +import { + isSemaphoreCapacityError, + createStreamingErrorResult, + getUpstreamErrorIdentifier, +} from "./chatCore/streamErrorResult.ts"; +import { wrapReadableStreamWithFinalize } from "./chatCore/streamFinalize.ts"; +import { buildCacheUsageLogMeta } from "./chatCore/cacheUsageMeta.ts"; +import { buildExecutorClientHeaders } from "./chatCore/executorClientHeaders.ts"; +import { resolveExecutionCredentials as resolveExecutionCredentialsFor } from "./chatCore/executionCredentials.ts"; +import { resolveExecutorWithProxy as resolveExecutorWithProxyFor } from "./chatCore/executorProxy.ts"; +import type { ClaudeMessage } from "./chatCore/claudeMessageTypes.ts"; +import { normalizeClaudeUpstreamMessages as normalizeClaudeUpstreamMessagesFor } from "./chatCore/claudeUpstreamMessages.ts"; +import { + persistAttemptLogs as persistAttemptLogsFor, + type PersistAttemptLogsArgs, +} from "./chatCore/attemptLogging.ts"; +import { stageTrace } from "./chatCore/stageTrace.ts"; +import { attachCompressionUsageReceiptAfterAnalytics as attachCompressionUsageReceiptAfterAnalyticsFor } from "./chatCore/compressionUsageReceipt.ts"; +import { prepareUpstreamBody } from "./chatCore/upstreamBody.ts"; import { getCallLogPipelineCaptureStreamChunks, @@ -133,18 +152,11 @@ import { } from "@/lib/logEnv"; import { logAuditEvent } from "@/lib/compliance"; import { emit } from "@/lib/events/eventBus"; -import { extractProviderWarnings } from "@/lib/compliance/providerAudit"; import { adaptBodyForCompression } from "../services/compression/bodyAdapter.ts"; import { ensureEngineBreakdown } from "../services/compression/engineBreakdown.ts"; import { handleBypassRequest } from "../utils/bypassHandler.ts"; -import { - saveRequestUsage, - trackPendingRequest, - appendRequestLog, - saveCallLog, -} from "@/lib/usageDb"; +import { saveRequestUsage, trackPendingRequest, appendRequestLog } from "@/lib/usageDb"; import { finalizePendingScope, updatePendingScope } from "@/lib/usage/pendingRequestScope"; -import { formatUsageLog } from "@/lib/usage/tokenAccounting"; import { recordCost } from "@/domain/costRules"; import { calculateCost } from "@/lib/usage/costCalculator"; import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta"; @@ -153,13 +165,21 @@ import { restoreClaudePassthroughToolNames, mergeResponseToolNameMap, } from "./chatCore/passthroughToolNames.ts"; +import { recordContextEditingTelemetryHook } from "./chatCore/contextEditingTelemetry.ts"; +import { recordCompressionCacheStats } from "./chatCore/compressionCacheStats.ts"; +import { writeCavemanOutputAnalytics } from "./chatCore/cavemanOutputAnalytics.ts"; +import { scheduleQuotaShareConsumption } from "./chatCore/quotaShareConsumption.ts"; +import { emitRequestGamificationEvent } from "./chatCore/gamificationEvent.ts"; +import { runPluginOnResponseHook } from "./chatCore/pluginOnResponse.ts"; +import { scheduleStreamingQuotaShareConsumption } from "./chatCore/streamingQuotaShare.ts"; +import { recordStreamingUsageStats } from "./chatCore/streamingUsageStats.ts"; +import { recordStreamingCost } from "./chatCore/streamingCost.ts"; import { - parseNonStreamingSSEPayload, - normalizeNonStreamingEventPayload, - shouldTreatBufferedEventResponseAsExpected, appendNonStreamingSseTerminalSignal, type NonStreamingSseTerminalState, } from "./chatCore/nonStreamingSse.ts"; +import { parseNonStreamingResponseBody } from "./chatCore/nonStreamingResponseParse.ts"; +import { recordNonStreamingUsageStats } from "./chatCore/nonStreamingUsageStats.ts"; import { createBodyTimeoutError, readStreamChunkWithTimeout, @@ -170,17 +190,9 @@ import { import { getModelNormalizeToolCallId, getModelPreserveOpenAIDeveloperRole } from "@/lib/localDb"; import { getProviderCredentials, extractSessionAffinityKey } from "@/sse/services/auth"; import { deleteSessionAccountAffinity } from "@/lib/db/sessionAccountAffinity"; -import { getExecutor } from "../executors/index.ts"; import { getCacheControlSettings } from "@/lib/cacheControlSettings"; import { guardrailRegistry, resolveDisabledGuardrails } from "@/lib/guardrails"; -import { - applyConfiguredPayloadRules, - resolvePayloadRuleProtocols, -} from "../services/payloadRules.ts"; -import { - shouldPreserveCacheControl, - providerSupportsCaching, -} from "../utils/cacheControlPolicy.ts"; +import { shouldPreserveCacheControl } from "../utils/cacheControlPolicy.ts"; import { getCachedSettings } from "@/lib/db/readCache"; import { applyCodexGlobalFastServiceTier } from "@/lib/providers/codexFastTier"; import { buildUpstreamHeadersForExecute as buildUpstreamHeadersForExecuteFor } from "./chatCore/upstreamExecuteHeaders.ts"; @@ -192,7 +204,6 @@ import { import { cacheReasoningFromAssistantMessage } from "../services/reasoningCache.ts"; import { sanitizeOpenAITool } from "../services/toolSchemaSanitizer.ts"; import { - getEffectiveToolLimit, setDetectedToolLimit, parseToolLimitFromError, shouldDetectLimit, @@ -203,7 +214,6 @@ import { buildCodexQuotaPersistence } from "./chatCore/codexQuota.ts"; import { invalidateCodexQuotaCache } from "../services/codexQuotaFetcher.ts"; import { translateNonStreamingResponse } from "./responseTranslator.ts"; import { extractUsageFromResponse } from "./usageExtractor.ts"; -import { extractSSEErrorMessage } from "./sseParser.ts"; import { sanitizeOpenAIResponse, sanitizeResponsesApiResponse } from "./responseSanitizer.ts"; import { withRateLimit, @@ -250,7 +260,6 @@ import { stripMarkdownCodeFence, } from "../utils/aiSdkCompat.ts"; import { generateRequestId } from "@/shared/utils/requestId"; -import { normalizePayloadForLog } from "@/lib/logPayloads"; import { extractFacts } from "@/lib/memory/extraction"; import { handleToolCallExecution } from "@/lib/skills/interception"; import { OMNIROUTE_RESPONSE_HEADERS } from "@/shared/constants/headers"; @@ -278,222 +287,6 @@ import { incrementRequestCount } from "../services/geminiRateLimitTracker.ts"; import { isSmallEnoughForSemanticCache } from "../utils/estimateSize.ts"; -function getSkillsProviderForFormat(format: string): "openai" | "anthropic" | "google" | "other" { - switch (format) { - case FORMATS.CLAUDE: - return "anthropic"; - case FORMATS.GEMINI: - return "google"; - default: - return "openai"; - } -} - -function getSkillsModelIdForFormat(format: string): string { - switch (format) { - case FORMATS.CLAUDE: - return "claude"; - case FORMATS.GEMINI: - return "gemini"; - default: - return "openai"; - } -} - -async function readNonStreamingResponseBody( - response: Response, - contentType: string, - upstreamStream: boolean -): Promise { - if ( - !upstreamStream || - !response.body || - (!contentType.includes("text/event-stream") && !contentType.includes("application/x-ndjson")) - ) { - return withBodyTimeout(response.text()); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - const terminalState: NonStreamingSseTerminalState = { - currentEvent: "", - pendingLine: "", - }; - let rawBody = ""; - const deadline = FETCH_BODY_TIMEOUT_MS > 0 ? Date.now() + FETCH_BODY_TIMEOUT_MS : 0; - - try { - while (true) { - const timeoutMs = deadline > 0 ? deadline - Date.now() : 0; - if (deadline > 0 && timeoutMs <= 0) { - throw createBodyTimeoutError(FETCH_BODY_TIMEOUT_MS); - } - - const { done, value } = await readStreamChunkWithTimeout(reader, timeoutMs); - if (done) break; - if (!value) continue; - - const decodedChunk = decoder.decode(value, { stream: true }); - rawBody += decodedChunk; - if (appendNonStreamingSseTerminalSignal(terminalState, decodedChunk)) { - await reader.cancel("non-streaming bridge consumed terminal SSE event").catch(() => {}); - break; - } - } - } catch (error) { - await reader.cancel(error).catch(() => {}); - throw error; - } finally { - rawBody += decoder.decode(); - reader.releaseLock(); - } - - return rawBody; -} - -function isSemaphoreCapacityError(error: unknown): error is Error & { code: string } { - return ( - !!error && - typeof error === "object" && - ((error as { code?: unknown }).code === "SEMAPHORE_TIMEOUT" || - (error as { code?: unknown }).code === "SEMAPHORE_QUEUE_FULL") - ); -} - -function createStreamingErrorResult( - statusCode: number, - message: string, - code?: string, - type?: string -) { - const errorBody = buildErrorBody(statusCode, message); - if (code) { - errorBody.error.code = code; - } - if (type) { - errorBody.error.type = type; - } - - const body = `data: ${JSON.stringify(errorBody)}\n\ndata: [DONE]\n\n`; - - return { - success: false as const, - status: statusCode, - error: message, - response: new Response(body, { - status: statusCode, - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache, no-transform", - Connection: "keep-alive", - "X-Accel-Buffering": "no", - }, - }), - }; -} - -function getUpstreamErrorIdentifier(error: unknown): string | undefined { - if (!error || typeof error !== "object") return undefined; - const value = (error as { code?: unknown }).code; - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -function wrapReadableStreamWithFinalize( - readable: ReadableStream, - finalize: () => void -): ReadableStream { - const reader = readable.getReader(); - let finalized = false; - - const runFinalize = () => { - if (finalized) return; - finalized = true; - finalize(); - }; - - return new ReadableStream({ - async pull(controller) { - try { - const { done, value } = await reader.read(); - if (done) { - runFinalize(); - controller.close(); - return; - } - controller.enqueue(value); - } catch (error) { - runFinalize(); - controller.error(error); - } - }, - - async cancel(reason) { - runFinalize(); - try { - await reader.cancel(reason); - } catch (error) { - // Ignored - } - }, - }); -} - -function toPositiveNumber(value: unknown) { - return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0; -} - -function buildCacheUsageLogMeta(usage: Record | null | undefined) { - if (!usage || typeof usage !== "object") return null; - const promptTokenDetails = - usage.prompt_tokens_details && typeof usage.prompt_tokens_details === "object" - ? (usage.prompt_tokens_details as Record) - : undefined; - const hasCacheFields = - "cache_read_input_tokens" in usage || - "cached_tokens" in usage || - "cache_creation_input_tokens" in usage || - (!!promptTokenDetails && - ("cached_tokens" in promptTokenDetails || "cache_creation_tokens" in promptTokenDetails)); - const cacheReadTokens = toPositiveNumber( - usage.cache_read_input_tokens ?? usage.cached_tokens ?? promptTokenDetails?.cached_tokens - ); - const cacheCreationTokens = toPositiveNumber( - usage.cache_creation_input_tokens ?? promptTokenDetails?.cache_creation_tokens - ); - if (!hasCacheFields) return null; - return { - cacheReadTokens, - cacheCreationTokens, - }; -} - -function attachLogMeta( - payload: Record | null | undefined, - meta: Record | null | undefined -) { - if (!meta || typeof meta !== "object") return payload; - const compactMeta = Object.fromEntries( - Object.entries(meta).filter(([, value]) => value !== null && value !== undefined) - ); - if (Object.keys(compactMeta).length === 0) return payload; - if (!payload || typeof payload !== "object" || Array.isArray(payload)) { - return { _omniroute: compactMeta, _payload: payload ?? null }; - } - const existing = - payload._omniroute && - typeof payload._omniroute === "object" && - !Array.isArray(payload._omniroute) - ? payload._omniroute - : {}; - return { - ...payload, - _omniroute: { - ...existing, - ...compactMeta, - }, - }; -} - /** * Core chat handler - shared between SSE and Worker * Returns { success, response, status, error } for caller to handle fallback @@ -514,33 +307,6 @@ function attachLogMeta( * @param {string} options.connectionId - Connection ID for settings lookup */ -function buildExecutorClientHeaders( - headers: Headers | Record | null | undefined, - userAgent?: string | null -) { - const normalized: Record = {}; - - if (headers instanceof Headers) { - headers.forEach((value, key) => { - normalized[key] = value; - }); - } else if (headers && typeof headers === "object") { - for (const [key, value] of Object.entries(headers)) { - if (typeof value === "string") { - normalized[key] = value; - } - } - } - - const normalizedUserAgent = typeof userAgent === "string" ? userAgent.trim() : ""; - if (normalizedUserAgent && !normalized["user-agent"] && !normalized["User-Agent"]) { - normalized["user-agent"] = normalizedUserAgent; - normalized["User-Agent"] = normalizedUserAgent; - } - - return Object.keys(normalized).length > 0 ? normalized : null; -} - // extractSystemRoleMessages extracted to chatCore/claudeSystemRole.ts (#3501); re-exported above so // existing importers (e.g. tests/unit/system-role-extraction.test.ts) keep resolving it from here. @@ -587,8 +353,10 @@ export async function handleChatCore({ const isModelScope = () => isModelScopeProvider(provider, credentials?.providerSpecificData); const startTime = Date.now(); // Per-request trace id + checkpoint helper. Lets us see exactly which await - // a hung request was sitting on in `[STAGE_TRACE]` log lines. - const traceId = Math.random().toString(36).slice(2, 8); + // a hung request was sitting on in `[STAGE_TRACE]` log lines. Uses crypto RNG + // (not Math.random) purely to satisfy CodeQL js/insecure-randomness — this id + // is a log-correlation token, not a security secret. + const traceId = globalThis.crypto.randomUUID().slice(0, 6); // Emit request.started event for real-time dashboard setImmediate(() => { @@ -601,19 +369,10 @@ export async function handleChatCore({ }); }); const traceEnabled = process.env.OMNIROUTE_TRACE === "true" || process.env.DEBUG === "true"; - const trace = (label: string, extra?: Record) => { - if (!traceEnabled) return; - const elapsed = Date.now() - startTime; - let suffix = ""; - if (extra) { - try { - suffix = ` ${JSON.stringify(extra)}`; - } catch { - suffix = " [unserializable]"; - } - } - log?.info?.("STAGE_TRACE", `${traceId} ${label} t=${elapsed}ms${suffix}`); - }; + // Stage trace extracted to chatCore/stageTrace.ts (#3501); bind the per-request inputs once so the + // call sites stay byte-identical. + const trace = (label: string, extra?: Record) => + stageTrace(label, extra, { traceEnabled, startTime, traceId, log }); let tokensCompressed: number | null = null; body = injectSystemPrompt(body); // ── Plugin onRequest hook ── @@ -916,22 +675,16 @@ export async function handleChatCore({ detailedLoggingEnabled && getCallLogPipelineCaptureStreamChunks(); const skillRequestId = generateRequestId(); let compressionAnalyticsWritePromise: Promise | null = null; + // Compression usage-receipt attachment extracted to chatCore/compressionUsageReceipt.ts (#3501); + // pass the in-flight analytics write + request id so behaviour stays byte-identical. const attachCompressionUsageReceiptAfterAnalytics = ( usage: Record, source: "provider" | "estimated" | "stream" - ) => { - const pendingWrite = compressionAnalyticsWritePromise; - void (async () => { - try { - if (pendingWrite) await pendingWrite; - const { attachCompressionUsageReceipt } = - await import("../../src/lib/db/compressionAnalytics.ts"); - attachCompressionUsageReceipt(skillRequestId, usage, source); - } catch { - // Compression analytics are best-effort and must never affect responses. - } - })(); - }; + ) => + attachCompressionUsageReceiptAfterAnalyticsFor(usage, source, { + pendingWrite: compressionAnalyticsWritePromise, + skillRequestId, + }); const pipelineSessionId = (clientRawRequest?.headers && typeof clientRawRequest.headers.get === "function" ? clientRawRequest.headers.get("x-omniroute-session-id") @@ -939,118 +692,31 @@ export async function handleChatCore({ clientRawRequest?.headers ?? null, "x-omniroute-session-id" )) || skillRequestId; - const persistAttemptLogs = ({ - status, - tokens, - responseBody, - error, - providerRequest, - providerResponse, - clientResponse, - claudeCacheMeta, - claudeCacheUsageMeta, - cacheSource, - }: { - status: number; - tokens?: unknown; - responseBody?: unknown; - error?: string | null; - providerRequest?: unknown; - providerResponse?: unknown; - clientResponse?: unknown; - claudeCacheMeta?: Record; - claudeCacheUsageMeta?: Record; - cacheSource?: "upstream" | "semantic"; - }) => { - const providerWarnings = extractProviderWarnings( - providerResponse, - clientResponse, - responseBody - ); - if (providerWarnings.length > 0) { - logAuditEvent({ - action: "provider.warning", - actor: "system", - target: [provider, connectionId].filter(Boolean).join(":") || provider || model, - resourceType: "provider_warning", - status: "warning", - requestId: skillRequestId, - details: { - provider, - model, - connectionId, - httpStatus: status, - warnings: providerWarnings, - }, - }); - } - - const pipelinePayloads = detailedLoggingEnabled - ? (reqLogger?.getPipelinePayloads?.() ?? {}) - : null; - - if (pipelinePayloads) { - if (providerRequest !== undefined && !pipelinePayloads.providerRequest) { - pipelinePayloads.providerRequest = providerRequest as Record; - } - if (providerResponse !== undefined && !pipelinePayloads.providerResponse) { - pipelinePayloads.providerResponse = providerResponse as Record; - } - if (clientResponse !== undefined) { - pipelinePayloads.clientResponse = clientResponse as Record; - } - if (error) { - pipelinePayloads.error = { - ...(typeof pipelinePayloads.error === "object" && pipelinePayloads.error - ? (pipelinePayloads.error as Record) - : {}), - message: error, - }; - } - } - - saveCallLog({ - id: pendingRequestId, - method: "POST", - path: clientRawRequest?.endpoint || "/v1/chat/completions", - status, - model, - requestedModel, + // persistAttemptLogs extracted to chatCore/attemptLogging.ts (#3501); bind the per-request context + // once so the 16 call sites keep passing only the per-attempt args (byte-identical). + const persistAttemptLogs = (args: PersistAttemptLogsArgs) => + persistAttemptLogsFor(args, { provider, - connectionId: connectionId || credentials?.connectionId || undefined, - duration: Date.now() - startTime, - tokens: tokens || {}, - requestBody: cloneBoundedChatLogPayload( - attachLogMeta(truncateForLog(body as Record), { - claudePromptCache: claudeCacheMeta, - }) - ), - responseBody: cloneBoundedChatLogPayload( - attachLogMeta(truncateForLog(responseBody as Record), { - claudePromptCache: claudeCacheMeta - ? { - applied: claudeCacheMeta.applied, - totalBreakpoints: claudeCacheMeta.totalBreakpoints, - anthropicBeta: claudeCacheMeta.anthropicBeta, - } - : null, - claudePromptCacheUsage: claudeCacheUsageMeta, - }) - ), - error: error || null, + connectionId, + model, + skillRequestId, + detailedLoggingEnabled, + reqLogger, + pendingRequestId, + clientRawRequest, + requestedModel, + credentials, + startTime, + body, sourceFormat, targetFormat, comboName, comboStepId, comboExecutionKey, tokensCompressed, - cacheSource: cacheSource === "semantic" ? "semantic" : "upstream", - apiKeyId: apiKeyInfo?.id || null, - apiKeyName: apiKeyInfo?.name || null, - noLog: noLogEnabled, - pipelinePayloads, - }).catch(() => {}); - }; + apiKeyInfo, + noLogEnabled, + }); // Primary path: merge client model id + alias target so config on either key applies; resolved // id wins on same header name. T5 family fallback uses only (nextModel, resolveModelAlias(next)) @@ -1461,33 +1127,63 @@ export async function handleChatCore({ ); } } - if (config.enabled && config.cavemanOutputMode?.enabled) { + // Phase 4A: unified output styles (supersedes cavemanOutputMode via the back-compat shim). + let outputStyleResult: + | import("../services/compression/outputStyles/apply.ts").OutputStylesResult + | null = null; + if (config.enabled) { try { - const { applyCavemanOutputMode } = await import("../services/compression/outputMode.ts"); - const outputModeLanguage = - config.languageConfig?.enabled === true ? config.languageConfig.defaultLanguage : "en"; - const outputMode = applyCavemanOutputMode( - body as Parameters[0], - config.cavemanOutputMode, - outputModeLanguage - ); - if (outputMode.applied) { - body = outputMode.body as typeof body; - cavemanOutputModeApplied = true; - cavemanOutputModeIntensity = config.cavemanOutputMode.intensity; - estimatedTokens = estimateTokens(body?.messages ?? body?.input ?? []); - log?.debug?.("COMPRESSION", "Caveman output mode instruction applied"); - } else if (outputMode.skippedReason && outputMode.skippedReason !== "disabled") { - log?.debug?.("COMPRESSION", `Caveman output mode skipped: ${outputMode.skippedReason}`); + const { resolveOutputStyleSelection } = + await import("../services/compression/outputStyles/backCompat.ts"); + const selection = resolveOutputStyleSelection(config); + if (selection.length > 0) { + const { applyOutputStyles } = + await import("../services/compression/outputStyles/apply.ts"); + const outputStyleLanguage = + config.languageConfig?.enabled === true + ? config.languageConfig.defaultLanguage + : "en"; + outputStyleResult = applyOutputStyles( + body as Parameters[0], + selection, + outputStyleLanguage + ); + if (outputStyleResult.applied) { + body = outputStyleResult.body as typeof body; + cavemanOutputModeApplied = true; + cavemanOutputModeIntensity = + outputStyleResult.appliedStyles?.map((s) => `${s.id}:${s.level}`).join(",") ?? null; + estimatedTokens = estimateTokens(body?.messages ?? body?.input ?? []); + log?.debug?.("COMPRESSION", "Output styles applied"); + } else if ( + outputStyleResult.skippedReason && + outputStyleResult.skippedReason !== "no_styles" + ) { + log?.debug?.( + "COMPRESSION", + `Output styles skipped: ${outputStyleResult.skippedReason}` + ); + } } } catch (err) { log?.debug?.( "COMPRESSION", - "Caveman output mode skipped: " + (err instanceof Error ? err.message : String(err)) + "Output styles skipped: " + (err instanceof Error ? err.message : String(err)) ); } } const compressionInputBody = body as Record; + // 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, @@ -1495,9 +1191,22 @@ 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 @@ -1578,6 +1287,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); @@ -1670,39 +1380,15 @@ export async function handleChatCore({ } if (result.compressed) { - void (async () => { - try { - const { detectCachingContext } = - await import("../services/compression/cachingAware.ts"); - const { recordCacheStats } = - await import("../../src/lib/db/compressionCacheStats.ts"); - const cacheContext = detectCachingContext(compressionInputBody, { - provider, - targetFormat, - model: effectiveModel, - }); - const tokensSavedCompression = Math.max( - 0, - result.stats.originalTokens - result.stats.compressedTokens - ); - recordCacheStats({ - provider: cacheContext.provider ?? provider ?? "unknown", - model: effectiveModel ?? "", - compressionMode: mode, - cacheControlPresent: cacheContext.hasCacheControl, - estimatedCacheHit: cacheContext.hasCacheControl && cacheContext.isCachingProvider, - tokensSavedCompression, - tokensSavedCaching: 0, - netSavings: tokensSavedCompression, - }); - } catch (err) { - log?.debug?.( - "COMPRESSION", - "Compression cache stats write skipped: " + - (err instanceof Error ? err.message : String(err)) - ); - } - })(); + recordCompressionCacheStats({ + compressionInputBody, + provider, + targetFormat, + effectiveModel, + mode, + stats: result.stats, + log, + }); log?.info?.( "COMPRESSION", `Prompt compressed (${mode}): ${result.stats.originalTokens} -> ${result.stats.compressedTokens} tokens (${result.stats.savingsPercent}% saved, techniques: ${result.stats.techniquesUsed.join(",")})` @@ -1711,28 +1397,39 @@ export async function handleChatCore({ } } if (cavemanOutputModeApplied && !compressionAnalyticsRecorded) { - compressionAnalyticsWritePromise = (async () => { + compressionAnalyticsWritePromise = writeCavemanOutputAnalytics({ + comboName, + provider, + compressionComboId: config.compressionComboId, + estimatedTokens, + skillRequestId, + cavemanOutputModeIntensity, + log, + }); + } + if (outputStyleResult) { + void (async () => { try { - const { insertCompressionAnalyticsRow } = - await import("../../src/lib/db/compressionAnalytics.ts"); - insertCompressionAnalyticsRow({ - timestamp: new Date().toISOString(), - combo_id: comboName ?? null, - provider: provider ?? null, - mode: "output-caveman", - engine: "caveman-output", - compression_combo_id: config.compressionComboId ?? null, - original_tokens: estimatedTokens, - compressed_tokens: estimatedTokens, - tokens_saved: 0, - request_id: skillRequestId, - output_mode: cavemanOutputModeIntensity, + const { buildOutputStyleTelemetry } = + await import("../services/compression/outputStyles/telemetry.ts"); + const { insertCompressionRunTelemetryRow } = + await import("../../src/lib/db/compressionRunTelemetry.ts"); + const record = buildOutputStyleTelemetry({ + requestId: skillRequestId ?? traceId ?? "", + model: effectiveModel ?? "", + provider: provider ?? "", + source: config.compressionComboId ? "active-profile" : "default", + tokensBefore: estimatedTokens, + tokensAfter: estimatedTokens, + applied: outputStyleResult.applied, + appliedStyles: outputStyleResult.appliedStyles, + skippedReason: outputStyleResult.skippedReason, }); + insertCompressionRunTelemetryRow(record); } catch (err) { log?.debug?.( "COMPRESSION", - "Caveman output analytics write skipped: " + - (err instanceof Error ? err.message : String(err)) + "Run-telemetry emit skipped: " + (err instanceof Error ? err.message : String(err)) ); } })(); @@ -1912,138 +1609,12 @@ export async function handleChatCore({ ); } - type ClaudeContentBlock = Record; - type ClaudeMessage = { - role?: unknown; - content?: unknown; - }; - - // Shared helper: lift any system/developer role messages out of the messages - // array into the top-level system parameter. Anthropic's Messages API rejects - // system/developer roles inside messages[]. Case-insensitive to be defensive. - const extractSystemMessagesToBody = (payload: Record) => { - if (!Array.isArray(payload.messages)) return; - const messages = payload.messages as ClaudeMessage[]; - const systemMessages = messages.filter((m) => { - const role = String(m.role || "").toLowerCase(); - return role === "system" || role === "developer"; - }); - if (systemMessages.length === 0) return; - const extraBlocks: ClaudeContentBlock[] = []; - for (const sm of systemMessages) { - if (typeof sm.content === "string" && sm.content.length > 0) { - extraBlocks.push({ type: "text", text: sm.content }); - } else if (Array.isArray(sm.content)) { - for (const block of sm.content as ClaudeContentBlock[]) { - if (block?.type === "text" && typeof block.text === "string" && block.text.length > 0) { - extraBlocks.push(block); - } - } - } - } - if (extraBlocks.length > 0) { - const existingSystem = payload.system; - if (typeof existingSystem === "string" && existingSystem.length > 0) { - payload.system = [{ type: "text", text: existingSystem }, ...extraBlocks]; - } else if (Array.isArray(existingSystem)) { - payload.system = [...(existingSystem as ClaudeContentBlock[]), ...extraBlocks]; - } else { - payload.system = extraBlocks; - } - } - payload.messages = messages.filter((m) => { - const role = String(m.role || "").toLowerCase(); - return role !== "system" && role !== "developer"; - }); - }; - + // extractSystemMessagesToBody + normalizeClaudeUpstreamMessages extracted to + // chatCore/claudeUpstreamMessages.ts (#3501); bind `log` once so the call sites stay byte-identical. const normalizeClaudeUpstreamMessages = ( payload: Record, options?: { preserveToolResultBlocks?: boolean } - ) => { - const preserveToolResultBlocks = options?.preserveToolResultBlocks === true; - if (!Array.isArray(payload.messages)) return; - let messages = payload.messages as ClaudeMessage[]; - - // Extract system/developer role messages into top-level system parameter. - extractSystemRoleMessages(payload); - messages = payload.messages as ClaudeMessage[]; - - // Anthropic rejects empty text blocks in native Messages payloads. - for (const msg of messages) { - if (Array.isArray(msg.content)) { - msg.content = msg.content.filter( - (block: ClaudeContentBlock) => - block.type !== "text" || (typeof block.text === "string" && block.text.length > 0) - ); - } - } - - // Normalize unsupported content types without reintroducing the Claude -> OpenAI round-trip. - for (const msg of messages) { - if (msg.role !== "user" || !Array.isArray(msg.content)) continue; - msg.content = (msg.content as ClaudeContentBlock[]).flatMap((block: ClaudeContentBlock) => { - if ( - block.type === "text" || - block.type === "image_url" || - block.type === "image" || - block.type === "file_url" || - block.type === "file" || - block.type === "document" - ) { - const fileData = (block.file_url ?? block.file ?? block.document) as - | Record - | undefined; - if ( - (block.type === "file" || block.type === "document") && - !fileData?.url && - !fileData?.data - ) { - const fileContent = - (block.file as ClaudeContentBlock)?.content ?? - (block.file as ClaudeContentBlock)?.text ?? - block.content ?? - block.text; - const fileName = - (block.file as Record)?.name ?? block.name ?? "attachment"; - if (typeof fileContent === "string" && fileContent.length > 0) { - return [{ type: "text", text: `[${fileName}]\n${fileContent}` }]; - } - } - return [block]; - } - - if (block.type === "tool_result") { - if (preserveToolResultBlocks) { - return [block]; - } - const toolId = block.tool_use_id ?? block.id ?? "unknown"; - const resultContent = block.content ?? block.text ?? block.output ?? ""; - const resultText = - typeof resultContent === "string" - ? resultContent - : Array.isArray(resultContent) - ? resultContent - .filter((c: Record) => c.type === "text") - .map((c: Record) => c.text) - .join("\n") - : JSON.stringify(resultContent); - if (resultText.length > 0) { - return [{ type: "text", text: `[Tool Result: ${toolId}]\n${resultText}` }]; - } - return []; - } - - log?.debug?.("CONTENT", `Dropped unsupported content part type="${block.type}"`); - return []; - }); - } - - // #2815: move stray tool_result blocks out of assistant messages. - payload.messages = splitMisplacedToolResults( - payload.messages as ClaudeMessage[] - ) as unknown as Record[]; - }; + ) => normalizeClaudeUpstreamMessagesFor(payload, options, log); try { if (nativeCodexPassthrough) { @@ -2460,80 +2031,7 @@ export async function handleChatCore({ // mode="cliproxyapi": returns the CLIProxyAPI executor instead. // mode="fallback": returns a wrapper that tries native first, falls back to CLIProxyAPI on 5xx/network errors. - const resolveExecutorWithProxy = async (prov: string) => { - const cfg = await getUpstreamProxyConfigCached(prov); - if (!cfg.enabled || cfg.mode === "native") return getExecutor(prov); - - if (cfg.mode === "cliproxyapi") { - log?.info?.("UPSTREAM_PROXY", `${prov} routed through CLIProxyAPI (passthrough)`); - return getExecutor("cliproxyapi"); - } - - // mode === "fallback": try native first, retry via CLIProxyAPI on specific failures - const nativeExec = getExecutor(prov); - const proxyExec = getExecutor("cliproxyapi"); - - // Read custom fallback codes from settings. Default: 5xx + 429 + network errors. - let fallbackCodes: number[] = [429, 500, 502, 503, 504]; - try { - const allSettings = await getCachedSettings(); - if ( - typeof allSettings.cliproxyapi_fallback_codes === "string" && - allSettings.cliproxyapi_fallback_codes.trim() - ) { - const parsed = allSettings.cliproxyapi_fallback_codes - .split(",") - .map((s: string) => Number.parseInt(s.trim(), 10)) - .filter((n: number) => !Number.isNaN(n)); - if (parsed.length > 0) fallbackCodes = parsed; - } - } catch { - /* use defaults */ - } - const isRetryableStatus = (s: number) => fallbackCodes.includes(s) || s === 0; - - const wrapper = Object.create(nativeExec); - wrapper.execute = async (input: { - model: string; - body: unknown; - stream: boolean; - credentials: unknown; - signal?: AbortSignal | null; - log?: unknown; - upstreamExtraHeaders?: Record | null; - }) => { - let result; - try { - result = await nativeExec.execute(input); - } catch (err) { - const errMsg = err instanceof Error ? err.message : String(err); - log?.info?.("UPSTREAM_PROXY", `${prov} native error (${errMsg}), retrying via CLIProxyAPI`); - try { - return await proxyExec.execute(input); - } catch (proxyErr) { - const proxyMsg = proxyErr instanceof Error ? proxyErr.message : String(proxyErr); - log?.error?.("UPSTREAM_PROXY", `${prov} CLIProxyAPI fallback also failed: ${proxyMsg}`); - throw proxyErr; - } - } - - if (!isRetryableStatus(result.response.status)) { - return result; - } - log?.info?.( - "UPSTREAM_PROXY", - `${prov} native failed (${result.response.status}), retrying via CLIProxyAPI` - ); - try { - return await proxyExec.execute(input); - } catch (proxyErr) { - const proxyMsg = proxyErr instanceof Error ? proxyErr.message : String(proxyErr); - log?.error?.("UPSTREAM_PROXY", `${prov} CLIProxyAPI fallback also failed: ${proxyMsg}`); - throw proxyErr; - } - }; - return wrapper; - }; + const resolveExecutorWithProxy = (prov: string) => resolveExecutorWithProxyFor(prov, log); // === Quota Share enforcement PRE-hook (B/F7) === // Runs after provider/model/credentials/apiKeyInfo are fully resolved, @@ -2602,50 +2100,15 @@ export async function handleChatCore({ // Get executor for this provider (with optional upstream proxy routing) const executor = await resolveExecutorWithProxy(provider); - const getExecutionCredentials = () => { - const nextCredentials = nativeCodexPassthrough - ? { ...credentials, requestEndpointPath: endpointPath } - : credentials; - - const providerSpecificData = - nextCredentials?.providerSpecificData && - typeof nextCredentials.providerSpecificData === "object" - ? { ...nextCredentials.providerSpecificData } - : {}; - - // Some providers (Azure AI Foundry, OCI OpenAI-compatible) choose upstream - // endpoint path from providerSpecificData.apiType. When a model routes to - // OpenAI Responses format, force apiType=responses unless explicitly set. - if ( - targetFormat === FORMATS.OPENAI_RESPONSES && - (provider === "azure-ai" || provider === "oci") && - providerSpecificData.apiType !== "responses" - ) { - providerSpecificData.apiType = "responses"; - } - - if ( - targetFormat === FORMATS.OPENAI_RESPONSES && - (provider === "azure-ai" || provider === "oci") - ) { - providerSpecificData._omnirouteForceResponsesUpstream = true; - } - - const withApiType = { - ...nextCredentials, - providerSpecificData, - }; - - if (!ccSessionId) return withApiType; - - return { - ...withApiType, - providerSpecificData: { - ...(withApiType?.providerSpecificData || {}), - ccSessionId, - }, - }; - }; + const getExecutionCredentials = () => + resolveExecutionCredentialsFor({ + credentials, + nativeCodexPassthrough, + endpointPath, + targetFormat, + provider, + ccSessionId, + }); let onPipelineStreamError: streamFailure.PipelineStreamErrorHandler | null = null; @@ -2676,86 +2139,17 @@ export async function handleChatCore({ connectionId, credentials: executionCredentials, }); - let bodyToSend = - translatedBody.model === modelToCall - ? translatedBody - : { ...translatedBody, model: modelToCall }; - const payloadRuleModel = - typeof bodyToSend.model === "string" && bodyToSend.model.length > 0 - ? bodyToSend.model - : modelToCall; - const payloadRuleProtocols = resolvePayloadRuleProtocols({ + // Upstream body preparation extracted to chatCore/upstreamBody.ts (#3501 — first internal + // sub-slice of executeProviderRequest); produces the body sent upstream (payload rules + + // tool-limit truncation + qwen oauth user backfill + prompt_cache_key injection). + let bodyToSend = await prepareUpstreamBody({ + translatedBody, + modelToCall, provider, targetFormat, + credentials, + log, }); - const payloadRuleResult = await applyConfiguredPayloadRules( - bodyToSend, - payloadRuleModel, - payloadRuleProtocols - ); - bodyToSend = payloadRuleResult.payload; - - if (payloadRuleResult.applied.length > 0) { - const appliedSummary = payloadRuleResult.applied - .map((rule) => { - if (rule.type === "filter") return `${rule.type}:${rule.path}`; - const serializedValue = JSON.stringify(rule.value); - const safeValue = - typeof serializedValue === "string" && serializedValue.length > 80 - ? `${serializedValue.slice(0, 77)}...` - : serializedValue; - return `${rule.type}:${rule.path}=${safeValue}`; - }) - .join(", "); - log?.debug?.( - "PAYLOAD_RULES", - `Applied ${payloadRuleResult.applied.length} rule(s) for ${payloadRuleModel} (${payloadRuleProtocols.join(", ")}): ${appliedSummary}` - ); - } - - const effectiveToolLimit = getEffectiveToolLimit(provider); - if ( - effectiveToolLimit < MAX_TOOLS_LIMIT && - Array.isArray(bodyToSend.tools) && - bodyToSend.tools.length > effectiveToolLimit - ) { - const truncatedTools = bodyToSend.tools.slice(0, effectiveToolLimit); - bodyToSend = { ...bodyToSend, tools: truncatedTools }; - log?.debug?.( - "TOOL_LIMIT", - `Truncated ${bodyToSend.tools.length} tools to ${effectiveToolLimit} for ${provider}` - ); - } - - // Qwen OAuth rejects requests without a non-empty `user` field. - // Some minimal OpenAI-compatible clients omit it, so we backfill a - // stable default only for OAuth mode (API key mode is unaffected). - const hasValidQwenUser = - typeof bodyToSend.user === "string" && bodyToSend.user.trim().length > 0; - const isQwenOAuthRequest = - provider === "qwen" && - !credentials?.apiKey && - typeof credentials?.accessToken === "string" && - credentials.accessToken.trim().length > 0; - if (isQwenOAuthRequest && !hasValidQwenUser) { - bodyToSend = { ...bodyToSend, user: "omniroute-qwen-oauth" }; - log?.debug?.("QWEN", "Injected fallback user for OAuth request"); - } - - // Inject prompt_cache_key only for providers that support it - if ( - targetFormat === FORMATS.OPENAI && - providerSupportsCaching(provider) && - !bodyToSend.prompt_cache_key && - Array.isArray(bodyToSend.messages) && - !["nvidia", "codex", "xai"].includes(provider) - ) { - const { generatePromptCacheKey } = await import("@/lib/promptCache"); - const cacheKey = generatePromptCacheKey(bodyToSend.messages); - if (cacheKey) { - bodyToSend = { ...bodyToSend, prompt_cache_key: cacheKey }; - } - } updatePendingScope(pendingScope, { providerRequest: bodyToSend, @@ -3961,93 +3355,64 @@ export async function handleChatCore({ // Non-streaming response if (!stream) { - const contentType = (providerResponse.headers.get("content-type") || "").toLowerCase(); - let responseBody; - let responsePayloadFormat = targetFormat; - const rawBody = await readNonStreamingResponseBody( + const parsed = await parseNonStreamingResponseBody({ providerResponse, - contentType, - upstreamStream - ); - const normalizedProviderPayload = normalizePayloadForLog(rawBody); - const looksLikeSSE = - contentType.includes("text/event-stream") || - contentType.includes("application/x-ndjson") || - /(^|\n)\s*(event|data):/m.test(rawBody); + upstreamStream, + providerHeaders, + finalBody, + targetFormat, + model, + log, + }); + const normalizedProviderPayload = parsed.normalizedProviderPayload; + const looksLikeSSE = parsed.looksLikeSSE; - if (looksLikeSSE) { - const streamPayload = normalizeNonStreamingEventPayload(rawBody, contentType); - const streamKind = contentType.includes("application/x-ndjson") ? "NDJSON" : "SSE"; - if (shouldTreatBufferedEventResponseAsExpected(upstreamStream, providerHeaders, finalBody)) { - log?.debug?.( - "STREAM", - `Buffering upstream ${streamKind} response for non-streaming client request` - ); - } else { - log?.warn?.( - "STREAM", - `Unexpected ${streamKind} response for non-streaming request — buffering` - ); - } - // Upstream returned an event stream for a non-streaming client; convert best-effort to JSON. - const parsedFromSSE = parseNonStreamingSSEPayload(streamPayload, targetFormat, model); - - if (!parsedFromSSE) { - appendRequestLog({ - model, - provider, - connectionId, - status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`, - }).catch(() => {}); - // Some executors (e.g. the Devin/Windsurf CLI) always emit - // text/event-stream, signalling failure with an error-only chunk - // (`data: {"error":{"message":"Devin CLI not found..."}}`) that carries - // no `choices`. Surface that real, sanitized message instead of the - // generic 502 so the actionable error is not swallowed (#3324). - const surfacedSseError = extractSSEErrorMessage(streamPayload); - const invalidSseMessage = - surfacedSseError || "Invalid SSE response for non-streaming request"; - persistAttemptLogs({ - status: HTTP_STATUS.BAD_GATEWAY, - error: invalidSseMessage, - providerRequest: finalBody || translatedBody, - providerResponse: normalizedProviderPayload, - clientResponse: buildErrorBody(HTTP_STATUS.BAD_GATEWAY, invalidSseMessage), - cacheSource: "upstream", - }); - persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "invalid_sse_payload"); - trackPendingRequest(model, provider, pendingConnId, false); - return createErrorResult(HTTP_STATUS.BAD_GATEWAY, invalidSseMessage); - } - - responseBody = parsedFromSSE.body; - responsePayloadFormat = parsedFromSSE.format; - } else { - try { - responseBody = rawBody ? JSON.parse(rawBody) : {}; - } catch (err) { - appendRequestLog({ - model, - provider, - connectionId, - status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`, - }).catch(() => {}); - const detailedError = `Invalid JSON response from provider (error: ${err instanceof Error ? err.message : String(err)}): ${rawBody.substring(0, 1000)}`; - const invalidJsonMessage = "Invalid JSON response from provider"; - persistAttemptLogs({ - status: HTTP_STATUS.BAD_GATEWAY, - error: detailedError, - providerRequest: finalBody || translatedBody, - providerResponse: normalizedProviderPayload, - clientResponse: buildErrorBody(HTTP_STATUS.BAD_GATEWAY, invalidJsonMessage), - cacheSource: "upstream", - }); - persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "invalid_json_payload"); - trackPendingRequest(model, provider, connectionId, false); - return createErrorResult(HTTP_STATUS.BAD_GATEWAY, invalidJsonMessage); - } + if (parsed.kind === "invalid_sse") { + appendRequestLog({ + model, + provider, + connectionId, + status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`, + }).catch(() => {}); + const invalidSseMessage = parsed.message; + persistAttemptLogs({ + status: HTTP_STATUS.BAD_GATEWAY, + error: invalidSseMessage, + providerRequest: finalBody || translatedBody, + providerResponse: normalizedProviderPayload, + clientResponse: buildErrorBody(HTTP_STATUS.BAD_GATEWAY, invalidSseMessage), + cacheSource: "upstream", + }); + persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "invalid_sse_payload"); + trackPendingRequest(model, provider, pendingConnId, false); + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, invalidSseMessage); } + if (parsed.kind === "invalid_json") { + appendRequestLog({ + model, + provider, + connectionId, + status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`, + }).catch(() => {}); + const detailedError = parsed.detailedError; + const invalidJsonMessage = parsed.message; + persistAttemptLogs({ + status: HTTP_STATUS.BAD_GATEWAY, + error: detailedError, + providerRequest: finalBody || translatedBody, + providerResponse: normalizedProviderPayload, + clientResponse: buildErrorBody(HTTP_STATUS.BAD_GATEWAY, invalidJsonMessage), + cacheSource: "upstream", + }); + persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "invalid_json_payload"); + trackPendingRequest(model, provider, connectionId, false); + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, invalidJsonMessage); + } + + let responseBody = parsed.responseBody; + let responsePayloadFormat = parsed.responsePayloadFormat; + // Check for empty content response (fake success) - trigger fallback if (isEmptyContentResponse(responseBody)) { appendRequestLog({ @@ -4152,66 +3517,30 @@ export async function handleChatCore({ // Context Editing telemetry: when the delegated server-side clear actually ran, // record the provider's cleared-token receipt under engine "context-editing" so // it surfaces in compression analytics. Best-effort, Claude-only, non-streaming. - if (contextEditingEnabled && provider === "claude") { - void (async () => { - try { - const { extractContextEditingTelemetry } = await import("../config/contextEditing.ts"); - const tele = extractContextEditingTelemetry(responseBody); - if (tele) { - const { recordContextEditingTelemetry } = - await import("../../src/lib/db/compressionAnalytics.ts"); - recordContextEditingTelemetry(skillRequestId, tele, provider); - log?.debug?.( - "CONTEXT_EDITING", - `cleared ${tele.clearedInputTokens} input tokens / ${tele.clearedToolUses} tool uses (${tele.editCount} edits)` - ); - } - } catch { - // Telemetry is best-effort and must never affect the response. - } - })(); - } + recordContextEditingTelemetryHook({ + contextEditingEnabled, + provider, + responseBody, + skillRequestId, + log, + }); appendRequestLog({ model, provider, connectionId, tokens: usage, status: "200 OK" }).catch( () => {} ); // Save structured call log with full payloads const cacheUsageLogMeta = buildCacheUsageLogMeta(usage); - if (usage && typeof usage === "object") { - if (traceEnabled) { - const msg = `[${new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" })}] 📊 [USAGE] ${provider?.toUpperCase()} | ${formatUsageLog(usage)}${connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""}`; - console.log(`${COLORS.green}${msg}${COLORS.reset}`); - } - - saveRequestUsage({ - provider: provider || "unknown", - model: model || "unknown", - tokens: usage, - status: "200", - success: true, - latencyMs: Date.now() - startTime, - timeToFirstTokenMs: Date.now() - startTime, - errorCode: null, - timestamp: new Date().toISOString(), - connectionId: connectionId || undefined, - apiKeyId: apiKeyInfo?.id || undefined, - apiKeyName: apiKeyInfo?.name || undefined, - serviceTier: effectiveServiceTier, - comboStrategy: isCombo ? comboStrategy || undefined : undefined, - }).catch((err) => { - console.error("Failed to save usage stats:", err.message); - }); - - if (apiKeyInfo?.id) { - try { - const billable = computeBillableTokens(usage); - if (billable > 0) - recordTokenUsage(apiKeyInfo.id, provider || "unknown", model || "unknown", billable); - } catch { - // never block the response on counter recording - } - } - } + recordNonStreamingUsageStats(usage, { + traceEnabled, + provider, + connectionId, + model, + startTime, + apiKeyInfo, + effectiveServiceTier, + isCombo, + comboStrategy, + }); // Translate response to client's expected format (usually OpenAI) // Pass toolNameMap so Claude OAuth proxy_ prefix is stripped in tool_use blocks (#605) @@ -4425,38 +3754,18 @@ export async function handleChatCore({ } // === Quota Share POST-hook (B/F7) — fire-and-forget, fail-open === - if (apiKeyInfo?.id && credentials?.connectionId) { - try { - const { scheduleRecordConsumption, buildConsumptionCost } = - await import("@/lib/quota/spendRecorder"); - scheduleRecordConsumption( - { - apiKeyId: apiKeyInfo.id, - connectionId: credentials.connectionId, - provider: provider ?? "unknown", - cost: buildConsumptionCost(usage, estimatedCost), - }, - log - ); - } catch (_) { - // Outer fail-open — never throws to caller - } - } + await scheduleQuotaShareConsumption({ + apiKeyId: apiKeyInfo?.id, + connectionId: credentials?.connectionId, + provider, + usage, + estimatedCost, + log, + }); // === /Quota Share POST-hook === // ── Gamification event (fire-and-forget) ── - if (apiKeyInfo?.id) { - try { - const { emitGamificationEvent } = await import("@/lib/gamification/events"); - emitGamificationEvent({ - apiKeyId: apiKeyInfo.id, - action: "request", - metadata: { model, provider }, - }); - } catch (_) { - /* gamification optional */ - } - } + await emitRequestGamificationEvent({ apiKeyId: apiKeyInfo?.id, model, provider }); finalizePendingScope(pendingScope, { providerResponse: responseBody, @@ -4675,37 +3984,20 @@ export async function handleChatCore({ // Track cache token metrics for streaming responses if (streamUsage && typeof streamUsage === "object") { attachCompressionUsageReceiptAfterAnalytics(streamUsage as Record, "stream"); - - saveRequestUsage({ - provider: provider || "unknown", - model: model || "unknown", - tokens: streamUsage, - status: String(normalizedStreamStatus), - success: normalizedStreamStatus === 200, - latencyMs: Date.now() - startTime, - timeToFirstTokenMs: ttft, - errorCode: - normalizedStreamStatus === 200 ? null : streamErrorCode || String(normalizedStreamStatus), - timestamp: new Date().toISOString(), - connectionId: connectionId || undefined, - apiKeyId: apiKeyInfo?.id || undefined, - apiKeyName: apiKeyInfo?.name || undefined, - serviceTier: effectiveServiceTier, - comboStrategy: isCombo ? comboStrategy || undefined : undefined, - }).catch((err) => { - console.error("Failed to save usage stats:", err.message); - }); - - if (apiKeyInfo?.id && normalizedStreamStatus === 200) { - try { - const billable = computeBillableTokens(streamUsage); - if (billable > 0) - recordTokenUsage(apiKeyInfo.id, provider || "unknown", model || "unknown", billable); - } catch { - // never block the stream on counter recording - } - } } + recordStreamingUsageStats(streamUsage, { + provider, + model, + streamStatus: normalizedStreamStatus, + startTime, + ttft, + streamErrorCode, + connectionId, + apiKeyInfo, + effectiveServiceTier, + isCombo, + comboStrategy, + }); persistAttemptLogs({ status: normalizedStreamStatus, @@ -4720,41 +4012,31 @@ export async function handleChatCore({ cacheSource: "upstream", }); - if (apiKeyInfo?.id && streamUsage) { - calculateCost(provider, model, streamUsage, { serviceTier: effectiveServiceTier }) - .then((estimatedCost) => { - if (estimatedCost > 0) recordCost(apiKeyInfo.id, estimatedCost); - }) - .catch(() => {}); - } + recordStreamingCost({ + apiKeyId: apiKeyInfo?.id, + provider, + model, + streamUsage, + serviceTier: effectiveServiceTier, + calculateCost, + recordCost, + }); // === Quota Share POST-hook streaming (B/F7) — fire-and-forget, fail-open === // Resolve the real per-request cost (calculateCost) so USD-unit pools accrue // on streaming traffic too; this previously recorded usd:0 hardcoded, which // meant DeepSeek-style `usd/monthly` shared pools never blocked on streams. - if (apiKeyInfo?.id && credentials?.connectionId && normalizedStreamStatus === 200) { - const quotaApiKeyId = apiKeyInfo.id; - const quotaConnectionId = credentials.connectionId; - // onStreamComplete is sync — use .then() (fire-and-forget, fail-open) instead of await - import("@/lib/quota/spendRecorder") - .then(({ recordStreamingConsumption }) => - recordStreamingConsumption( - { - apiKeyId: quotaApiKeyId, - connectionId: quotaConnectionId, - provider, - model, - streamUsage, - streamStatus: normalizedStreamStatus, - serviceTier: effectiveServiceTier, - }, - { calculateCost, log } - ) - ) - .catch(() => { - // Outer fail-open — never throws to caller - }); - } + scheduleStreamingQuotaShareConsumption({ + apiKeyId: apiKeyInfo?.id, + connectionId: credentials?.connectionId, + provider, + model, + streamUsage, + streamStatus: normalizedStreamStatus, + serviceTier: effectiveServiceTier, + calculateCost, + log, + }); // === /Quota Share POST-hook streaming === if ( @@ -4905,29 +4187,10 @@ export async function handleChatCore({ } // ── Gamification event (fire-and-forget) ── - if (apiKeyInfo?.id) { - try { - const { emitGamificationEvent } = await import("@/lib/gamification/events"); - emitGamificationEvent({ - apiKeyId: apiKeyInfo.id, - action: "request", - metadata: { model, provider }, - }); - } catch (_) { - /* gamification optional */ - } - } + await emitRequestGamificationEvent({ apiKeyId: apiKeyInfo?.id, model, provider }); // ── Plugin onResponse hook (fire-and-forget) ── - try { - const { runOnResponse } = await import("@/lib/plugins/hooks"); - runOnResponse( - { requestId: traceId, body, model, provider, apiKeyInfo, metadata: {} }, - { status: 200 } - ).catch(() => {}); - } catch (_) { - /* plugin onResponse optional */ - } + await runPluginOnResponseHook({ requestId: traceId, body, model, provider, apiKeyInfo }); return { success: true, diff --git a/open-sse/handlers/chatCore/attemptLogging.ts b/open-sse/handlers/chatCore/attemptLogging.ts new file mode 100644 index 0000000000..7414b951da --- /dev/null +++ b/open-sse/handlers/chatCore/attemptLogging.ts @@ -0,0 +1,178 @@ +/** + * chatCore per-attempt logging persistence (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Extracted from handleChatCore: persists one attempt's call log. Emits a provider.warning audit + * event when the provider response carries warnings, fills the detailed pipeline payloads (when + * detailed logging is on), and writes the bounded/truncated call-log row (request/response bodies + * with the Claude prompt-cache meta attached). Best-effort: the saveCallLog write swallows its own + * errors. The per-request context (provider/model/ids/combo/etc.) is threaded via `ctx` so the 16 + * call sites in the handler stay byte-identical; behaviour is unchanged. + */ + +import { extractProviderWarnings } from "@/lib/compliance/providerAudit"; +import { logAuditEvent } from "@/lib/compliance"; +import { saveCallLog } from "@/lib/usageDb"; +import { cloneBoundedChatLogPayload, truncateForLog } from "./logTruncation.ts"; +import { attachLogMeta } from "./cacheUsageMeta.ts"; + +export type PersistAttemptLogsArgs = { + status: number; + tokens?: unknown; + responseBody?: unknown; + error?: string | null; + providerRequest?: unknown; + providerResponse?: unknown; + clientResponse?: unknown; + claudeCacheMeta?: Record; + claudeCacheUsageMeta?: Record; + cacheSource?: "upstream" | "semantic"; +}; + +export type PersistAttemptLogsContext = { + provider: string | null | undefined; + connectionId: string | null | undefined; + model: string | null | undefined; + skillRequestId: string; + detailedLoggingEnabled: boolean; + reqLogger: + | { getPipelinePayloads?: () => Record | undefined } + | null + | undefined; + pendingRequestId: unknown; + clientRawRequest: { endpoint?: string } | null | undefined; + requestedModel: unknown; + credentials: { connectionId?: string } | null | undefined; + startTime: number; + body: unknown; + sourceFormat: unknown; + targetFormat: unknown; + comboName: unknown; + comboStepId: unknown; + comboExecutionKey: unknown; + tokensCompressed: unknown; + apiKeyInfo: { id?: string | null; name?: string | null } | null | undefined; + noLogEnabled: unknown; +}; + +export function persistAttemptLogs(args: PersistAttemptLogsArgs, ctx: PersistAttemptLogsContext) { + const { + status, + tokens, + responseBody, + error, + providerRequest, + providerResponse, + clientResponse, + claudeCacheMeta, + claudeCacheUsageMeta, + cacheSource, + } = args; + const { + provider, + connectionId, + model, + skillRequestId, + detailedLoggingEnabled, + reqLogger, + pendingRequestId, + clientRawRequest, + requestedModel, + credentials, + startTime, + body, + sourceFormat, + targetFormat, + comboName, + comboStepId, + comboExecutionKey, + tokensCompressed, + apiKeyInfo, + noLogEnabled, + } = ctx; + + const providerWarnings = extractProviderWarnings(providerResponse, clientResponse, responseBody); + if (providerWarnings.length > 0) { + logAuditEvent({ + action: "provider.warning", + actor: "system", + target: [provider, connectionId].filter(Boolean).join(":") || provider || model, + resourceType: "provider_warning", + status: "warning", + requestId: skillRequestId, + details: { + provider, + model, + connectionId, + httpStatus: status, + warnings: providerWarnings, + }, + }); + } + + const pipelinePayloads = detailedLoggingEnabled + ? (reqLogger?.getPipelinePayloads?.() ?? {}) + : null; + + if (pipelinePayloads) { + if (providerRequest !== undefined && !pipelinePayloads.providerRequest) { + pipelinePayloads.providerRequest = providerRequest as Record; + } + if (providerResponse !== undefined && !pipelinePayloads.providerResponse) { + pipelinePayloads.providerResponse = providerResponse as Record; + } + if (clientResponse !== undefined) { + pipelinePayloads.clientResponse = clientResponse as Record; + } + if (error) { + pipelinePayloads.error = { + ...(typeof pipelinePayloads.error === "object" && pipelinePayloads.error + ? (pipelinePayloads.error as Record) + : {}), + message: error, + }; + } + } + + saveCallLog({ + id: pendingRequestId, + method: "POST", + path: clientRawRequest?.endpoint || "/v1/chat/completions", + status, + model, + requestedModel, + provider, + connectionId: connectionId || credentials?.connectionId || undefined, + duration: Date.now() - startTime, + tokens: tokens || {}, + requestBody: cloneBoundedChatLogPayload( + attachLogMeta(truncateForLog(body as Record), { + claudePromptCache: claudeCacheMeta, + }) + ), + responseBody: cloneBoundedChatLogPayload( + attachLogMeta(truncateForLog(responseBody as Record), { + claudePromptCache: claudeCacheMeta + ? { + applied: claudeCacheMeta.applied, + totalBreakpoints: claudeCacheMeta.totalBreakpoints, + anthropicBeta: claudeCacheMeta.anthropicBeta, + } + : null, + claudePromptCacheUsage: claudeCacheUsageMeta, + }) + ), + error: error || null, + sourceFormat, + targetFormat, + comboName, + comboStepId, + comboExecutionKey, + tokensCompressed, + cacheSource: cacheSource === "semantic" ? "semantic" : "upstream", + apiKeyId: apiKeyInfo?.id || null, + apiKeyName: apiKeyInfo?.name || null, + noLog: noLogEnabled, + pipelinePayloads, + }).catch(() => {}); +} diff --git a/open-sse/handlers/chatCore/cacheUsageMeta.ts b/open-sse/handlers/chatCore/cacheUsageMeta.ts new file mode 100644 index 0000000000..ccb3b5707b --- /dev/null +++ b/open-sse/handlers/chatCore/cacheUsageMeta.ts @@ -0,0 +1,65 @@ +/** + * chatCore cache-usage log meta helpers (Quality Gate v2 / Fase 9 — chatCore god-file decomposition, + * #3501). + * + * Pure helpers extracted from chatCore: coerce an unknown to a positive number, derive cache + * read/creation token counts from a usage object (handling both top-level and prompt_tokens_details + * shapes), and attach an `_omniroute` meta blob to a log payload. Side-effect-free; behaviour is + * byte-identical to the previous module-level functions. + */ + +export function toPositiveNumber(value: unknown) { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0; +} + +export function buildCacheUsageLogMeta(usage: Record | null | undefined) { + if (!usage || typeof usage !== "object") return null; + const promptTokenDetails = + usage.prompt_tokens_details && typeof usage.prompt_tokens_details === "object" + ? (usage.prompt_tokens_details as Record) + : undefined; + const hasCacheFields = + "cache_read_input_tokens" in usage || + "cached_tokens" in usage || + "cache_creation_input_tokens" in usage || + (!!promptTokenDetails && + ("cached_tokens" in promptTokenDetails || "cache_creation_tokens" in promptTokenDetails)); + const cacheReadTokens = toPositiveNumber( + usage.cache_read_input_tokens ?? usage.cached_tokens ?? promptTokenDetails?.cached_tokens + ); + const cacheCreationTokens = toPositiveNumber( + usage.cache_creation_input_tokens ?? promptTokenDetails?.cache_creation_tokens + ); + if (!hasCacheFields) return null; + return { + cacheReadTokens, + cacheCreationTokens, + }; +} + +export function attachLogMeta( + payload: Record | null | undefined, + meta: Record | null | undefined +) { + if (!meta || typeof meta !== "object") return payload; + const compactMeta = Object.fromEntries( + Object.entries(meta).filter(([, value]) => value !== null && value !== undefined) + ); + if (Object.keys(compactMeta).length === 0) return payload; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return { _omniroute: compactMeta, _payload: payload ?? null }; + } + const existing = + payload._omniroute && + typeof payload._omniroute === "object" && + !Array.isArray(payload._omniroute) + ? payload._omniroute + : {}; + return { + ...payload, + _omniroute: { + ...existing, + ...compactMeta, + }, + }; +} diff --git a/open-sse/handlers/chatCore/cavemanOutputAnalytics.ts b/open-sse/handlers/chatCore/cavemanOutputAnalytics.ts new file mode 100644 index 0000000000..fa3a778d9c --- /dev/null +++ b/open-sse/handlers/chatCore/cavemanOutputAnalytics.ts @@ -0,0 +1,47 @@ +/** + * chatCore caveman-output compression analytics (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Extracted from handleChatCore's request-setup compression path: when only the caveman output + * mode was applied (no upstream compression run recorded a row), persist a single analytics row so + * output-caveman runs still surface in compression analytics. Best-effort — returns the write + * promise (the caller assigns it to compressionAnalyticsWritePromise) and swallows its own errors. + * Behaviour is byte-identical to the previous inline block. + */ + +type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined; + +export function writeCavemanOutputAnalytics(args: { + comboName: string | null | undefined; + provider: string | null | undefined; + compressionComboId: string | null | undefined; + estimatedTokens: number; + skillRequestId: string; + cavemanOutputModeIntensity: string | null | undefined; + log?: LoggerLike; +}): Promise { + return (async () => { + try { + const { insertCompressionAnalyticsRow } = await import("@/lib/db/compressionAnalytics"); + insertCompressionAnalyticsRow({ + timestamp: new Date().toISOString(), + combo_id: args.comboName ?? null, + provider: args.provider ?? null, + mode: "output-caveman", + engine: "caveman-output", + compression_combo_id: args.compressionComboId ?? null, + original_tokens: args.estimatedTokens, + compressed_tokens: args.estimatedTokens, + tokens_saved: 0, + request_id: args.skillRequestId, + output_mode: args.cavemanOutputModeIntensity, + }); + } catch (err) { + args.log?.debug?.( + "COMPRESSION", + "Caveman output analytics write skipped: " + + (err instanceof Error ? err.message : String(err)) + ); + } + })(); +} diff --git a/open-sse/handlers/chatCore/claudeMessageTypes.ts b/open-sse/handlers/chatCore/claudeMessageTypes.ts new file mode 100644 index 0000000000..24492a3957 --- /dev/null +++ b/open-sse/handlers/chatCore/claudeMessageTypes.ts @@ -0,0 +1,15 @@ +/** + * chatCore Claude message shape aliases (Quality Gate v2 / Fase 9 — chatCore god-file decomposition, + * #3501). + * + * Minimal structural aliases shared by the Claude upstream-message transforms (and a handful of + * narrowing casts left in handleChatCore). Kept intentionally loose — these mirror the permissive + * shapes the handler already used inline; behaviour is unchanged. + */ + +export type ClaudeContentBlock = Record; + +export type ClaudeMessage = { + role?: unknown; + content?: unknown; +}; diff --git a/open-sse/handlers/chatCore/claudeUpstreamMessages.ts b/open-sse/handlers/chatCore/claudeUpstreamMessages.ts new file mode 100644 index 0000000000..b34ff7b36d --- /dev/null +++ b/open-sse/handlers/chatCore/claudeUpstreamMessages.ts @@ -0,0 +1,143 @@ +/** + * chatCore Claude upstream-message transforms (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Extracted from handleChatCore. `extractSystemMessagesToBody` lifts system/developer role messages + * into the top-level `system` parameter (Anthropic rejects those roles inside messages[]). + * `normalizeClaudeUpstreamMessages` prepares a native Claude Messages payload: lifts system roles, + * drops empty text blocks, inlines unsupported file/document parts as text, collapses tool_result + * blocks to text (unless preserved), drops unsupported parts, and moves stray tool_result blocks out + * of assistant messages (#2815). Both mutate the payload in place; behaviour is byte-identical to the + * previous inline closures (normalize captured only `log`). + */ + +import type { ClaudeContentBlock, ClaudeMessage } from "./claudeMessageTypes.ts"; +import { extractSystemRoleMessages } from "./claudeSystemRole.ts"; +import { splitMisplacedToolResults } from "../../translator/helpers/claudeHelper.ts"; + +type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined; + +export function extractSystemMessagesToBody(payload: Record) { + if (!Array.isArray(payload.messages)) return; + const messages = payload.messages as ClaudeMessage[]; + const systemMessages = messages.filter((m) => { + const role = String(m.role || "").toLowerCase(); + return role === "system" || role === "developer"; + }); + if (systemMessages.length === 0) return; + const extraBlocks: ClaudeContentBlock[] = []; + for (const sm of systemMessages) { + if (typeof sm.content === "string" && sm.content.length > 0) { + extraBlocks.push({ type: "text", text: sm.content }); + } else if (Array.isArray(sm.content)) { + for (const block of sm.content as ClaudeContentBlock[]) { + if (block?.type === "text" && typeof block.text === "string" && block.text.length > 0) { + extraBlocks.push(block); + } + } + } + } + if (extraBlocks.length > 0) { + const existingSystem = payload.system; + if (typeof existingSystem === "string" && existingSystem.length > 0) { + payload.system = [{ type: "text", text: existingSystem }, ...extraBlocks]; + } else if (Array.isArray(existingSystem)) { + payload.system = [...(existingSystem as ClaudeContentBlock[]), ...extraBlocks]; + } else { + payload.system = extraBlocks; + } + } + payload.messages = messages.filter((m) => { + const role = String(m.role || "").toLowerCase(); + return role !== "system" && role !== "developer"; + }); +} + +export function normalizeClaudeUpstreamMessages( + payload: Record, + options?: { preserveToolResultBlocks?: boolean }, + log?: LoggerLike +) { + const preserveToolResultBlocks = options?.preserveToolResultBlocks === true; + if (!Array.isArray(payload.messages)) return; + let messages = payload.messages as ClaudeMessage[]; + + // Extract system/developer role messages into top-level system parameter. + extractSystemRoleMessages(payload); + messages = payload.messages as ClaudeMessage[]; + + // Anthropic rejects empty text blocks in native Messages payloads. + for (const msg of messages) { + if (Array.isArray(msg.content)) { + msg.content = msg.content.filter( + (block: ClaudeContentBlock) => + block.type !== "text" || (typeof block.text === "string" && block.text.length > 0) + ); + } + } + + // Normalize unsupported content types without reintroducing the Claude -> OpenAI round-trip. + for (const msg of messages) { + if (msg.role !== "user" || !Array.isArray(msg.content)) continue; + msg.content = (msg.content as ClaudeContentBlock[]).flatMap((block: ClaudeContentBlock) => { + if ( + block.type === "text" || + block.type === "image_url" || + block.type === "image" || + block.type === "file_url" || + block.type === "file" || + block.type === "document" + ) { + const fileData = (block.file_url ?? block.file ?? block.document) as + | Record + | undefined; + if ( + (block.type === "file" || block.type === "document") && + !fileData?.url && + !fileData?.data + ) { + const fileContent = + (block.file as ClaudeContentBlock)?.content ?? + (block.file as ClaudeContentBlock)?.text ?? + block.content ?? + block.text; + const fileName = + (block.file as Record)?.name ?? block.name ?? "attachment"; + if (typeof fileContent === "string" && fileContent.length > 0) { + return [{ type: "text", text: `[${fileName}]\n${fileContent}` }]; + } + } + return [block]; + } + + if (block.type === "tool_result") { + if (preserveToolResultBlocks) { + return [block]; + } + const toolId = block.tool_use_id ?? block.id ?? "unknown"; + const resultContent = block.content ?? block.text ?? block.output ?? ""; + const resultText = + typeof resultContent === "string" + ? resultContent + : Array.isArray(resultContent) + ? resultContent + .filter((c: Record) => c.type === "text") + .map((c: Record) => c.text) + .join("\n") + : JSON.stringify(resultContent); + if (resultText.length > 0) { + return [{ type: "text", text: `[Tool Result: ${toolId}]\n${resultText}` }]; + } + return []; + } + + log?.debug?.("CONTENT", `Dropped unsupported content part type="${block.type}"`); + return []; + }); + } + + // #2815: move stray tool_result blocks out of assistant messages. + payload.messages = splitMisplacedToolResults( + payload.messages as ClaudeMessage[] + ) as unknown as Record[]; +} diff --git a/open-sse/handlers/chatCore/compressionCacheStats.ts b/open-sse/handlers/chatCore/compressionCacheStats.ts new file mode 100644 index 0000000000..06bca10837 --- /dev/null +++ b/open-sse/handlers/chatCore/compressionCacheStats.ts @@ -0,0 +1,53 @@ +/** + * chatCore compression cache-stats hook (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Extracted from handleChatCore's request-setup compression path: when a prompt was compressed, + * record the caching-context cache-stats receipt (estimated cache hit + tokens saved). Best-effort, + * fire-and-forget — the inner work is an un-awaited IIFE that swallows its own errors and never + * affects the request. Behaviour is byte-identical to the previous inline block. + */ + +type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined; + +export function recordCompressionCacheStats(args: { + compressionInputBody: unknown; + provider: string | null | undefined; + targetFormat: string | null | undefined; + effectiveModel: string | null | undefined; + mode: string; + stats: { originalTokens: number; compressedTokens: number }; + log?: LoggerLike; +}): void { + void (async () => { + try { + const { detectCachingContext } = await import("../../services/compression/cachingAware.ts"); + const { recordCacheStats } = await import("@/lib/db/compressionCacheStats"); + const cacheContext = detectCachingContext(args.compressionInputBody, { + provider: args.provider, + targetFormat: args.targetFormat, + model: args.effectiveModel, + }); + const tokensSavedCompression = Math.max( + 0, + args.stats.originalTokens - args.stats.compressedTokens + ); + recordCacheStats({ + provider: cacheContext.provider ?? args.provider ?? "unknown", + model: args.effectiveModel ?? "", + compressionMode: args.mode, + cacheControlPresent: cacheContext.hasCacheControl, + estimatedCacheHit: cacheContext.hasCacheControl && cacheContext.isCachingProvider, + tokensSavedCompression, + tokensSavedCaching: 0, + netSavings: tokensSavedCompression, + }); + } catch (err) { + args.log?.debug?.( + "COMPRESSION", + "Compression cache stats write skipped: " + + (err instanceof Error ? err.message : String(err)) + ); + } + })(); +} diff --git a/open-sse/handlers/chatCore/compressionUsageReceipt.ts b/open-sse/handlers/chatCore/compressionUsageReceipt.ts new file mode 100644 index 0000000000..76f4907d32 --- /dev/null +++ b/open-sse/handlers/chatCore/compressionUsageReceipt.ts @@ -0,0 +1,27 @@ +/** + * chatCore compression-usage receipt attachment (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Extracted from handleChatCore: best-effort, fire-and-forget attachment of a usage receipt to the + * stored compression-analytics row, ordered after any in-flight analytics write (`pendingWrite`) so + * the receipt lands on the persisted row. Errors are swallowed — analytics must never affect the + * response. The per-request inputs are passed via `ctx`; behaviour is byte-identical to the previous + * inline closure. + */ + +export function attachCompressionUsageReceiptAfterAnalytics( + usage: Record, + source: "provider" | "estimated" | "stream", + ctx: { pendingWrite: Promise | null; skillRequestId: string } +) { + const { pendingWrite, skillRequestId } = ctx; + void (async () => { + try { + if (pendingWrite) await pendingWrite; + const { attachCompressionUsageReceipt } = await import("@/lib/db/compressionAnalytics.ts"); + attachCompressionUsageReceipt(skillRequestId, usage, source); + } catch { + // Compression analytics are best-effort and must never affect responses. + } + })(); +} diff --git a/open-sse/handlers/chatCore/contextEditingTelemetry.ts b/open-sse/handlers/chatCore/contextEditingTelemetry.ts new file mode 100644 index 0000000000..abe1c7bde2 --- /dev/null +++ b/open-sse/handlers/chatCore/contextEditingTelemetry.ts @@ -0,0 +1,40 @@ +/** + * chatCore context-editing telemetry hook (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Extracted from handleChatCore's non-streaming success path: when the delegated server-side + * context-clear actually ran (Claude-only), record the provider's cleared-token receipt under the + * "context-editing" engine so it surfaces in compression analytics. Best-effort, fire-and-forget, + * and must never affect the response — the inner work is an un-awaited IIFE that swallows its own + * errors. Behaviour is byte-identical to the previous inline block. + */ + +type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined; + +export function recordContextEditingTelemetryHook(args: { + contextEditingEnabled: boolean; + provider: string | null | undefined; + responseBody: unknown; + skillRequestId: string; + log?: LoggerLike; +}): void { + const { contextEditingEnabled, provider, responseBody, skillRequestId, log } = args; + if (!contextEditingEnabled || provider !== "claude") return; + + void (async () => { + try { + const { extractContextEditingTelemetry } = await import("../../config/contextEditing.ts"); + const tele = extractContextEditingTelemetry(responseBody); + if (tele) { + const { recordContextEditingTelemetry } = await import("@/lib/db/compressionAnalytics"); + recordContextEditingTelemetry(skillRequestId, tele, provider); + log?.debug?.( + "CONTEXT_EDITING", + `cleared ${tele.clearedInputTokens} input tokens / ${tele.clearedToolUses} tool uses (${tele.editCount} edits)` + ); + } + } catch { + // Telemetry is best-effort and must never affect the response. + } + })(); +} diff --git a/open-sse/handlers/chatCore/executionCredentials.ts b/open-sse/handlers/chatCore/executionCredentials.ts new file mode 100644 index 0000000000..3d0d7dcf93 --- /dev/null +++ b/open-sse/handlers/chatCore/executionCredentials.ts @@ -0,0 +1,72 @@ +/** + * chatCore execution-credentials resolver (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Pure builder extracted from handleChatCore: derives the per-execution credentials object from the + * resolved request context. Applies the native-Codex passthrough endpoint override, forces + * apiType=responses (and the responses-upstream marker) for Azure AI Foundry / OCI when the model + * routes to the OpenAI Responses format, and threads the Claude Code session id when present. + * Side-effect-free; behaviour is byte-identical to the previous inline closure. + */ + +import { FORMATS } from "../../translator/formats.ts"; + +type CredentialsLike = + | { + providerSpecificData?: Record | null; + [key: string]: unknown; + } + | null + | undefined; + +export function resolveExecutionCredentials(opts: { + credentials: CredentialsLike; + nativeCodexPassthrough: boolean; + endpointPath: string; + targetFormat: string; + provider: string | null | undefined; + ccSessionId: string | null; +}) { + const { credentials, nativeCodexPassthrough, endpointPath, targetFormat, provider, ccSessionId } = + opts; + + const nextCredentials = nativeCodexPassthrough + ? { ...credentials, requestEndpointPath: endpointPath } + : credentials; + + const providerSpecificData = + nextCredentials?.providerSpecificData && + typeof nextCredentials.providerSpecificData === "object" + ? { ...nextCredentials.providerSpecificData } + : {}; + + // Some providers (Azure AI Foundry, OCI OpenAI-compatible) choose upstream + // endpoint path from providerSpecificData.apiType. When a model routes to + // OpenAI Responses format, force apiType=responses unless explicitly set. + if ( + targetFormat === FORMATS.OPENAI_RESPONSES && + (provider === "azure-ai" || provider === "oci") && + providerSpecificData.apiType !== "responses" + ) { + providerSpecificData.apiType = "responses"; + } + + if (targetFormat === FORMATS.OPENAI_RESPONSES && (provider === "azure-ai" || provider === "oci")) { + providerSpecificData._omnirouteForceResponsesUpstream = true; + } + + const withApiType = { + ...nextCredentials, + providerSpecificData, + }; + + if (!ccSessionId) return withApiType; + + return { + ...withApiType, + providerSpecificData: { + ...(withApiType?.providerSpecificData || {}), + ccSessionId, + }, + }; +} diff --git a/open-sse/handlers/chatCore/executorClientHeaders.ts b/open-sse/handlers/chatCore/executorClientHeaders.ts new file mode 100644 index 0000000000..e2a77bf36e --- /dev/null +++ b/open-sse/handlers/chatCore/executorClientHeaders.ts @@ -0,0 +1,36 @@ +/** + * chatCore executor client-header normalizer (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Pure helper extracted from chatCore: normalizes a Headers instance or a plain header object into a + * lowercased-tolerant Record, and backfills the client User-Agent (both casings) when + * one is supplied and not already present. Returns null when nothing was collected. Side-effect-free; + * behaviour is byte-identical to the previous module-level function. + */ + +export function buildExecutorClientHeaders( + headers: Headers | Record | null | undefined, + userAgent?: string | null +) { + const normalized: Record = {}; + + if (headers instanceof Headers) { + headers.forEach((value, key) => { + normalized[key] = value; + }); + } else if (headers && typeof headers === "object") { + for (const [key, value] of Object.entries(headers)) { + if (typeof value === "string") { + normalized[key] = value; + } + } + } + + const normalizedUserAgent = typeof userAgent === "string" ? userAgent.trim() : ""; + if (normalizedUserAgent && !normalized["user-agent"] && !normalized["User-Agent"]) { + normalized["user-agent"] = normalizedUserAgent; + normalized["User-Agent"] = normalizedUserAgent; + } + + return Object.keys(normalized).length > 0 ? normalized : null; +} diff --git a/open-sse/handlers/chatCore/executorProxy.ts b/open-sse/handlers/chatCore/executorProxy.ts new file mode 100644 index 0000000000..bb9e327ea9 --- /dev/null +++ b/open-sse/handlers/chatCore/executorProxy.ts @@ -0,0 +1,98 @@ +/** + * chatCore upstream-proxy executor resolver (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Extracted from handleChatCore: resolves the executor for a provider honoring the configured + * upstream proxy mode. `native` / disabled → the provider's own executor; `cliproxyapi` → the + * CLIProxyAPI passthrough executor; `fallback` → a wrapper that tries the native executor first and + * retries via CLIProxyAPI on configured failure codes (default 5xx + 429 + network) or on a thrown + * error. Behaviour is byte-identical to the previous inline closure (it only captured `log`). + */ + +import { getExecutor } from "../../executors/index.ts"; +import { getCachedSettings } from "@/lib/db/readCache"; +import { getUpstreamProxyConfigCached } from "./comboContextCache.ts"; + +type LoggerLike = + | { + info?: (...args: unknown[]) => void; + error?: (...args: unknown[]) => void; + warn?: (...args: unknown[]) => void; + } + | null + | undefined; + +export async function resolveExecutorWithProxy(prov: string, log?: LoggerLike) { + const cfg = await getUpstreamProxyConfigCached(prov); + if (!cfg.enabled || cfg.mode === "native") return getExecutor(prov); + + if (cfg.mode === "cliproxyapi") { + log?.info?.("UPSTREAM_PROXY", `${prov} routed through CLIProxyAPI (passthrough)`); + return getExecutor("cliproxyapi"); + } + + // mode === "fallback": try native first, retry via CLIProxyAPI on specific failures + const nativeExec = getExecutor(prov); + const proxyExec = getExecutor("cliproxyapi"); + + // Read custom fallback codes from settings. Default: 5xx + 429 + network errors. + let fallbackCodes: number[] = [429, 500, 502, 503, 504]; + try { + const allSettings = await getCachedSettings(); + if ( + typeof allSettings.cliproxyapi_fallback_codes === "string" && + allSettings.cliproxyapi_fallback_codes.trim() + ) { + const parsed = allSettings.cliproxyapi_fallback_codes + .split(",") + .map((s: string) => Number.parseInt(s.trim(), 10)) + .filter((n: number) => !Number.isNaN(n)); + if (parsed.length > 0) fallbackCodes = parsed; + } + } catch { + /* use defaults */ + } + const isRetryableStatus = (s: number) => fallbackCodes.includes(s) || s === 0; + + const wrapper = Object.create(nativeExec); + wrapper.execute = async (input: { + model: string; + body: unknown; + stream: boolean; + credentials: unknown; + signal?: AbortSignal | null; + log?: unknown; + upstreamExtraHeaders?: Record | null; + }) => { + let result; + try { + result = await nativeExec.execute(input); + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + log?.info?.("UPSTREAM_PROXY", `${prov} native error (${errMsg}), retrying via CLIProxyAPI`); + try { + return await proxyExec.execute(input); + } catch (proxyErr) { + const proxyMsg = proxyErr instanceof Error ? proxyErr.message : String(proxyErr); + log?.error?.("UPSTREAM_PROXY", `${prov} CLIProxyAPI fallback also failed: ${proxyMsg}`); + throw proxyErr; + } + } + + if (!isRetryableStatus(result.response.status)) { + return result; + } + log?.info?.( + "UPSTREAM_PROXY", + `${prov} native failed (${result.response.status}), retrying via CLIProxyAPI` + ); + try { + return await proxyExec.execute(input); + } catch (proxyErr) { + const proxyMsg = proxyErr instanceof Error ? proxyErr.message : String(proxyErr); + log?.error?.("UPSTREAM_PROXY", `${prov} CLIProxyAPI fallback also failed: ${proxyMsg}`); + throw proxyErr; + } + }; + return wrapper; +} diff --git a/open-sse/handlers/chatCore/gamificationEvent.ts b/open-sse/handlers/chatCore/gamificationEvent.ts new file mode 100644 index 0000000000..ab170667e3 --- /dev/null +++ b/open-sse/handlers/chatCore/gamificationEvent.ts @@ -0,0 +1,28 @@ +/** + * chatCore request gamification event (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Extracted from handleChatCore: emits the per-request "request" gamification event, + * fire-and-forget and fail-open. Shared by the non-streaming and streaming success paths (the + * inline block was duplicated verbatim in both). Guards on a missing api-key id and never throws + * to the caller — the underlying emit is intentionally not awaited; behaviour is byte-identical to + * the previous inline blocks. + */ + +export async function emitRequestGamificationEvent(args: { + apiKeyId: string | null | undefined; + model: string | null | undefined; + provider: string | null | undefined; +}): Promise { + if (!args.apiKeyId) return; + try { + const { emitGamificationEvent } = await import("@/lib/gamification/events"); + emitGamificationEvent({ + apiKeyId: args.apiKeyId, + action: "request", + metadata: { model: args.model, provider: args.provider }, + }); + } catch (_) { + /* gamification optional */ + } +} diff --git a/open-sse/handlers/chatCore/nonStreamingResponseBody.ts b/open-sse/handlers/chatCore/nonStreamingResponseBody.ts new file mode 100644 index 0000000000..c55c7c0261 --- /dev/null +++ b/open-sse/handlers/chatCore/nonStreamingResponseBody.ts @@ -0,0 +1,68 @@ +/** + * chatCore non-streaming response-body reader (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Extracted from chatCore: reads an upstream response body to a string. When the upstream is an SSE / + * NDJSON stream consumed in non-streaming mode, it drains the reader chunk-by-chunk under the body + * timeout and cancels early once a terminal SSE signal is observed; otherwise it falls back to a + * timeout-bounded response.text(). Behaviour is byte-identical to the previous module-level function. + */ + +import { withBodyTimeout } from "../../utils/stream.ts"; +import { FETCH_BODY_TIMEOUT_MS } from "../../config/constants.ts"; +import { createBodyTimeoutError, readStreamChunkWithTimeout } from "./upstreamTimeouts.ts"; +import { + appendNonStreamingSseTerminalSignal, + type NonStreamingSseTerminalState, +} from "./nonStreamingSse.ts"; + +export async function readNonStreamingResponseBody( + response: Response, + contentType: string, + upstreamStream: boolean +): Promise { + if ( + !upstreamStream || + !response.body || + (!contentType.includes("text/event-stream") && !contentType.includes("application/x-ndjson")) + ) { + return withBodyTimeout(response.text()); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const terminalState: NonStreamingSseTerminalState = { + currentEvent: "", + pendingLine: "", + }; + let rawBody = ""; + const deadline = FETCH_BODY_TIMEOUT_MS > 0 ? Date.now() + FETCH_BODY_TIMEOUT_MS : 0; + + try { + while (true) { + const timeoutMs = deadline > 0 ? deadline - Date.now() : 0; + if (deadline > 0 && timeoutMs <= 0) { + throw createBodyTimeoutError(FETCH_BODY_TIMEOUT_MS); + } + + const { done, value } = await readStreamChunkWithTimeout(reader, timeoutMs); + if (done) break; + if (!value) continue; + + const decodedChunk = decoder.decode(value, { stream: true }); + rawBody += decodedChunk; + if (appendNonStreamingSseTerminalSignal(terminalState, decodedChunk)) { + await reader.cancel("non-streaming bridge consumed terminal SSE event").catch(() => {}); + break; + } + } + } catch (error) { + await reader.cancel(error).catch(() => {}); + throw error; + } finally { + rawBody += decoder.decode(); + reader.releaseLock(); + } + + return rawBody; +} diff --git a/open-sse/handlers/chatCore/nonStreamingResponseParse.ts b/open-sse/handlers/chatCore/nonStreamingResponseParse.ts new file mode 100644 index 0000000000..fcdf4d072e --- /dev/null +++ b/open-sse/handlers/chatCore/nonStreamingResponseParse.ts @@ -0,0 +1,135 @@ +/** + * chatCore non-streaming response parsing/classification (Quality Gate v2 / Fase 9 — chatCore + * god-file decomposition, #3501 — response-handling slice of executeProviderRequest). + * + * Extracted from handleChatCore's `if (!stream)` block: reads the upstream non-streaming body, + * detects whether it is an event stream (SSE / NDJSON) buffered for a non-streaming client, and + * parses it into a JSON response body. Returns a discriminated union describing the outcome + * (`ok` | `invalid_sse` | `invalid_json`) and leaves every persistence side-effect + * (appendRequestLog / persistAttemptLogs / persistFailureUsage / trackPendingRequest / + * createErrorResult) to the handler, so behaviour is observably identical to the previous inline + * block. Pure with respect to handler state (only buffering debug/warn logs as a side effect). + */ + +import { normalizePayloadForLog } from "@/lib/logPayloads"; +import { extractSSEErrorMessage } from "../sseParser.ts"; +import { readNonStreamingResponseBody } from "./nonStreamingResponseBody.ts"; +import { + normalizeNonStreamingEventPayload, + parseNonStreamingSSEPayload, + shouldTreatBufferedEventResponseAsExpected, +} from "./nonStreamingSse.ts"; + +type LoggerLike = + | { + debug?: (...args: unknown[]) => void; + warn?: (...args: unknown[]) => void; + } + | null + | undefined; + +export type NonStreamingParseResult = + | { + kind: "ok"; + responseBody: unknown; + responsePayloadFormat: string; + looksLikeSSE: boolean; + normalizedProviderPayload: unknown; + } + | { + kind: "invalid_sse"; + message: string; + looksLikeSSE: true; + normalizedProviderPayload: unknown; + } + | { + kind: "invalid_json"; + message: string; + detailedError: string; + looksLikeSSE: false; + normalizedProviderPayload: unknown; + }; + +export async function parseNonStreamingResponseBody(opts: { + providerResponse: Response; + upstreamStream: boolean; + providerHeaders: Record | Headers | null | undefined; + finalBody: unknown; + targetFormat: string; + model: string; + log?: LoggerLike; +}): Promise { + const { providerResponse, upstreamStream, providerHeaders, finalBody, targetFormat, model, log } = + opts; + + const contentType = (providerResponse.headers.get("content-type") || "").toLowerCase(); + const rawBody = await readNonStreamingResponseBody(providerResponse, contentType, upstreamStream); + const normalizedProviderPayload = normalizePayloadForLog(rawBody); + const looksLikeSSE = + contentType.includes("text/event-stream") || + contentType.includes("application/x-ndjson") || + /(^|\n)\s*(event|data):/m.test(rawBody); + + if (looksLikeSSE) { + const streamPayload = normalizeNonStreamingEventPayload(rawBody, contentType); + const streamKind = contentType.includes("application/x-ndjson") ? "NDJSON" : "SSE"; + if (shouldTreatBufferedEventResponseAsExpected(upstreamStream, providerHeaders, finalBody)) { + log?.debug?.( + "STREAM", + `Buffering upstream ${streamKind} response for non-streaming client request` + ); + } else { + log?.warn?.( + "STREAM", + `Unexpected ${streamKind} response for non-streaming request — buffering` + ); + } + // Upstream returned an event stream for a non-streaming client; convert best-effort to JSON. + const parsedFromSSE = parseNonStreamingSSEPayload(streamPayload, targetFormat, model); + + if (!parsedFromSSE) { + // Some executors (e.g. the Devin/Windsurf CLI) always emit text/event-stream, signalling + // failure with an error-only chunk (`data: {"error":{"message":"Devin CLI not found..."}}`) + // that carries no `choices`. Surface that real, sanitized message instead of the generic 502 + // so the actionable error is not swallowed (#3324). + const surfacedSseError = extractSSEErrorMessage(streamPayload); + const invalidSseMessage = + surfacedSseError || "Invalid SSE response for non-streaming request"; + return { + kind: "invalid_sse", + message: invalidSseMessage, + looksLikeSSE: true, + normalizedProviderPayload, + }; + } + + return { + kind: "ok", + responseBody: parsedFromSSE.body, + responsePayloadFormat: parsedFromSSE.format, + looksLikeSSE: true, + normalizedProviderPayload, + }; + } + + try { + const responseBody = rawBody ? JSON.parse(rawBody) : {}; + return { + kind: "ok", + responseBody, + responsePayloadFormat: targetFormat, + looksLikeSSE: false, + normalizedProviderPayload, + }; + } catch (err) { + const detailedError = `Invalid JSON response from provider (error: ${err instanceof Error ? err.message : String(err)}): ${rawBody.substring(0, 1000)}`; + const invalidJsonMessage = "Invalid JSON response from provider"; + return { + kind: "invalid_json", + message: invalidJsonMessage, + detailedError, + looksLikeSSE: false, + normalizedProviderPayload, + }; + } +} diff --git a/open-sse/handlers/chatCore/nonStreamingUsageStats.ts b/open-sse/handlers/chatCore/nonStreamingUsageStats.ts new file mode 100644 index 0000000000..d7514e5d44 --- /dev/null +++ b/open-sse/handlers/chatCore/nonStreamingUsageStats.ts @@ -0,0 +1,88 @@ +/** + * chatCore non-streaming usage-stats persistence (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501 — response-handling slice of executeProviderRequest). + * + * Extracted from handleChatCore's non-streaming success path: records per-request usage analytics + * for a successful non-streaming response — an optional trace console line, the fire-and-forget + * `saveRequestUsage` row, and the per-api-key billable-token counter. Side-effect only (no handler + * state is mutated, nothing is returned); best-effort, every write swallows its own errors. The + * per-request context is threaded via `ctx` so the call site stays byte-identical; behaviour is + * unchanged. + */ + +import { saveRequestUsage } from "@/lib/usageDb"; +import { formatUsageLog } from "@/lib/usage/tokenAccounting"; +import { COLORS } from "../../utils/stream.ts"; +import { recordTokenUsage } from "../../services/tokenLimitCounter.ts"; +import { computeBillableTokens } from "./upstreamTimeouts.ts"; +import { type EffectiveServiceTier } from "./serviceTier.ts"; + +export type RecordNonStreamingUsageStatsContext = { + traceEnabled: boolean; + provider: string | null | undefined; + connectionId: string | null | undefined; + model: string | null | undefined; + startTime: number; + apiKeyInfo: { id?: string | null; name?: string | null } | null | undefined; + effectiveServiceTier: EffectiveServiceTier; + isCombo: boolean; + comboStrategy: string | null | undefined; +}; + +function logUsageTrace( + usage: object, + provider: string | null | undefined, + connectionId: string | null | undefined +): void { + const msg = `[${new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" })}] 📊 [USAGE] ${provider?.toUpperCase()} | ${formatUsageLog(usage)}${connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""}`; + console.log(`${COLORS.green}${msg}${COLORS.reset}`); +} + +function persistUsageRow(usage: object, ctx: RecordNonStreamingUsageStatsContext): void { + const { provider, connectionId, model, startTime, apiKeyInfo, effectiveServiceTier } = ctx; + saveRequestUsage({ + provider: provider || "unknown", + model: model || "unknown", + tokens: usage, + status: "200", + success: true, + latencyMs: Date.now() - startTime, + timeToFirstTokenMs: Date.now() - startTime, + errorCode: null, + timestamp: new Date().toISOString(), + connectionId: connectionId || undefined, + apiKeyId: apiKeyInfo?.id || undefined, + apiKeyName: apiKeyInfo?.name || undefined, + serviceTier: effectiveServiceTier, + comboStrategy: ctx.isCombo ? ctx.comboStrategy || undefined : undefined, + }).catch((err) => { + console.error("Failed to save usage stats:", err.message); + }); +} + +function recordBillableTokens( + usage: object, + apiKeyInfo: RecordNonStreamingUsageStatsContext["apiKeyInfo"], + provider: string | null | undefined, + model: string | null | undefined +): void { + if (!apiKeyInfo?.id) return; + try { + const billable = computeBillableTokens(usage); + if (billable > 0) + recordTokenUsage(apiKeyInfo.id, provider || "unknown", model || "unknown", billable); + } catch { + // never block the response on counter recording + } +} + +export function recordNonStreamingUsageStats( + usage: unknown, + ctx: RecordNonStreamingUsageStatsContext +): void { + if (!usage || typeof usage !== "object") return; + + if (ctx.traceEnabled) logUsageTrace(usage, ctx.provider, ctx.connectionId); + persistUsageRow(usage, ctx); + recordBillableTokens(usage, ctx.apiKeyInfo, ctx.provider, ctx.model); +} diff --git a/open-sse/handlers/chatCore/pluginOnResponse.ts b/open-sse/handlers/chatCore/pluginOnResponse.ts new file mode 100644 index 0000000000..e5eb90cea3 --- /dev/null +++ b/open-sse/handlers/chatCore/pluginOnResponse.ts @@ -0,0 +1,34 @@ +/** + * chatCore plugin onResponse hook (Quality Gate v2 / Fase 9 — chatCore god-file decomposition, + * #3501). + * + * Extracted from handleChatCore's streaming finalization: runs the registered plugin `onResponse` + * hooks for a completed (status 200) response. Fire-and-forget and fail-open — the inner run is + * not awaited and both the dynamic import and the run swallow their own errors, so a misbehaving + * plugin never affects the response. Behaviour is byte-identical to the previous inline block. + */ + +export async function runPluginOnResponseHook(args: { + requestId: string; + body: unknown; + model: string | null | undefined; + provider: string | null | undefined; + apiKeyInfo: unknown; +}): Promise { + try { + const { runOnResponse } = await import("@/lib/plugins/hooks"); + runOnResponse( + { + requestId: args.requestId, + body: args.body, + model: args.model, + provider: args.provider, + apiKeyInfo: args.apiKeyInfo, + metadata: {}, + }, + { status: 200 } + ).catch(() => {}); + } catch (_) { + /* plugin onResponse optional */ + } +} diff --git a/open-sse/handlers/chatCore/quotaShareConsumption.ts b/open-sse/handlers/chatCore/quotaShareConsumption.ts new file mode 100644 index 0000000000..e38a413e8b --- /dev/null +++ b/open-sse/handlers/chatCore/quotaShareConsumption.ts @@ -0,0 +1,38 @@ +/** + * chatCore quota-share consumption POST-hook (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Extracted from handleChatCore's non-streaming success path (B/F7): schedules a shared-quota + * consumption record for a completed non-streaming response. Fire-and-forget and fail-open — guards + * on a missing api-key id / connection id and never throws to the caller. Behaviour is + * byte-identical to the previous inline block. + */ + +type LoggerLike = { warn?: (...args: unknown[]) => void } | null | undefined; + +export async function scheduleQuotaShareConsumption(args: { + apiKeyId: string | null | undefined; + connectionId: string | null | undefined; + provider: string | null | undefined; + usage: unknown; + estimatedCost: number; + log?: LoggerLike; +}): Promise { + if (!args.apiKeyId || !args.connectionId) return; + try { + const { scheduleRecordConsumption, buildConsumptionCost } = await import( + "@/lib/quota/spendRecorder" + ); + scheduleRecordConsumption( + { + apiKeyId: args.apiKeyId, + connectionId: args.connectionId, + provider: args.provider ?? "unknown", + cost: buildConsumptionCost(args.usage, args.estimatedCost), + }, + args.log + ); + } catch (_) { + // Outer fail-open — never throws to caller + } +} diff --git a/open-sse/handlers/chatCore/skillsFormat.ts b/open-sse/handlers/chatCore/skillsFormat.ts new file mode 100644 index 0000000000..8007d99564 --- /dev/null +++ b/open-sse/handlers/chatCore/skillsFormat.ts @@ -0,0 +1,33 @@ +/** + * chatCore skills-format mappers (Quality Gate v2 / Fase 9 — chatCore god-file decomposition, #3501). + * + * Pure mappers extracted from chatCore: translate the request wire format into the skills provider + * bucket and the skills model id used when injecting skill context. Side-effect-free; behaviour is + * byte-identical to the previous module-level functions. + */ + +import { FORMATS } from "../../translator/formats.ts"; + +export function getSkillsProviderForFormat( + format: string +): "openai" | "anthropic" | "google" | "other" { + switch (format) { + case FORMATS.CLAUDE: + return "anthropic"; + case FORMATS.GEMINI: + return "google"; + default: + return "openai"; + } +} + +export function getSkillsModelIdForFormat(format: string): string { + switch (format) { + case FORMATS.CLAUDE: + return "claude"; + case FORMATS.GEMINI: + return "gemini"; + default: + return "openai"; + } +} diff --git a/open-sse/handlers/chatCore/stageTrace.ts b/open-sse/handlers/chatCore/stageTrace.ts new file mode 100644 index 0000000000..d1f49685b0 --- /dev/null +++ b/open-sse/handlers/chatCore/stageTrace.ts @@ -0,0 +1,30 @@ +/** + * chatCore per-request stage trace (Quality Gate v2 / Fase 9 — chatCore god-file decomposition, + * #3501). + * + * Extracted from handleChatCore: emits a `[STAGE_TRACE]` checkpoint log (trace id + elapsed ms + + * optional serialized extra) when tracing is enabled, so a hung request reveals which await it was + * sitting on. Per-request inputs (enable flag, start time, trace id, logger) are threaded via `ctx` + * so the call sites stay byte-identical; behaviour is unchanged. + */ + +type LoggerLike = { info?: (...args: unknown[]) => void } | null | undefined; + +export function stageTrace( + label: string, + extra: Record | undefined, + ctx: { traceEnabled: boolean; startTime: number; traceId: string; log: LoggerLike } +) { + const { traceEnabled, startTime, traceId, log } = ctx; + if (!traceEnabled) return; + const elapsed = Date.now() - startTime; + let suffix = ""; + if (extra) { + try { + suffix = ` ${JSON.stringify(extra)}`; + } catch { + suffix = " [unserializable]"; + } + } + log?.info?.("STAGE_TRACE", `${traceId} ${label} t=${elapsed}ms${suffix}`); +} diff --git a/open-sse/handlers/chatCore/streamErrorResult.ts b/open-sse/handlers/chatCore/streamErrorResult.ts new file mode 100644 index 0000000000..77244b611d --- /dev/null +++ b/open-sse/handlers/chatCore/streamErrorResult.ts @@ -0,0 +1,58 @@ +/** + * chatCore streaming error-result helpers (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Extracted from chatCore: identify semaphore capacity errors, build a sanitized SSE error result + * (an `data: {...}\n\ndata: [DONE]\n\n` body wrapped in an event-stream Response), and pull a string + * error code off an unknown error. Side-effect-free; behaviour is byte-identical to the previous + * module-level functions. + */ + +import { buildErrorBody } from "../../utils/error.ts"; + +export function isSemaphoreCapacityError(error: unknown): error is Error & { code: string } { + return ( + !!error && + typeof error === "object" && + ((error as { code?: unknown }).code === "SEMAPHORE_TIMEOUT" || + (error as { code?: unknown }).code === "SEMAPHORE_QUEUE_FULL") + ); +} + +export function createStreamingErrorResult( + statusCode: number, + message: string, + code?: string, + type?: string +) { + const errorBody = buildErrorBody(statusCode, message); + if (code) { + errorBody.error.code = code; + } + if (type) { + errorBody.error.type = type; + } + + const body = `data: ${JSON.stringify(errorBody)}\n\ndata: [DONE]\n\n`; + + return { + success: false as const, + status: statusCode, + error: message, + response: new Response(body, { + status: statusCode, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }, + }), + }; +} + +export function getUpstreamErrorIdentifier(error: unknown): string | undefined { + if (!error || typeof error !== "object") return undefined; + const value = (error as { code?: unknown }).code; + return typeof value === "string" && value.length > 0 ? value : undefined; +} diff --git a/open-sse/handlers/chatCore/streamFinalize.ts b/open-sse/handlers/chatCore/streamFinalize.ts new file mode 100644 index 0000000000..83ef8dec7e --- /dev/null +++ b/open-sse/handlers/chatCore/streamFinalize.ts @@ -0,0 +1,48 @@ +/** + * chatCore stream finalize wrapper (Quality Gate v2 / Fase 9 — chatCore god-file decomposition, + * #3501). + * + * Extracted from chatCore: wraps a ReadableStream so a `finalize` callback runs exactly once when the + * stream is fully drained, errors, or is cancelled. Side-effect-free other than the wrapped stream's + * own lifecycle; behaviour is byte-identical to the previous module-level function. + */ + +export function wrapReadableStreamWithFinalize( + readable: ReadableStream, + finalize: () => void +): ReadableStream { + const reader = readable.getReader(); + let finalized = false; + + const runFinalize = () => { + if (finalized) return; + finalized = true; + finalize(); + }; + + return new ReadableStream({ + async pull(controller) { + try { + const { done, value } = await reader.read(); + if (done) { + runFinalize(); + controller.close(); + return; + } + controller.enqueue(value); + } catch (error) { + runFinalize(); + controller.error(error); + } + }, + + async cancel(reason) { + runFinalize(); + try { + await reader.cancel(reason); + } catch (error) { + // Ignored + } + }, + }); +} diff --git a/open-sse/handlers/chatCore/streamingCost.ts b/open-sse/handlers/chatCore/streamingCost.ts new file mode 100644 index 0000000000..634c1ba973 --- /dev/null +++ b/open-sse/handlers/chatCore/streamingCost.ts @@ -0,0 +1,37 @@ +/** + * chatCore streaming per-request cost recording (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Extracted from handleChatCore's onStreamComplete: resolves the real per-request cost for a + * completed streaming response and records it against the api key. onStreamComplete is synchronous, + * so this is a sync fire-and-forget driven through calculateCost().then().catch() that never throws + * to the caller. calculateCost and recordCost are injected so the hook stays decoupled. Behaviour + * is byte-identical to the previous inline block. + */ + +type CostResolver = ( + provider: string, + model: string, + usage: Record | null | undefined, + options: { serviceTier?: string } +) => Promise; + +export function recordStreamingCost(args: { + apiKeyId: string | null | undefined; + provider: string | null | undefined; + model: string | null | undefined; + streamUsage: Record | null | undefined; + serviceTier?: string; + calculateCost: CostResolver; + recordCost: (apiKeyId: string, cost: number) => void; +}): void { + if (!args.apiKeyId || !args.streamUsage) return; + + const apiKeyId = args.apiKeyId; + args + .calculateCost(args.provider, args.model, args.streamUsage, { serviceTier: args.serviceTier }) + .then((estimatedCost) => { + if (estimatedCost > 0) args.recordCost(apiKeyId, estimatedCost); + }) + .catch(() => {}); +} diff --git a/open-sse/handlers/chatCore/streamingQuotaShare.ts b/open-sse/handlers/chatCore/streamingQuotaShare.ts new file mode 100644 index 0000000000..8d667a227d --- /dev/null +++ b/open-sse/handlers/chatCore/streamingQuotaShare.ts @@ -0,0 +1,54 @@ +/** + * chatCore streaming quota-share consumption POST-hook (Quality Gate v2 / Fase 9 — chatCore + * god-file decomposition, #3501). + * + * Extracted from handleChatCore's onStreamComplete (B/F7): records shared-quota consumption for a + * completed streaming response, resolving the real per-request cost via the injected calculateCost + * so USD-unit pools accrue on streaming traffic too. onStreamComplete is synchronous, so this is a + * sync fire-and-forget that drives the work through import().then().catch() and never throws to the + * caller. Behaviour is byte-identical to the previous inline block. + */ + +type LoggerLike = { warn?: (...args: unknown[]) => void } | null | undefined; +type CostResolver = ( + provider: string, + model: string, + usage: Record | null | undefined, + options: { serviceTier?: string } +) => Promise; + +export function scheduleStreamingQuotaShareConsumption(args: { + apiKeyId: string | null | undefined; + connectionId: string | null | undefined; + provider: string | null | undefined; + model: string | null | undefined; + streamUsage: unknown; + streamStatus: number; + serviceTier?: string; + calculateCost: CostResolver; + log?: LoggerLike; +}): void { + if (!args.apiKeyId || !args.connectionId || args.streamStatus !== 200) return; + + const quotaApiKeyId = args.apiKeyId; + const quotaConnectionId = args.connectionId; + // onStreamComplete is sync — use .then() (fire-and-forget, fail-open) instead of await + import("@/lib/quota/spendRecorder") + .then(({ recordStreamingConsumption }) => + recordStreamingConsumption( + { + apiKeyId: quotaApiKeyId, + connectionId: quotaConnectionId, + provider: args.provider, + model: args.model, + streamUsage: args.streamUsage, + streamStatus: args.streamStatus, + serviceTier: args.serviceTier, + }, + { calculateCost: args.calculateCost, log: args.log } + ) + ) + .catch(() => { + // Outer fail-open — never throws to caller + }); +} diff --git a/open-sse/handlers/chatCore/streamingUsageStats.ts b/open-sse/handlers/chatCore/streamingUsageStats.ts new file mode 100644 index 0000000000..0561f8099b --- /dev/null +++ b/open-sse/handlers/chatCore/streamingUsageStats.ts @@ -0,0 +1,77 @@ +/** + * chatCore streaming usage-stats persistence (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Extracted from handleChatCore's onStreamComplete: records per-request usage analytics for a + * completed streaming response — the fire-and-forget `saveRequestUsage` row and the per-api-key + * billable-token counter (only on a 200 stream). Side-effect only (no handler state is mutated, + * nothing is returned); best-effort, every write swallows its own errors. The per-request context + * is threaded via `ctx` so the call site stays byte-identical; behaviour is unchanged. The + * compression usage-receipt attach stays in the handler (it is a handler-bound closure). + */ + +import { saveRequestUsage } from "@/lib/usageDb"; +import { recordTokenUsage } from "../../services/tokenLimitCounter.ts"; +import { computeBillableTokens } from "./upstreamTimeouts.ts"; +import { type EffectiveServiceTier } from "./serviceTier.ts"; + +export type RecordStreamingUsageStatsContext = { + provider: string | null | undefined; + model: string | null | undefined; + streamStatus: number; + startTime: number; + ttft: number; + streamErrorCode: string | null | undefined; + connectionId: string | null | undefined; + apiKeyInfo: { id?: string | null; name?: string | null } | null | undefined; + effectiveServiceTier: EffectiveServiceTier; + isCombo: boolean; + comboStrategy: string | null | undefined; +}; + +function persistStreamingUsageRow(usage: object, ctx: RecordStreamingUsageStatsContext): void { + const { provider, model, streamStatus, startTime, ttft, streamErrorCode } = ctx; + saveRequestUsage({ + provider: provider || "unknown", + model: model || "unknown", + tokens: usage, + status: String(streamStatus), + success: streamStatus === 200, + latencyMs: Date.now() - startTime, + timeToFirstTokenMs: ttft, + errorCode: streamStatus === 200 ? null : streamErrorCode || String(streamStatus), + timestamp: new Date().toISOString(), + connectionId: ctx.connectionId || undefined, + apiKeyId: ctx.apiKeyInfo?.id || undefined, + apiKeyName: ctx.apiKeyInfo?.name || undefined, + serviceTier: ctx.effectiveServiceTier, + comboStrategy: ctx.isCombo ? ctx.comboStrategy || undefined : undefined, + }).catch((err) => { + console.error("Failed to save usage stats:", err.message); + }); +} + +function recordStreamingBillableTokens(usage: object, ctx: RecordStreamingUsageStatsContext): void { + if (!ctx.apiKeyInfo?.id || ctx.streamStatus !== 200) return; + try { + const billable = computeBillableTokens(usage); + if (billable > 0) + recordTokenUsage( + ctx.apiKeyInfo.id, + ctx.provider || "unknown", + ctx.model || "unknown", + billable + ); + } catch { + // never block the stream on counter recording + } +} + +export function recordStreamingUsageStats( + usage: unknown, + ctx: RecordStreamingUsageStatsContext +): void { + if (!usage || typeof usage !== "object") return; + persistStreamingUsageRow(usage, ctx); + recordStreamingBillableTokens(usage, ctx); +} diff --git a/open-sse/handlers/chatCore/upstreamBody.ts b/open-sse/handlers/chatCore/upstreamBody.ts new file mode 100644 index 0000000000..51d6c74c11 --- /dev/null +++ b/open-sse/handlers/chatCore/upstreamBody.ts @@ -0,0 +1,141 @@ +/** + * chatCore upstream body preparation (Quality Gate v2 / Fase 9 — chatCore god-file decomposition, + * #3501 — first internal sub-slice of executeProviderRequest). + * + * Extracted from handleChatCore's execute() closure: prepares the body actually sent upstream for a + * given target model. Pins the model id, applies the configured payload rules, truncates the tool + * list to the provider's effective limit, backfills a default `user` for Qwen OAuth requests, and + * injects an OpenAI `prompt_cache_key` for caching-capable providers. Pure with respect to handler + * state (returns a fresh body, only logs as a side effect); behaviour is byte-identical to the + * previous inline block. Split into small private steps so each stays under the complexity cap. + */ + +import { + applyConfiguredPayloadRules, + resolvePayloadRuleProtocols, +} from "../../services/payloadRules.ts"; +import { getEffectiveToolLimit } from "../../services/toolLimitDetector.ts"; +import { providerSupportsCaching } from "../../utils/cacheControlPolicy.ts"; +import { MAX_TOOLS_LIMIT } from "../../config/constants.ts"; +import { FORMATS } from "../../translator/formats.ts"; + +type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined; +type Body = Record; +type CredentialsLike = { apiKey?: unknown; accessToken?: unknown } | null | undefined; + +function buildAppliedRulesSummary( + applied: Array<{ type: string; path: string; value?: unknown }> +): string { + return applied + .map((rule) => { + if (rule.type === "filter") return `${rule.type}:${rule.path}`; + const serializedValue = JSON.stringify(rule.value); + const safeValue = + typeof serializedValue === "string" && serializedValue.length > 80 + ? `${serializedValue.slice(0, 77)}...` + : serializedValue; + return `${rule.type}:${rule.path}=${safeValue}`; + }) + .join(", "); +} + +function truncateToolList(bodyToSend: Body, provider: string | null | undefined, log?: LoggerLike): Body { + const effectiveToolLimit = getEffectiveToolLimit(provider); + if ( + effectiveToolLimit < MAX_TOOLS_LIMIT && + Array.isArray(bodyToSend.tools) && + bodyToSend.tools.length > effectiveToolLimit + ) { + const truncatedTools = bodyToSend.tools.slice(0, effectiveToolLimit); + bodyToSend = { ...bodyToSend, tools: truncatedTools }; + log?.debug?.( + "TOOL_LIMIT", + `Truncated ${(bodyToSend.tools as unknown[]).length} tools to ${effectiveToolLimit} for ${provider}` + ); + } + return bodyToSend; +} + +// Qwen OAuth rejects requests without a non-empty `user` field. Some minimal OpenAI-compatible +// clients omit it, so we backfill a stable default only for OAuth mode (API key mode is unaffected). +function backfillQwenOAuthUser( + bodyToSend: Body, + provider: string | null | undefined, + credentials: CredentialsLike, + log?: LoggerLike +): Body { + const hasValidQwenUser = + typeof bodyToSend.user === "string" && bodyToSend.user.trim().length > 0; + const isQwenOAuthRequest = + provider === "qwen" && + !credentials?.apiKey && + typeof credentials?.accessToken === "string" && + credentials.accessToken.trim().length > 0; + if (isQwenOAuthRequest && !hasValidQwenUser) { + bodyToSend = { ...bodyToSend, user: "omniroute-qwen-oauth" }; + log?.debug?.("QWEN", "Injected fallback user for OAuth request"); + } + return bodyToSend; +} + +// Inject prompt_cache_key only for providers that support it. +async function injectPromptCacheKey( + bodyToSend: Body, + provider: string | null | undefined, + targetFormat: string +): Promise { + if ( + targetFormat === FORMATS.OPENAI && + providerSupportsCaching(provider) && + !bodyToSend.prompt_cache_key && + Array.isArray(bodyToSend.messages) && + !["nvidia", "codex", "xai"].includes(provider) + ) { + const { generatePromptCacheKey } = await import("@/lib/promptCache"); + const cacheKey = generatePromptCacheKey(bodyToSend.messages); + if (cacheKey) { + bodyToSend = { ...bodyToSend, prompt_cache_key: cacheKey }; + } + } + return bodyToSend; +} + +export async function prepareUpstreamBody(opts: { + translatedBody: Body; + modelToCall: string; + provider: string | null | undefined; + targetFormat: string; + credentials: CredentialsLike; + log?: LoggerLike; +}): Promise { + const { translatedBody, modelToCall, provider, targetFormat, credentials, log } = opts; + + let bodyToSend: Body = + translatedBody.model === modelToCall + ? translatedBody + : { ...translatedBody, model: modelToCall }; + const payloadRuleModel = + typeof bodyToSend.model === "string" && bodyToSend.model.length > 0 + ? bodyToSend.model + : modelToCall; + const payloadRuleProtocols = resolvePayloadRuleProtocols({ provider, targetFormat }); + const payloadRuleResult = await applyConfiguredPayloadRules( + bodyToSend, + payloadRuleModel, + payloadRuleProtocols + ); + bodyToSend = payloadRuleResult.payload; + + if (payloadRuleResult.applied.length > 0) { + log?.debug?.( + "PAYLOAD_RULES", + `Applied ${payloadRuleResult.applied.length} rule(s) for ${payloadRuleModel} (${payloadRuleProtocols.join(", ")}): ${buildAppliedRulesSummary(payloadRuleResult.applied)}` + ); + } + + bodyToSend = truncateToolList(bodyToSend, provider, log); + bodyToSend = backfillQwenOAuthUser(bodyToSend, provider, credentials, log); + bodyToSend = await injectPromptCacheKey(bodyToSend, provider, targetFormat); + + return bodyToSend; +} diff --git a/open-sse/package.json b/open-sse/package.json index 171738a4b3..109198faad 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "3.8.34", + "version": "3.8.35", "description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration", "type": "module", "main": "index.js", diff --git a/open-sse/services/__tests__/tierResolver.test.ts b/open-sse/services/__tests__/tierResolver.test.ts index 23047eb757..fe14e0c646 100644 --- a/open-sse/services/__tests__/tierResolver.test.ts +++ b/open-sse/services/__tests__/tierResolver.test.ts @@ -13,6 +13,12 @@ import { classifyTiers, } from "../tierResolver.ts"; import { PROVIDER_TIER } from "../tierTypes.ts"; +import { + DEFAULT_TIER_CONFIG, + LEGACY_FREE_PROVIDERS, + deriveNoAuthFreeProviders, +} from "../tierConfig.ts"; +import { NOAUTH_PROVIDERS } from "@/shared/constants/providers.ts"; describe("TierResolver", () => { // Reset cache between tests @@ -213,4 +219,76 @@ describe("TierResolver", () => { assert.ok(stats[PROVIDER_TIER.CHEAP] >= 1); }); }); + + describe("freeProviders from NOAUTH_PROVIDERS (#4517)", () => { + beforeEach(() => clearTierCache()); + + it("LEGACY_FREE_PROVIDERS keeps the historical explicit list", () => { + for (const id of [ + "kiro", + "qoder", + "pollinations", + "longcat", + "cloudflare-ai", + "qwen", + "gemini-cli", + "nvidia-nim", + "cerebras", + "groq", + ]) { + assert.ok(LEGACY_FREE_PROVIDERS.includes(id), `expected ${id} in LEGACY_FREE_PROVIDERS`); + } + }); + + it("deriveNoAuthFreeProviders includes all chat-tier noAuth providers", () => { + const derived = deriveNoAuthFreeProviders(); + // opencode + mimocode are the ones the bug report called out + assert.ok(derived.includes("opencode"), "opencode should be in derived noAuth-free list"); + assert.ok(derived.includes("mimocode"), "mimocode should be in derived noAuth-free list"); + assert.ok(derived.includes("duckduckgo-web")); + }); + + it("deriveNoAuthFreeProviders excludes non-LLM noAuth providers", () => { + const derived = deriveNoAuthFreeProviders(); + assert.ok( + !derived.includes("veoaifree-web"), + "veoaifree-web (serviceKinds: video) must not be classified as chat-free" + ); + }); + + it("DEFAULT_TIER_CONFIG.freeProviders contains the union of legacy + noAuth-derived", () => { + const expected = new Set([...LEGACY_FREE_PROVIDERS, ...deriveNoAuthFreeProviders()]); + const actual = new Set(DEFAULT_TIER_CONFIG.freeProviders); + assert.deepEqual(actual, expected, "freeProviders must be the union, deduplicated"); + }); + + it("classifyTier classifies opencode/big-pickle as free via noAuth derivation", () => { + // No provider override, no cost-based match (big-pickle has no KNOWN_MODEL_PRICING row). + // The fix is that 'opencode' is now in freeProviders. + const result = classifyTier("opencode", "big-pickle"); + assert.equal(result.tier, PROVIDER_TIER.FREE); + assert.equal(result.hasFreeTier, true); + }); + + it("classifyTier classifies mimocode/mimo-auto as free via noAuth derivation", () => { + const result = classifyTier("mimocode", "mimo-auto"); + assert.equal(result.tier, PROVIDER_TIER.FREE); + assert.equal(result.hasFreeTier, true); + }); + + it("classifyTier still returns cheap for paid glm-5.1 (no regression)", () => { + // glm-5.1 is not in freeProviders, costs $0.50/M → cheap tier. + // Make sure the new noAuth derivation didn't accidentally pull it into free. + const result = classifyTier("opencode-go", "glm-5.1"); + assert.equal(result.tier, PROVIDER_TIER.CHEAP); + }); + + it("userConfig.freeProviders is merged on top of the noAuth-derived list", () => { + // Re-merge with a new free provider (e.g. local-llama) and confirm it's added. + setTierConfig({ freeProviders: ["local-llama"] }); + const result = classifyTier("local-llama", "anything"); + assert.equal(result.tier, PROVIDER_TIER.FREE); + clearTierCache(); + }); + }); }); diff --git a/open-sse/services/autoCombo/__tests__/autoCombo.test.ts b/open-sse/services/autoCombo/__tests__/autoCombo.test.ts index 0ae1ec01ea..6ee4b5997c 100644 --- a/open-sse/services/autoCombo/__tests__/autoCombo.test.ts +++ b/open-sse/services/autoCombo/__tests__/autoCombo.test.ts @@ -11,6 +11,8 @@ import { getTaskTypes, getModelsDevTierFitness, invalidateFitnessCache, + setUserFitnessOverride, + clearUserFitnessOverride, } from "../taskFitness"; import { SelfHealingManager } from "../selfHealing"; import { MODE_PACKS, getModePack, getModePackNames } from "../modePacks"; @@ -91,6 +93,64 @@ describe("Task Fitness", () => { const normalScore = getTaskFitness("some-random-model", "coding"); expect(coderScore).toBeGreaterThan(normalScore); }); + + describe("-free alias resolution (#4517)", () => { + beforeEach(() => invalidateFitnessCache()); + + it("returns the base model's arena_elo when given a -free variant", async () => { + // The fix: getTaskFitnessWithSource strips a trailing "-free" suffix + // and re-queries arena_elo with the base id. We seed an arena_elo + // row directly via the DB module, look up the free variant, and + // assert the alias path returns the base score with source + // "arena_elo_free_alias". + const baseId = "alias-base-test-4517"; + const freeId = "alias-base-test-4517-free"; + const { upsertModelIntelligence, deleteModelIntelligence } = + await import("../../../../src/lib/db/modelIntelligence.ts"); + // Seed arena_elo on the base id only — no row exists for the free id. + upsertModelIntelligence({ + model: baseId, + source: "arena_elo", + category: "coding", + score: 0.42, + eloRaw: 1500, + confidence: "high", + expiresAt: null, + }); + invalidateFitnessCache(); + try { + const result = getTaskFitnessWithSource(freeId, "coding"); + // Without the fix: result.source would be "wildcard_boost" (0.5 default). + // With the fix: result.source is "arena_elo_free_alias" with score 0.42. + expect(result.score).toBeCloseTo(0.42, 5); + expect(result.source).toBe("arena_elo_free_alias"); + } finally { + deleteModelIntelligence(baseId, "arena_elo", "coding"); + invalidateFitnessCache(); + } + }); + + it("does not strip -free when arena_elo is present on the literal model id", () => { + // If both "foo-free" and "foo" have arena_elo rows, the literal "foo-free" + // wins (we never go through the alias path). This protects future + // benchmark uploads that specifically tag free tiers. + setUserFitnessOverride("foo-free", "coding", 0.91); + const result = getTaskFitnessWithSource("foo-free", "coding"); + expect(result.score).toBe(0.91); + expect(result.source).toBe("user_override"); + clearUserFitnessOverride("foo-free", "coding"); + invalidateFitnessCache(); + }); + + it("ignores -free suffix only at the end of the model id", () => { + // "free-something" must NOT be treated as a free alias of "free-something-free" + // — the suffix must be at the end. "mimo-free-edition" is left alone. + // We just confirm no exception is thrown and the lookup returns a number. + const score = getTaskFitness("mimo-free-edition", "coding"); + expect(typeof score).toBe("number"); + expect(score).toBeGreaterThan(0); + }); + }); }); describe("Self-Healing", () => { diff --git a/open-sse/services/autoCombo/taskFitness.ts b/open-sse/services/autoCombo/taskFitness.ts index 404a476089..35b38fae12 100644 --- a/open-sse/services/autoCombo/taskFitness.ts +++ b/open-sse/services/autoCombo/taskFitness.ts @@ -199,11 +199,7 @@ const TIER_TASK_FITNESS: Record> = { const _intelligenceCache = new Map(); -function queryModelIntelligence( - model: string, - category: string, - source: string, -): number | null { +function queryModelIntelligence(model: string, category: string, source: string): number | null { const cacheKey = `${model}:${category}:${source}`; if (_intelligenceCache.has(cacheKey)) { return _intelligenceCache.get(cacheKey)!; @@ -233,8 +229,7 @@ interface ModelCapRow { function deriveTierFromCapabilities(cap: ModelCapRow): string { if (cap.reasoning === true) return "premium"; - if (cap.tool_call === true && (cap.limit_context ?? 0) >= 128000) - return "standard"; + if (cap.tool_call === true && (cap.limit_context ?? 0) >= 128000) return "standard"; if (cap.tool_call === true) return "fast"; return "budget"; } @@ -244,10 +239,7 @@ function loadModelCapabilities(): Record | null { try { const db = getDbInstance(); - const rows = db.prepare("SELECT * FROM model_capabilities").all() as Record< - string, - unknown - >[]; + const rows = db.prepare("SELECT * FROM model_capabilities").all() as Record[]; const cache: Record = {}; for (const row of rows) { @@ -267,8 +259,7 @@ function loadModelCapabilities(): Record | null { : row.reasoning === false || row.reasoning === 0 ? false : null, - limit_context: - typeof row.limit_context === "number" ? row.limit_context : null, + limit_context: typeof row.limit_context === "number" ? row.limit_context : null, }; } @@ -279,18 +270,11 @@ function loadModelCapabilities(): Record | null { } } -export function getModelsDevTierFitness( - model: string, - taskType: string, -): number | null { +export function getModelsDevTierFitness(model: string, taskType: string): number | null { const normalizedModel = model.toLowerCase(); const normalizedTask = taskType.toLowerCase(); - const dbScore = queryModelIntelligence( - normalizedModel, - normalizedTask, - "models_dev_tier", - ); + const dbScore = queryModelIntelligence(normalizedModel, normalizedTask, "models_dev_tier"); if (dbScore !== null) return dbScore; const caps = loadModelCapabilities(); @@ -308,10 +292,7 @@ export function getModelsDevTierFitness( // ─── Resolution chain ─────────────────────────────────────────────────── -function lookupStaticFitnessTable( - normalizedModel: string, - normalizedTask: string, -): number | null { +function lookupStaticFitnessTable(normalizedModel: string, normalizedTask: string): number | null { const table = FITNESS_TABLE[normalizedTask] || FITNESS_TABLE.default; for (const [pattern, score] of Object.entries(table)) { if (normalizedModel.includes(pattern)) return score; @@ -319,10 +300,7 @@ function lookupStaticFitnessTable( return null; } -function lookupWildcardBoosts( - normalizedModel: string, - normalizedTask: string, -): number { +function lookupWildcardBoosts(normalizedModel: string, normalizedTask: string): number { let baseScore = 0.5; for (const wc of WILDCARD_BOOSTS) { if (normalizedModel.includes(wc.pattern) && normalizedTask === wc.taskType) { @@ -338,38 +316,38 @@ export function getTaskFitness(model: string, taskType: string): number { export function getTaskFitnessWithSource( model: string, - taskType: string, + taskType: string ): { score: number; source: string } { const normalizedModel = model.toLowerCase(); const normalizedTask = taskType.toLowerCase(); - const userOverride = queryModelIntelligence( - normalizedModel, - normalizedTask, - "user_override", - ); + const userOverride = queryModelIntelligence(normalizedModel, normalizedTask, "user_override"); if (userOverride !== null) { return { score: userOverride, source: "user_override" }; } - const arenaElo = queryModelIntelligence( - normalizedModel, - normalizedTask, - "arena_elo", - ); + // Try arena_elo with the literal model id first (e.g. "mimo-v2.5"). If that's + // a miss and the model id carries a "-free" suffix (e.g. "mimo-v2.5-free"), + // try the un-suffixed base id so free-tier variants inherit the arena_elo + // score of their paid counterpart. This is what operators expect: the + // upstream's `mimo-v2.5` is benchmarked once, and `mimo-v2.5-free` should + // pick up the same signal rather than falling through to the wildcard 0.5 + // and losing every free-vs-paid comparison. + const arenaElo = queryModelIntelligence(normalizedModel, normalizedTask, "arena_elo"); if (arenaElo !== null) { return { score: arenaElo, source: "arena_elo" }; } + const arenaEloBase = lookupFreeAliasArenaElo(normalizedModel, normalizedTask); + if (arenaEloBase !== null) { + return { score: arenaEloBase, source: "arena_elo_free_alias" }; + } const tierScore = getModelsDevTierFitness(normalizedModel, normalizedTask); if (tierScore !== null) { return { score: tierScore, source: "models_dev_tier" }; } - const staticScore = lookupStaticFitnessTable( - normalizedModel, - normalizedTask, - ); + const staticScore = lookupStaticFitnessTable(normalizedModel, normalizedTask); if (staticScore !== null) { return { score: staticScore, source: "fitness_table" }; } @@ -377,35 +355,44 @@ export function getTaskFitnessWithSource( return { score: lookupWildcardBoosts(normalizedModel, normalizedTask), source: "wildcard_boost" }; } -export function setUserFitnessOverride( - model: string, - category: string, - score: number, -): void { +/** Suffix used to mark free-tier model variants (e.g. "mimo-v2.5-free"). */ +const FREE_SUFFIX = "-free"; + +/** + * Strip a trailing "-free" suffix from the model id and re-query arena_elo. + * Returns `null` when the original id has no "-free" suffix, when the base id + * is identical to the original, or when no arena_elo row exists for the base. + * + * Examples: + * "mimo-v2.5-free" → look up "mimo-v2.5" + * "deepseek-v4-flash-free" → look up "deepseek-v4-flash" + * "big-pickle" → no "-free" suffix → return null (skip) + */ +function lookupFreeAliasArenaElo(normalizedModel: string, normalizedTask: string): number | null { + if (!normalizedModel.endsWith(FREE_SUFFIX)) return null; + const baseId = normalizedModel.slice(0, -FREE_SUFFIX.length); + if (baseId.length === 0 || baseId === normalizedModel) return null; + return queryModelIntelligence(baseId, normalizedTask, "arena_elo"); +} + +export function setUserFitnessOverride(model: string, category: string, score: number): void { try { - setUserFitnessOverrideEntry( - model.toLowerCase(), - category.toLowerCase(), - score, - ); + setUserFitnessOverrideEntry(model.toLowerCase(), category.toLowerCase(), score); invalidateFitnessCache(); } catch (err) { throw new Error( - `Failed to set user fitness override for ${model}/${category}: ${err instanceof Error ? err.message : String(err)}`, + `Failed to set user fitness override for ${model}/${category}: ${err instanceof Error ? err.message : String(err)}` ); } } -export function clearUserFitnessOverride( - model: string, - category: string, -): void { +export function clearUserFitnessOverride(model: string, category: string): void { try { deleteUserFitnessOverrideEntry(model.toLowerCase(), category.toLowerCase()); invalidateFitnessCache(); } catch (err) { throw new Error( - `Failed to clear user fitness override for ${model}/${category}: ${err instanceof Error ? err.message : String(err)}`, + `Failed to clear user fitness override for ${model}/${category}: ${err instanceof Error ? err.message : String(err)}` ); } } diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index 8912148c4d..dd386b7775 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -294,8 +294,14 @@ export async function createVirtualAutoCombo( } // #4235 Phase B: narrow the pool by the `auto/:` overlay - // (vision/reasoning capability, free/premium model tier). Fall back to the full - // pool if the filter would empty it — never break routing, just lose the bias. + // (vision/reasoning capability, free/premium model tier). + // + // Default behavior: when the filter yields zero candidates, return an EMPTY + // pool — never silently fall back to the full pool. This makes + // `auto/coding:free` actually mean "free tier only" and prevents a paid + // expensive model from being picked just because no free provider is + // connected. Operators who want the old "never break routing, lose the bias" + // behavior can opt back in via the env var below. let effectivePool = candidatePool; const candidateFilter = spec ? buildAutoCandidateFilter(spec.category, spec.tier) : null; if (candidateFilter) { @@ -304,11 +310,21 @@ export async function createVirtualAutoCombo( ); if (narrowed.length > 0) { effectivePool = narrowed; + } else if ( + process.env.OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL === "true" || + process.env.OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL === "1" + ) { + // Opt-in legacy behavior: warn loudly, then keep the full pool. + log.warn( + "AUTO", + `auto/${spec?.category ?? ""}${spec?.tier ? `:${spec.tier}` : ""} matched no connected models; falling back to the full pool (OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL=true)` + ); } else { log.warn( "AUTO", - `auto/${spec?.category ?? ""}${spec?.tier ? `:${spec.tier}` : ""} matched no connected models; using the full pool` + `auto/${spec?.category ?? ""}${spec?.tier ? `:${spec.tier}` : ""} matched no connected models; returning an empty pool. Set OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL=true to restore the legacy "use full pool" behavior.` ); + effectivePool = []; } } 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/engines/cavemanAdapter.ts b/open-sse/services/compression/engines/cavemanAdapter.ts index 41ea89dc2b..d819357c0f 100644 --- a/open-sse/services/compression/engines/cavemanAdapter.ts +++ b/open-sse/services/compression/engines/cavemanAdapter.ts @@ -1,7 +1,7 @@ import { applyLiteCompression } from "../lite.ts"; import { cavemanCompress } from "../caveman.ts"; import { compressAggressive } from "../aggressive.ts"; -import { ultraCompress } from "../ultra.ts"; +import { ultraCompressHeuristic } from "../ultra.ts"; import { createCompressionStats } from "../stats.ts"; import { adaptBodyForCompression } from "../bodyAdapter.ts"; import { @@ -414,7 +414,7 @@ export const ultraEngine: CompressionEngine = { ...(options?.stepConfig ?? {}), preserveSystemPrompt: options?.config?.preserveSystemPrompt !== false, }; - const result = ultraCompress(messages, ultraConfig); + const result = ultraCompressHeuristic(messages, ultraConfig); const compressedBody = { ...adapter.body, messages: result.messages }; return { body: adapter.restore(compressedBody), diff --git a/open-sse/services/compression/engines/llmlingua/ultraEntry.ts b/open-sse/services/compression/engines/llmlingua/ultraEntry.ts new file mode 100644 index 0000000000..58e4107fef --- /dev/null +++ b/open-sse/services/compression/engines/llmlingua/ultraEntry.ts @@ -0,0 +1,109 @@ +/** + * LLMLingua-2 entry point for the `ultra` mode (Phase 4, Sub-project B). + * + * A THIN wrapper over the existing worker backend (`./worker.ts`) — no new ONNX + * integration. It adds exactly what the `ultra` two-tier resolver needs: + * - `slmAvailable()` — cached, NON-BLOCKING probe (reuses the worker's memoized + * optional-deps gate). It NEVER loads a model; an actual load happens lazily + * inside the worker under its first-call timeout. + * - `runLlmlinguaUltra(text, opts)` — compress ONE prose string. Throws when the + * backend fail-opens to the original text (no-op), so the ultra resolver can + * fall through to the Tier-A heuristic and record "heuristic-fallback". + * - `prewarmLlmlinguaUltra()` — best-effort warm call (errors swallowed). + * + * The structure-preservation split (code/math/URLs never reach the model) is done + * by the CALLER (`ultra.ts`), exactly as the heuristic path already does — this + * entry only sees prose. + */ + +import { workerBackend, depsAvailable } from "./worker.ts"; +import { DEFAULT_LLMLINGUA_MODEL } from "./constants.ts"; + +/** Cached probe result. null = not probed yet. */ +let _slmAvailable: boolean | null = null; + +// ─── test-only injectable hooks ───────────────────────────────────────────── +interface UltraSlmTestHooks { + available?: boolean; + run?: (text: string, opts?: UltraSlmOptions) => Promise; +} +let _testHooks: UltraSlmTestHooks | null = null; + +/** Test-only: override availability + the per-prose run, to avoid loading a real model. */ +export function __setUltraSlmTestHooks(hooks: UltraSlmTestHooks): void { + _testHooks = hooks; +} + +/** + * Cheap, cached, non-blocking probe: are the optional SLM deps installed? + * Reuses the worker's memoized `depsAvailable()` (a filesystem manifest check), + * so it never spawns a worker or loads a model. + */ +export function slmAvailable(): boolean { + if (_testHooks && typeof _testHooks.available === "boolean") return _testHooks.available; + if (_slmAvailable !== null) return _slmAvailable; + _slmAvailable = depsAvailable(); + return _slmAvailable; +} + +/** Options the ultra SLM tier threads to the worker backend. */ +export interface UltraSlmOptions { + model?: string; + compressionRate?: number; + modelPath?: string; +} + +/** + * Compress ONE prose string via the SLM worker backend. + * + * The worker backend is strictly fail-open: on missing deps / spawn error / + * model-load or inference error / per-call timeout it returns the ORIGINAL text. + * We treat a returned no-op (output not shorter than input) as a FAILURE and + * throw, so the ultra resolver falls back to Tier-A and records the fallback. + */ +export async function runLlmlinguaUltra(text: string, opts?: UltraSlmOptions): Promise { + if (_testHooks?.run) { + const out = await _testHooks.run(text, opts); + if (typeof out !== "string" || out.length >= text.length) { + throw new Error("llmlingua-ultra: backend produced no gain"); + } + return out; + } + const out = await workerBackend(text, { + model: opts?.model, + compressionRate: opts?.compressionRate, + modelPath: opts?.modelPath, + }); + if (typeof out !== "string" || out.length >= text.length) { + // Fail-open / no-op from the worker → let the caller fall back to heuristic. + throw new Error("llmlingua-ultra: backend produced no gain"); + } + return out; +} + +/** + * Best-effort pre-warm: ask the worker to load the model once on a short prose + * sample. NEVER throws — any failure is swallowed (the lazy first-call path still + * applies on the next real request). Returns true if a warm call was attempted. + */ +export async function prewarmLlmlinguaUltra(opts?: UltraSlmOptions): Promise { + if (!slmAvailable()) return false; + try { + // A small but non-trivial sample so the worker triggers a real model load. + // Route through `runLlmlinguaUltra` so the same backend seam (and test hook) + // is exercised; a no-op/throw from the worker is fine here (best-effort). + await runLlmlinguaUltra( + "The quick brown fox jumps over the lazy dog while the sun sets behind the hills.", + { model: opts?.model ?? DEFAULT_LLMLINGUA_MODEL, compressionRate: opts?.compressionRate } + ); + } catch { + // swallow — pre-warm is best-effort (a no-op/throw from the worker is fine). + } + return true; +} + +/** Test-only: reset the cached probe + injected hooks. */ +export function __resetUltraEntryForTests(): void { + _slmAvailable = null; + _testHooks = null; +} diff --git a/open-sse/services/compression/eval/aggregate.ts b/open-sse/services/compression/eval/aggregate.ts new file mode 100644 index 0000000000..5b45501884 --- /dev/null +++ b/open-sse/services/compression/eval/aggregate.ts @@ -0,0 +1,62 @@ +import type { ContentKind, EvalRecord, EvalReport, KindSummary, RunStamps } from "./types.ts"; + +function pct(n: number, d: number): number { + return d > 0 ? Math.round((n / d) * 1000) / 10 : 0; +} + +function goldDelta(scored: EvalRecord[]): number | null { + const gold = scored.filter((r) => r.goldFull !== null && r.goldCompressed !== null); + if (gold.length === 0) return null; + const fullCorrect = gold.filter((r) => r.goldFull === true).length; + const compCorrect = gold.filter((r) => r.goldCompressed === true).length; + return Math.round((pct(compCorrect, gold.length) - pct(fullCorrect, gold.length)) * 10) / 10; +} + +function meanRatio(scored: EvalRecord[]): number { + if (scored.length === 0) return 1; + const sum = scored.reduce((s, r) => s + r.savings.ratio, 0); + return Math.round((sum / scored.length) * 10000) / 10000; +} + +function summarizeKind(kind: ContentKind, scored: EvalRecord[]): KindSummary { + const same = scored.filter((r) => r.fidelity === "same").length; + return { + kind, + casesScored: scored.length, + fidelityPreservedPct: pct(same, scored.length), + goldAccuracyDeltaPct: goldDelta(scored), + meanRatio: meanRatio(scored), + }; +} + +/** + * Aggregate per content-kind + overall. Errored records (D1 §6) are counted but EXCLUDED + * from every rate so a failed model call can't masquerade as a fidelity loss. `run.partial` + * (cost-cap stop) and `run.totalCostUsd` flow through unchanged. + */ +export function aggregateRecords( + records: EvalRecord[], + stamps: RunStamps, + run: { partial: boolean; totalCostUsd: number } +): EvalReport { + const scored = records.filter((r) => !r.errored); + const errored = records.length - scored.length; + + const kinds = Array.from(new Set(scored.map((r) => r.kind))); + const perKind = kinds.map((k) => summarizeKind(k, scored.filter((r) => r.kind === k))); + + const same = scored.filter((r) => r.fidelity === "same").length; + return { + stamps, + partial: run.partial, + totalCostUsd: run.totalCostUsd, + overall: { + casesScored: scored.length, + casesErrored: errored, + fidelityPreservedPct: pct(same, scored.length), + goldAccuracyDeltaPct: goldDelta(scored), + meanRatio: meanRatio(scored), + }, + perKind, + }; +} diff --git a/open-sse/services/compression/eval/corpus.ts b/open-sse/services/compression/eval/corpus.ts new file mode 100644 index 0000000000..bb72f5d22b --- /dev/null +++ b/open-sse/services/compression/eval/corpus.ts @@ -0,0 +1,45 @@ +import { createHash } from "node:crypto"; +import type { EvalCase, ContentKind } from "./types.ts"; + +const KINDS: ContentKind[] = ["tool-output-json", "logs", "code", "prose", "multi-turn"]; + +/** Best-effort PII markers; captured cases must be anonymized BEFORE ingestion. */ +const PII_PATTERNS: RegExp[] = [ + /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/, // email + /\b\d{3}-\d{2}-\d{4}\b/, // US SSN shape + /\b(?:\d[ -]*?){13,16}\b/, // card-number shape +]; + +function looksLikePII(text: string): boolean { + return PII_PATTERNS.some((re) => re.test(text)); +} + +/** + * Validate + sanity-check a corpus. Throws on a malformed case so a bad corpus + * fails loudly rather than silently skewing results. Captured cases (captured === true) + * are rejected on obvious PII markers (D-D5 anonymization bar); curated seed cases + * (captured falsy) are trusted as already-vetted. + */ +export function loadCorpus(rawCases: EvalCase[]): EvalCase[] { + return rawCases.map((c) => { + if (!c.id || !c.context || !c.question) { + throw new Error(`eval corpus: case "${c.id ?? "?"}" missing id/context/question`); + } + if (!KINDS.includes(c.kind)) { + throw new Error(`eval corpus: case "${c.id}" has unknown kind "${c.kind}"`); + } + if (c.captured === true && looksLikePII(c.context)) { + throw new Error(`eval corpus: captured case "${c.id}" contains an obvious PII marker — anonymize before ingestion`); + } + return c; + }); +} + +/** Stable, order-independent sha-256 hex over the canonical case payloads (for the report stamp). */ +export function hashCorpus(cases: EvalCase[]): string { + const canonical = cases + .map((c) => JSON.stringify([c.id, c.kind, c.context, c.question, c.gold ?? null])) + .sort() + .join("\n"); + return createHash("sha256").update(canonical).digest("hex"); +} diff --git a/open-sse/services/compression/eval/costMeter.ts b/open-sse/services/compression/eval/costMeter.ts new file mode 100644 index 0000000000..c8217ef51d --- /dev/null +++ b/open-sse/services/compression/eval/costMeter.ts @@ -0,0 +1,31 @@ +export interface CostMeter { + add(usd: number): void; + /** True if adding `usd` would cross the cap (caller stops BEFORE the call that exceeds). */ + wouldExceed(usd: number): boolean; + readonly spent: number; + readonly exceeded: boolean; +} + +/** + * Pure cost accumulator driving the per-run cost cap (D1 §6: cap reached => stop, + * report partial, never silent truncation). `cap <= 0` (or NaN) means unbounded. + */ +export function createCostMeter(cap: number): CostMeter { + const bounded = typeof cap === "number" && cap > 0; + let spent = 0; + return { + add(usd: number) { + spent += Number.isFinite(usd) && usd > 0 ? usd : 0; + }, + wouldExceed(usd: number) { + if (!bounded) return false; + return spent + (Number.isFinite(usd) && usd > 0 ? usd : 0) > cap; + }, + get spent() { + return spent; + }, + get exceeded() { + return bounded && spent > cap; + }, + }; +} diff --git a/open-sse/services/compression/eval/executorModelClient.ts b/open-sse/services/compression/eval/executorModelClient.ts new file mode 100644 index 0000000000..dba6a65592 --- /dev/null +++ b/open-sse/services/compression/eval/executorModelClient.ts @@ -0,0 +1,44 @@ +import { getExecutor } from "../../../executors/index.ts"; +import type { ExecuteInput, ProviderCredentials } from "../../../executors/base.ts"; +import type { ChatTurn, ModelCallResult, ModelClient } from "./types.ts"; + +/** + * Production ModelClient adapter (Hard Rule #18 — NOT unit-tested; validated on a real + * VPS/account). Wraps the server executor: builds a minimal non-stream chat body, calls + * `getExecutor(provider).execute(...)`, reads the response text + (best-effort) usage cost. + * + * The pure runner depends only on the `ModelClient` interface; this adapter is the single + * place that touches credentials, the executor, and Response parsing — so the eval stays + * faithful to production while the runner/scorers remain fully testable with a stub. + */ +export function createExecutorModelClient( + provider: string, + credentials: ProviderCredentials, + costPerKTokenOut?: number +): ModelClient { + const executor = getExecutor(provider); + return { + async complete(model: string, messages: ChatTurn[]): Promise { + const body = { model, messages, stream: false }; + const input: ExecuteInput = { + model, + body, + stream: false, + credentials, + }; + // BaseExecutor.execute resolves to { response, url, headers, transformedBody } — the + // upstream Response lives on `.response` (never a bare Response). Validated live on VPS. + const raw = (await executor.execute(input)) as { response: Response }; + const response = raw.response; + const json = (await response.json()) as { + choices?: Array<{ message?: { content?: string } }>; + usage?: { completion_tokens?: number }; + }; + const text = json.choices?.[0]?.message?.content ?? ""; + const outTokens = json.usage?.completion_tokens ?? 0; + const usdCost = + typeof costPerKTokenOut === "number" ? (outTokens / 1000) * costPerKTokenOut : undefined; + return usdCost === undefined ? { text } : { text, usdCost }; + }, + }; +} diff --git a/open-sse/services/compression/eval/grader.ts b/open-sse/services/compression/eval/grader.ts new file mode 100644 index 0000000000..b19fc497ae --- /dev/null +++ b/open-sse/services/compression/eval/grader.ts @@ -0,0 +1,27 @@ +import type { ChatTurn, GradeVerdict } from "./types.ts"; + +/** + * Gold grader prompt (D-D2b). Grades a single answer against the gold answer — semantically, + * so a DIFFERENT-but-correct phrasing still grades CORRECT (catches "different but still right"), + * while a wrong fact grades INCORRECT. The grader must end with `VERDICT: CORRECT|INCORRECT`. + */ +export function buildGradePrompt(answer: string, gold: string): ChatTurn[] { + return [ + { + role: "system", + content: + "You are a strict grader. Decide whether the candidate answer is CORRECT with respect " + + "to the gold answer — judge meaning, not wording (a correctly-phrased-differently answer " + + "is CORRECT). Reply with exactly one final line: `VERDICT: CORRECT` or `VERDICT: INCORRECT`.", + }, + { role: "user", content: `Gold answer:\n${gold}\n\nCandidate answer:\n${answer}` }, + ]; +} + +/** PURE grade parser. Conservative: anything not clearly CORRECT grades INCORRECT (no benefit of doubt). */ +export function parseGradeVerdict(raw: string): GradeVerdict { + const text = raw.toLowerCase(); + if (/\bincorrect\b/.test(text)) return { correct: false, raw }; + if (/\bcorrect\b/.test(text)) return { correct: true, raw }; + return { correct: false, raw }; +} diff --git a/open-sse/services/compression/eval/judge.ts b/open-sse/services/compression/eval/judge.ts new file mode 100644 index 0000000000..7659208aeb --- /dev/null +++ b/open-sse/services/compression/eval/judge.ts @@ -0,0 +1,68 @@ +import type { ChatTurn, JudgeVerdict, ModelClient } from "./types.ts"; + +/** + * Fidelity judge prompt (D-D2a). Asks the judge to decide whether the compressed-context + * answer MATERIALLY differs from the full-context answer — wording differences that do not + * change the substance are "SAME". The judge must end with a single `VERDICT:` line. + */ +export function buildJudgePrompt(fullAnswer: string, compressedAnswer: string): ChatTurn[] { + return [ + { + role: "system", + content: + "You are a strict evaluation judge. You are given two answers to the same question: " + + "answer A produced from the full context, and answer B produced from a compressed context. " + + "Decide whether B MATERIALLY differs from A (a difference that changes the substance, " + + "correctness, or key facts — NOT mere wording/format). Reply with exactly one final line: " + + "`VERDICT: SAME` or `VERDICT: MATERIALLY_DIFFERS`.", + }, + { + role: "user", + content: `Answer A (full context):\n${fullAnswer}\n\nAnswer B (compressed context):\n${compressedAnswer}`, + }, + ]; +} + +/** PURE verdict parser. Tolerant of case/format; unrecognized output => "unparseable" (never guessed). */ +export function parseJudgeVerdict(raw: string): JudgeVerdict { + const text = raw.toLowerCase(); + const differs = /materially[_\s-]*differs|differs[_\s]+materially|\bdiffers\b/.test(text); + const same = /verdict:\s*same|\bsame\b/.test(text); + if (differs) return "materially-differs"; + if (same) return "same"; + return "unparseable"; +} + +/** + * Control pair for the self-test (D-D3 / ponytail discipline). The judge must rank the + * KNOWN-DEGRADED answer as MATERIALLY_DIFFERS and the KNOWN-GOOD answer as SAME, both relative + * to the same reference. A judge that mis-ranks either is untrusted and aborts the run. + */ +export const CONTROL_PAIR = { + reference: "The function returns 3 because the input is clamped to the upper bound.", + good: "It returns 3 since the value is clamped to the maximum allowed.", + degraded: "It returns 0 because the value is set to zero.", +} as const; + +export interface SelfTestResult { passed: boolean; detail: string; } + +/** + * Run the control pair through the judge. PASS requires: degraded => materially-differs AND + * good => same. Any other outcome (including unparseable) FAILS, so the runner aborts before + * emitting untrusted scores. + */ +export async function runSelfTest(client: ModelClient, judgeModel: string): Promise { + const goodVerdict = parseJudgeVerdict( + (await client.complete(judgeModel, buildJudgePrompt(CONTROL_PAIR.reference, CONTROL_PAIR.good))).text + ); + const degradedVerdict = parseJudgeVerdict( + (await client.complete(judgeModel, buildJudgePrompt(CONTROL_PAIR.reference, CONTROL_PAIR.degraded))).text + ); + if (degradedVerdict !== "materially-differs") { + return { passed: false, detail: `judge failed to flag the known-degraded control (got "${degradedVerdict}")` }; + } + if (goodVerdict !== "same") { + return { passed: false, detail: `judge flagged the known-good control as "${goodVerdict}" (expected same)` }; + } + return { passed: true, detail: "control pair ranked correctly" }; +} diff --git a/open-sse/services/compression/eval/report.ts b/open-sse/services/compression/eval/report.ts new file mode 100644 index 0000000000..ccd530e55b --- /dev/null +++ b/open-sse/services/compression/eval/report.ts @@ -0,0 +1,44 @@ +import type { EvalReport, KindSummary } from "./types.ts"; + +function fmtDelta(d: number | null): string { + return d === null ? "n/a" : `${d > 0 ? "+" : ""}${d}%`; +} + +function kindRow(k: KindSummary): string { + return `| ${k.kind} | ${k.casesScored} | ${k.fidelityPreservedPct}% | ${fmtDelta(k.goldAccuracyDeltaPct)} | ${k.meanRatio} |`; +} + +/** + * Render the eval report as markdown with the pinned reproducibility stamps in the header + * (D1 §4.1) and a prominent PARTIAL banner when the cost cap stopped the run. + */ +export function formatReport(report: EvalReport): string { + const { stamps, overall } = report; + const lines: string[] = []; + lines.push("# Compression evaluation report (D1)"); + lines.push(""); + if (report.partial) { + lines.push("> ⚠️ **PARTIAL RUN** — the per-run cost cap was reached; results below cover only the cases scored before the stop."); + lines.push(""); + } + lines.push(`- answer model: \`${stamps.answerModel}\``); + lines.push(`- judge model: \`${stamps.judgeModel}\``); + lines.push(`- corpus hash: \`${stamps.corpusHash}\``); + lines.push(`- sample size: ${stamps.sampleSize}`); + lines.push(`- total cost: $${Math.round(report.totalCostUsd * 1e4) / 1e4}`); + lines.push(""); + lines.push("## Overall"); + lines.push(""); + lines.push(`- cases scored: ${overall.casesScored} (errored, excluded: ${overall.casesErrored})`); + lines.push(`- fidelity preserved: ${overall.fidelityPreservedPct}%`); + lines.push(`- gold-accuracy delta (compressed − full): ${fmtDelta(overall.goldAccuracyDeltaPct)}`); + lines.push(`- mean compression ratio: ${overall.meanRatio}`); + lines.push(""); + lines.push("## Per content-kind"); + lines.push(""); + lines.push("| kind | scored | fidelity preserved | gold Δ | mean ratio |"); + lines.push("|---|---|---|---|---|"); + for (const k of report.perKind) lines.push(kindRow(k)); + lines.push(""); + return lines.join("\n"); +} diff --git a/open-sse/services/compression/eval/runner.ts b/open-sse/services/compression/eval/runner.ts new file mode 100644 index 0000000000..24499da35a --- /dev/null +++ b/open-sse/services/compression/eval/runner.ts @@ -0,0 +1,135 @@ +import { selectCompressionPlan, applyCompressionAsync } from "../strategySelector.ts"; +import { estimateCompressionTokens } from "../stats.ts"; +import type { CompressionConfig, CompressionMode } from "../types.ts"; +import { loadCorpus, hashCorpus } from "./corpus.ts"; +import { buildJudgePrompt, parseJudgeVerdict, runSelfTest } from "./judge.ts"; +import { buildGradePrompt, parseGradeVerdict } from "./grader.ts"; +import { computeSavings } from "./savings.ts"; +import { createCostMeter } from "./costMeter.ts"; +import { aggregateRecords } from "./aggregate.ts"; +import type { EvalCase, EvalRecord, EvalReport, ModelClient, RunStamps } from "./types.ts"; + +export interface RunEvalOptions { + corpus: EvalCase[]; + client: ModelClient; + config: CompressionConfig; + comboId: string | null; + combos: Record; + answerModel: string; + judgeModel: string; + provider: string; + /** USD; <= 0 means unbounded. */ + costCapUsd: number; + /** Score at most N cases (the seam for `--sample N`). */ + sample?: number; + costPerKTokenIn?: number; +} + +export interface RunEvalResult { + aborted: boolean; + abortReason?: string; + report: EvalReport | null; +} + +/** Build a one-question chat body the pipeline + model both accept. */ +function buildBody(context: string, question: string): Record { + return { messages: [{ role: "user", content: `${context}\n\nQuestion: ${question}` }] }; +} + +function answerText(body: Record): string { + const messages = (body.messages ?? []) as Array<{ content?: unknown }>; + return messages.map((m) => (typeof m.content === "string" ? m.content : "")).join("\n"); +} + +/** + * Offline corpus eval (D1 §4.1). Self-test the judge FIRST (D-D3 abort), then for each case: + * model(full) baseline, compress via the REAL pipeline at the target config, model(compressed), + * judge fidelity, grade gold when present, compute mechanical savings. Errored cases are + * recorded + excluded; the cost cap stops the loop and flags partial (no silent truncation). + */ +export async function runEval(opts: RunEvalOptions): Promise { + const corpus = loadCorpus(opts.corpus); + + // D-D3 self-test gate — a broken judge aborts before any score is emitted. + const selfTest = await runSelfTest(opts.client, opts.judgeModel); + if (!selfTest.passed) { + return { aborted: true, abortReason: `judge self-test failed: ${selfTest.detail}`, report: null }; + } + + const limit = typeof opts.sample === "number" ? Math.max(0, opts.sample) : corpus.length; + const cases = corpus.slice(0, limit); + const meter = createCostMeter(opts.costCapUsd); + // Self-test calls are NOT metered against the run cap — the cap governs only the corpus loop. + const records: EvalRecord[] = []; + let partial = false; + + for (const c of cases) { + // Stop BEFORE a case if we cannot afford its (~3) model calls; flag partial. + if (meter.exceeded) { partial = true; break; } + + const fullBody = buildBody(c.context, c.question); + try { + const full = await opts.client.complete(opts.answerModel, [{ role: "user", content: answerText(fullBody) }]); + meter.add(full.usdCost ?? 0); + + const estimatedTokens = estimateCompressionTokens(fullBody); + const plan = selectCompressionPlan(opts.config, opts.comboId, estimatedTokens, fullBody, undefined, opts.combos); + const compressedResult = await applyCompressionAsync(fullBody, plan.mode as CompressionMode, { + config: opts.config, + model: opts.answerModel, + }); + const compressedBody = compressedResult.compressed ? (compressedResult.body as Record) : fullBody; + + const compressed = await opts.client.complete(opts.answerModel, [{ role: "user", content: answerText(compressedBody) }]); + meter.add(compressed.usdCost ?? 0); + + const judge = await opts.client.complete(opts.judgeModel, buildJudgePrompt(full.text, compressed.text)); + meter.add(judge.usdCost ?? 0); + const fidelity = parseJudgeVerdict(judge.text); + + let goldFull: boolean | null = null; + let goldCompressed: boolean | null = null; + if (typeof c.gold === "string") { + const gf = await opts.client.complete(opts.judgeModel, buildGradePrompt(full.text, c.gold)); + meter.add(gf.usdCost ?? 0); + const gc = await opts.client.complete(opts.judgeModel, buildGradePrompt(compressed.text, c.gold)); + meter.add(gc.usdCost ?? 0); + goldFull = parseGradeVerdict(gf.text).correct; + goldCompressed = parseGradeVerdict(gc.text).correct; + } + + records.push({ + id: c.id, + kind: c.kind, + fidelity, + goldFull, + goldCompressed, + savings: computeSavings(fullBody, compressedBody, opts.costPerKTokenIn), + errored: false, + }); + } catch (err) { + records.push({ + id: c.id, + kind: c.kind, + fidelity: "unparseable", + goldFull: null, + goldCompressed: null, + savings: { tokensBefore: 0, tokensAfter: 0, ratio: 1 }, + errored: true, + errorDetail: err instanceof Error ? err.message : String(err), + }); + } + + // After the case, if the cap is now crossed, stop the loop and flag partial. + if (meter.exceeded) { partial = true; break; } + } + + const stamps: RunStamps = { + answerModel: opts.answerModel, + judgeModel: opts.judgeModel, + corpusHash: hashCorpus(corpus), + sampleSize: typeof opts.sample === "number" ? opts.sample : "all", + }; + const report = aggregateRecords(records, stamps, { partial, totalCostUsd: meter.spent }); + return { aborted: false, report }; +} diff --git a/open-sse/services/compression/eval/savings.ts b/open-sse/services/compression/eval/savings.ts new file mode 100644 index 0000000000..50562ae2f4 --- /dev/null +++ b/open-sse/services/compression/eval/savings.ts @@ -0,0 +1,30 @@ +import { estimateCompressionTokens } from "../stats.ts"; + +export interface SavingsResult { + tokensBefore: number; + tokensAfter: number; + ratio: number; // tokensAfter / tokensBefore (1 = no savings) + costDelta?: number; // USD saved on input tokens, when a price is supplied +} + +/** + * Mechanical savings for a full-vs-compressed body pair. Reuses the production + * token estimator so the eval reports the same numbers the pipeline reports. + * `costPerKTokenIn` (USD per 1000 input tokens) is optional — when supplied, the + * positive cost saved on the input side is reported (the eval does not model output cost). + */ +export function computeSavings( + fullBody: Record, + compressedBody: Record, + costPerKTokenIn?: number +): SavingsResult { + const tokensBefore = estimateCompressionTokens(fullBody); + const tokensAfter = estimateCompressionTokens(compressedBody); + const ratio = tokensBefore > 0 ? Math.round((tokensAfter / tokensBefore) * 10000) / 10000 : 1; + const result: SavingsResult = { tokensBefore, tokensAfter, ratio }; + if (typeof costPerKTokenIn === "number" && costPerKTokenIn > 0) { + const saved = ((tokensBefore - tokensAfter) / 1000) * costPerKTokenIn; + result.costDelta = Math.round(saved * 1e6) / 1e6; + } + return result; +} diff --git a/open-sse/services/compression/eval/seedCorpus.ts b/open-sse/services/compression/eval/seedCorpus.ts new file mode 100644 index 0000000000..b5c2483f50 --- /dev/null +++ b/open-sse/services/compression/eval/seedCorpus.ts @@ -0,0 +1,60 @@ +import type { EvalCase } from "./types.ts"; + +/** + * Curated seed corpus (D-D5). Small, diverse content kinds; pinned for reproducible runs. + * Anonymized captured cases append to this array with `captured: true` (loader vets PII). + * Keep every `context` synthetic / public so the corpus carries no real user data. + */ +export const SEED_CORPUS: EvalCase[] = [ + { + id: "prose-1", + kind: "prose", + context: + "The deployment pipeline has three stages. First, the build stage compiles the " + + "TypeScript sources and bundles them. Second, the test stage runs the unit suite " + + "and the integration suite in parallel. Third, the deploy stage pushes the artifact " + + "to the staging environment and waits for a manual approval before promoting to prod.", + question: "How many stages does the deployment pipeline have, and what is the last stage?", + gold: "Three stages; the last is the deploy stage (push to staging, manual approval before prod).", + }, + { + id: "code-1", + kind: "code", + context: + "export function clamp(n: number, lo: number, hi: number): number {\n" + + " if (n < lo) return lo;\n if (n > hi) return hi;\n return n;\n}", + question: "What does clamp(5, 0, 3) return?", + gold: "3", + }, + { + id: "tool-output-json-1", + kind: "tool-output-json", + context: + '{"status":"ok","results":[{"id":1,"name":"alpha","score":0.91},' + + '{"id":2,"name":"beta","score":0.42},{"id":3,"name":"gamma","score":0.77}]}', + question: "Which result has the highest score?", + gold: "alpha (score 0.91)", + }, + { + id: "logs-1", + kind: "logs", + context: + "2026-06-22T10:00:01Z INFO worker started pid=4821\n" + + "2026-06-22T10:00:02Z WARN retrying upstream attempt=1\n" + + "2026-06-22T10:00:03Z ERROR upstream timeout after=30000ms\n" + + "2026-06-22T10:00:04Z INFO fell back to secondary provider", + question: "What error occurred and what happened after it?", + gold: "An upstream timeout after 30000ms; the worker then fell back to the secondary provider.", + }, + { + id: "multi-turn-1", + kind: "multi-turn", + context: + "User: I need to rename the column user_name to username in the users table.\n" + + "Assistant: You can run ALTER TABLE users RENAME COLUMN user_name TO username.\n" + + "User: Will that drop the data in the column?\n" + + "Assistant: No — RENAME COLUMN only changes the column name; the data is preserved.", + question: "Does renaming the column drop its data?", + gold: "No; RENAME COLUMN preserves the data.", + }, +]; diff --git a/open-sse/services/compression/eval/types.ts b/open-sse/services/compression/eval/types.ts new file mode 100644 index 0000000000..426c379189 --- /dev/null +++ b/open-sse/services/compression/eval/types.ts @@ -0,0 +1,65 @@ +export type ContentKind = "tool-output-json" | "logs" | "code" | "prose" | "multi-turn"; + +export interface EvalCase { + id: string; + kind: ContentKind; + /** The raw context to compress (one user turn's worth of context). */ + context: string; + /** The question asked against the context. */ + question: string; + /** Optional gold answer; when present, both answers are graded against it. */ + gold?: string; + /** true => a curated seed case; false/undefined => an anonymized captured case. */ + captured?: boolean; +} + +export interface ChatTurn { role: "system" | "user" | "assistant"; content: string; } + +export interface ModelCallResult { text: string; usdCost?: number; } + +/** Narrow seam the runner depends on; production adapter wraps the executor, tests use a stub. */ +export interface ModelClient { + /** Single non-stream completion. `model` selects answer-model vs judge-model. */ + complete(model: string, messages: ChatTurn[]): Promise; +} + +export type JudgeVerdict = "same" | "materially-differs" | "unparseable"; +export interface GradeVerdict { correct: boolean; raw: string; } + +export interface RunStamps { answerModel: string; judgeModel: string; corpusHash: string; sampleSize: number | "all"; } + +import type { SavingsResult } from "./savings.ts"; + +export interface EvalRecord { + id: string; + kind: ContentKind; + fidelity: JudgeVerdict; + /** null when the case has no gold; otherwise whether that answer graded correct. */ + goldFull: boolean | null; + goldCompressed: boolean | null; + savings: SavingsResult; + errored: boolean; + errorDetail?: string; +} + +export interface KindSummary { + kind: ContentKind; + casesScored: number; + fidelityPreservedPct: number; // same / scored + goldAccuracyDeltaPct: number | null; // compressed-correct% − full-correct% over gold cases (null if none) + meanRatio: number; +} + +export interface EvalReport { + stamps: RunStamps; + partial: boolean; + totalCostUsd: number; + overall: { + casesScored: number; + casesErrored: number; + fidelityPreservedPct: number; + goldAccuracyDeltaPct: number | null; + meanRatio: number; + }; + perKind: KindSummary[]; +} diff --git a/open-sse/services/compression/index.ts b/open-sse/services/compression/index.ts index 062a1c8d57..8ef9a581b5 100644 --- a/open-sse/services/compression/index.ts +++ b/open-sse/services/compression/index.ts @@ -153,6 +153,13 @@ export { STOPWORDS, FORCE_PRESERVE_RE, scoreToken, pruneByScore } from "./ultraH export type { UltraCompressResult } from "./ultra.ts"; export { ultraCompress } from "./ultra.ts"; +export { ultraCompressHeuristic } from "./ultra.ts"; +export type { UltraTier } from "./ultra.ts"; +export { + slmAvailable, + runLlmlinguaUltra, + prewarmLlmlinguaUltra, +} from "./engines/llmlingua/ultraEntry.ts"; export type { UltraConfig } from "./types.ts"; export { DEFAULT_ULTRA_CONFIG } from "./types.ts"; diff --git a/open-sse/services/compression/outputMode.ts b/open-sse/services/compression/outputMode.ts index c512c05cb1..725e806e64 100644 --- a/open-sse/services/compression/outputMode.ts +++ b/open-sse/services/compression/outputMode.ts @@ -28,7 +28,7 @@ export interface CavemanOutputModeResult { export const SHARED_BOUNDARIES = "Code blocks, file paths, commands, errors, URLs: keep exact. Security warnings, irreversible action confirmations, multi-step ordered sequences: write normal. Resume terse style after. Active every response until user asks for normal mode."; -const CAVEMAN_INSTRUCTION_BY_LANGUAGE = { +export const CAVEMAN_INSTRUCTION_BY_LANGUAGE = { en: { lite: `Respond concise. Drop filler, pleasantries, hedging. Keep full sentences, technical terms, code, errors, URLs, and identifiers exact. ${SHARED_BOUNDARIES}`, full: `Respond terse like smart caveman. Drop articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries, hedging. Fragments OK. Short synonyms (big not extensive, fix not implement). Keep all technical substance, code, errors, URLs, identifiers exact. ${SHARED_BOUNDARIES}`, diff --git a/open-sse/services/compression/outputStyles/apply.ts b/open-sse/services/compression/outputStyles/apply.ts new file mode 100644 index 0000000000..e9a6e76041 --- /dev/null +++ b/open-sse/services/compression/outputStyles/apply.ts @@ -0,0 +1,135 @@ +import { SHARED_BOUNDARIES, shouldBypassCavemanOutputMode } from "../outputMode.ts"; +import { OUTPUT_STYLE_IDS, outputStyleMeta } from "./catalog.ts"; + +export type OutputStyleLevel = "lite" | "full" | "ultra"; + +export interface OutputStyleSelectionEntry { + id: string; + level: OutputStyleLevel; +} + +interface ChatMessage { + role: string; + content?: string | unknown[]; + [key: string]: unknown; +} + +interface ChatRequestBody { + messages?: ChatMessage[]; + instructions?: string; + input?: unknown; + [key: string]: unknown; +} + +export interface OutputStylesResult { + body: ChatRequestBody; + applied: boolean; + skippedReason?: string; + /** The styles actually injected (after unknown/locale filtering), in catalog order. */ + appliedStyles?: OutputStyleSelectionEntry[]; +} + +/** Single idempotency marker guarding the unified injection (D-A: one marker for all styles). */ +export const OUTPUT_STYLE_MARKER = "[OmniRoute Output Styles]"; + +/** + * Resolve the selection into the ordered, locale-gated, known styles in catalog order. + * Pure: drops unknown ids and locale-mismatched styles; never throws (D-A6 forward-compat). + */ +function resolveStyles( + selection: OutputStyleSelectionEntry[], + language: string +): OutputStyleSelectionEntry[] { + const byId = new Map(selection.map((entry) => [entry.id, entry])); + const resolved: OutputStyleSelectionEntry[] = []; + for (const id of OUTPUT_STYLE_IDS) { + const entry = byId.get(id); + if (!entry) continue; + const meta = outputStyleMeta(id); + if (!meta) continue; + if (meta.locale && meta.locale !== language) continue; + resolved.push({ id, level: entry.level }); + } + return resolved; +} + +/** Build the combined instruction body (no marker, no trailing boundary). Pure / deterministic. */ +function buildStyleInstructions( + resolved: OutputStyleSelectionEntry[], + language: string +): string { + const parts: string[] = []; + for (const { id, level } of resolved) { + const meta = outputStyleMeta(id); + const localized = meta.i18n?.[language]; + const levels = localized ?? meta.levels; + // Strip the per-style boundary so SHARED_BOUNDARIES is appended exactly once below. + parts.push(levels[level].replace(SHARED_BOUNDARIES, "").trim()); + } + return parts.join("\n"); +} + +/** + * Inject one or more output styles deterministically and front-loaded into the system prompt. + * - Selection resolved in catalog order; unknown/locale-mismatched styles dropped. + * - SHARED_BOUNDARIES applied once at the end (not per style). + * - Single idempotency marker; re-applying is a no-op. + * - Content bypass runs once across the whole turn (all-or-nothing); reason recorded. + */ +export function applyOutputStyles( + body: ChatRequestBody, + selection: OutputStyleSelectionEntry[], + language = "en" +): OutputStylesResult { + const resolved = resolveStyles(selection ?? [], language); + if (resolved.length === 0) { + return { body, applied: false, skippedReason: "no_styles" }; + } + + // Single space before the shared boundary so a legacy single-style (terse-prose) + // injection stays byte-identical to the old caveman output mode (D-A5 back-compat). + const combined = `${buildStyleInstructions(resolved, language)} ${SHARED_BOUNDARIES}`; + const instruction = `${OUTPUT_STYLE_MARKER}\n${combined}`; + + const messages = Array.isArray(body.messages) ? body.messages : null; + if (!messages || messages.length === 0) { + if (typeof body.instructions === "string") { + if (body.instructions.includes(OUTPUT_STYLE_MARKER)) { + return { body, applied: false, skippedReason: "already_applied" }; + } + return { + body: { ...body, instructions: `${body.instructions.trim()}\n\n${instruction}` }, + applied: true, + appliedStyles: resolved, + }; + } + if (typeof body.input === "string" || Array.isArray(body.input)) { + return { body: { ...body, instructions: instruction }, applied: true, appliedStyles: resolved }; + } + return { body, applied: false, skippedReason: "no_messages" }; + } + + // Idempotency before bypass so an already-injected marker (which contains + // SHARED_BOUNDARIES keywords) cannot trigger a false-positive bypass. + const alreadyApplied = messages.some( + (message) => + message.role === "system" && + typeof message.content === "string" && + message.content.includes(OUTPUT_STYLE_MARKER) + ); + if (alreadyApplied) return { body, applied: false, skippedReason: "already_applied" }; + + // Content bypass (all-or-nothing for the turn): reuse the existing rules verbatim. + const bypass = shouldBypassCavemanOutputMode(messages); + if (bypass) return { body, applied: false, skippedReason: bypass }; + + const nextMessages = [...messages]; + const first = nextMessages[0]; + if (first?.role === "system" && typeof first.content === "string") { + nextMessages[0] = { ...first, content: `${first.content.trim()}\n\n${instruction}` }; + } else { + nextMessages.unshift({ role: "system", content: instruction }); + } + + return { body: { ...body, messages: nextMessages }, applied: true, appliedStyles: resolved }; +} diff --git a/open-sse/services/compression/outputStyles/backCompat.ts b/open-sse/services/compression/outputStyles/backCompat.ts new file mode 100644 index 0000000000..82631b1baa --- /dev/null +++ b/open-sse/services/compression/outputStyles/backCompat.ts @@ -0,0 +1,29 @@ +import type { OutputStyleSelectionEntry } from "./apply.ts"; + +interface LegacyOutputModeConfig { + enabled?: boolean; + intensity?: "lite" | "full" | "ultra"; +} + +interface ConfigSlice { + outputStyles?: OutputStyleSelectionEntry[]; + cavemanOutputMode?: LegacyOutputModeConfig; +} + +/** + * Resolve the effective output-style selection (D-A5 back-compat). + * Precedence: an explicit non-empty `outputStyles` wins; otherwise a stored + * `cavemanOutputMode` (when enabled) maps to `[{ terse-prose, }]`, + * keeping existing installs byte-identical until they opt into other styles. + * Pure; never throws. + */ +export function resolveOutputStyleSelection(config: ConfigSlice): OutputStyleSelectionEntry[] { + if (Array.isArray(config.outputStyles) && config.outputStyles.length > 0) { + return config.outputStyles; + } + const legacy = config.cavemanOutputMode; + if (legacy?.enabled) { + return [{ id: "terse-prose", level: legacy.intensity ?? "full" }]; + } + return []; +} diff --git a/open-sse/services/compression/outputStyles/catalog.ts b/open-sse/services/compression/outputStyles/catalog.ts new file mode 100644 index 0000000000..bc0111feb5 --- /dev/null +++ b/open-sse/services/compression/outputStyles/catalog.ts @@ -0,0 +1,76 @@ +import { SHARED_BOUNDARIES, CAVEMAN_INSTRUCTION_BY_LANGUAGE } from "../outputMode.ts"; + +/** + * A single output-steering style. Instruction text MUST be static per + * `(id, level, language)` — no timestamps, no per-request interpolation — so the + * injected system prefix stays prompt-cache-stable (D-A4). The registry contract + * forbids non-deterministic instruction text. + */ +export interface OutputStyle { + /** Stable id, e.g. "terse-prose" | "less-code" | "terse-cjk". */ + id: string; + /** Human label for the settings panel. */ + label: string; + /** Short panel description (i18n-independent English). */ + description?: string; + /** Instruction text per intensity. Static / deterministic. */ + levels: { lite: string; full: string; ultra: string }; + /** Optional per-style boundary clause; when absent the SHARED_BOUNDARIES is used. */ + boundaries?: string; + /** Locale gate: when set, the style is only offered/honored under this language code. */ + locale?: string; + /** Optional localized `levels`, keyed by language code. */ + i18n?: Record; +} + +/** + * The Output Style registry. Adding a style = one entry here; the injector and the + * settings panel both enumerate this object, so no other file needs to change (D-A1). + * Declaration order is the deterministic concatenation order used by the injector. + */ +export const OUTPUT_STYLE_CATALOG: Record = { + "terse-prose": { + id: "terse-prose", + label: "Terse prose", + description: "Drop filler/articles/hedging; keep technical substance exact.", + // Migrated verbatim from the caveman output mode (outputMode.ts) — referenced (not + // re-typed) so the back-compat injection stays byte-identical across ALL languages, + // not just English (the legacy mode localized to en/pt-BR/ja/id). + levels: CAVEMAN_INSTRUCTION_BY_LANGUAGE.en, + i18n: { + "pt-BR": CAVEMAN_INSTRUCTION_BY_LANGUAGE["pt-BR"], + ja: CAVEMAN_INSTRUCTION_BY_LANGUAGE.ja, + id: CAVEMAN_INSTRUCTION_BY_LANGUAGE.id, + }, + }, + "less-code": { + id: "less-code", + label: "Less code", + description: "YAGNI ladder: smallest working change, no unrequested abstractions.", + // Ported from 9router ponytail (ponytailPrompt.js); attribution preserved. + levels: { + lite: `Write the smallest change that satisfies the request. Skip speculative abstractions. ${SHARED_BOUNDARIES}`, + full: `Act like a lazy senior dev applying YAGNI. Smallest working change only. No unrequested abstractions, no premature generalization, no extra layers, no defensive scaffolding the request did not ask for. Reuse existing code over adding new code. ${SHARED_BOUNDARIES}`, + ultra: `Minimal diff discipline. Touch the fewest lines that make it work. Zero new files, classes, or config unless strictly required. Inline over abstract. No "while we're here" extras. ${SHARED_BOUNDARIES}`, + }, + }, + "terse-cjk": { + id: "terse-cjk", + label: "Terse CJK (文言)", + description: "Classical-Chinese ultra-terse style (locale-gated to zh).", + // Ported from 9router wenyan (cavemanPrompts.js); the worked extensibility example. + locale: "zh", + levels: { + lite: `回答从简,去虚词、寒暄、修饰。代码、路径、命令、错误、URL、标识符一律照原样保留。${SHARED_BOUNDARIES}`, + full: `以文言简体回答,惜字如金,去赘语虚词。代码、路径、命令、错误、URL、标识符照原样保留,不得改写。${SHARED_BOUNDARIES}`, + ultra: `以极简文言回答,字字千金。仅留要义。代码、API名、错误串、URL、标识符照原样保留,绝不省略或改写。${SHARED_BOUNDARIES}`, + }, + }, +}; + +/** Catalog ids in declaration order (the deterministic concat order). */ +export const OUTPUT_STYLE_IDS: string[] = Object.keys(OUTPUT_STYLE_CATALOG); + +export function outputStyleMeta(id: string): OutputStyle { + return OUTPUT_STYLE_CATALOG[id]; +} diff --git a/open-sse/services/compression/outputStyles/telemetry.ts b/open-sse/services/compression/outputStyles/telemetry.ts new file mode 100644 index 0000000000..582fd63dc6 --- /dev/null +++ b/open-sse/services/compression/outputStyles/telemetry.ts @@ -0,0 +1,50 @@ +import type { OutputStyleSelectionEntry } from "./apply.ts"; + +/** The CompressionRunTelemetry fields this sub-project (A) fills. Clock-free / pure. */ +export interface OutputStyleTelemetryRecord { + requestId: string; + model: string; + provider: string; + source: string; + tokensBefore: number; + tokensAfter: number; + ratio: number; + outputStyles?: OutputStyleSelectionEntry[]; + outputStyleBypass?: string; + outputTokens?: number; +} + +// Benign (non-content-bypass) skips that must NOT be recorded as a bypass reason. +const BENIGN_SKIPS = new Set(["disabled", "no_styles", "no_messages", "already_applied"]); + +export function buildOutputStyleTelemetry(input: { + requestId: string; + model: string; + provider: string; + source: string; + tokensBefore: number; + tokensAfter: number; + applied: boolean; + appliedStyles?: OutputStyleSelectionEntry[]; + skippedReason?: string; + outputTokens?: number; +}): OutputStyleTelemetryRecord { + const ratio = input.tokensBefore > 0 ? input.tokensAfter / input.tokensBefore : 0; + const record: OutputStyleTelemetryRecord = { + requestId: input.requestId, + model: input.model, + provider: input.provider, + source: input.source, + tokensBefore: input.tokensBefore, + tokensAfter: input.tokensAfter, + ratio, + }; + if (input.applied && input.appliedStyles && input.appliedStyles.length > 0) { + record.outputStyles = input.appliedStyles; + } + if (!input.applied && input.skippedReason && !BENIGN_SKIPS.has(input.skippedReason)) { + record.outputStyleBypass = input.skippedReason; + } + if (typeof input.outputTokens === "number") record.outputTokens = input.outputTokens; + return record; +} diff --git a/open-sse/services/compression/strategySelector.ts b/open-sse/services/compression/strategySelector.ts index 51e5865122..264d8ff086 100644 --- a/open-sse/services/compression/strategySelector.ts +++ b/open-sse/services/compression/strategySelector.ts @@ -9,7 +9,7 @@ import type { CompressionEngineApplyOptions } from "./engines/types.ts"; import { applyLiteCompression } from "./lite.ts"; import { cavemanCompress } from "./caveman.ts"; import { compressAggressive } from "./aggressive.ts"; -import { ultraCompress } from "./ultra.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"; @@ -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) { @@ -325,19 +355,22 @@ export function applyCompression( ...(options?.config?.ultra ?? {}), preserveSystemPrompt: options?.config?.preserveSystemPrompt !== false, }; - const result = ultraCompress(messages, ultraConfig); + 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 - ), + stats: { + ...createCompressionStats( + compressionBody, + compressedBody, + mode, + ["ultra"], + result.stats.rulesApplied, + result.stats.durationMs + ), + ultraTier: result.stats.ultraTier, + }, }; } return { body, compressed: false, stats: null }; @@ -402,9 +435,41 @@ async function applyUltraAsync( const ultraConfig = options?.config?.ultra; const modelPath = typeof ultraConfig?.modelPath === "string" ? ultraConfig.modelPath.trim() : ""; - // No model configured → heuristic ultra (unchanged default). + // 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) { - return applyCompression(body, "ultra", options); + 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(); diff --git a/open-sse/services/compression/types.ts b/open-sse/services/compression/types.ts index e086cbfe50..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. @@ -69,6 +70,13 @@ export interface CavemanOutputModeConfig { autoClarity: boolean; } +export type OutputStyleLevel = "lite" | "full" | "ultra"; + +export interface OutputStyleSelectionEntry { + id: string; + level: OutputStyleLevel; +} + export interface RtkConfig { enabled: boolean; intensity: RtkIntensity; @@ -135,6 +143,8 @@ export interface CompressionConfig { stackedPipeline?: CompressionPipelineStep[]; cavemanConfig?: CavemanConfig; cavemanOutputMode?: CavemanOutputModeConfig; + /** Phase 4A: selected output styles (supersedes cavemanOutputMode via a back-compat shim). */ + outputStyles?: OutputStyleSelectionEntry[]; rtkConfig?: RtkConfig; languageConfig?: CompressionLanguageConfig; aggressive?: AggressiveConfig; @@ -152,6 +162,25 @@ 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). + * "slm" = Tier-B LLMLingua-2 ONNX worker when available, else fail-open to Tier-A. + */ + ultraEngine?: "heuristic" | "slm"; + /** + * Phase 4 (B): best-effort pre-warm of the SLM model on the enable transition + * and on a cold restart when `ultraEngine: "slm"` is already set. Failures are + * swallowed; the lazy first-call path still applies. Default false. + */ + ultraSlmPrewarm?: boolean; } export interface CompressionStats { @@ -168,6 +197,14 @@ export interface CompressionStats { validationWarnings?: string[]; validationErrors?: string[]; fallbackApplied?: boolean; + /** + * Phase 4 (B): which `ultra` tier actually ran for this request. + * "slm" — Tier-B ran and produced the output. + * "heuristic-fallback" — Tier-B was selected but failed/timed out → Tier-A used. + * "heuristic" — Tier-A used directly (ultraEngine !== "slm" or SLM unavailable). + * Consumed by D0's persister as `CompressionRunTelemetry.ultraTier`. + */ + ultraTier?: "slm" | "heuristic-fallback" | "heuristic"; preservedBlockCount?: number; rtkRawOutputPointers?: Array<{ id: string; @@ -220,6 +257,8 @@ export const DEFAULT_COMPRESSION_CONFIG: CompressionConfig = { ], engines: Object.fromEntries(ENGINE_IDS.map((id) => [id, { enabled: false }])), activeComboId: null, + ultraEngine: "heuristic", + ultraSlmPrewarm: false, }; export const DEFAULT_CAVEMAN_CONFIG: CavemanConfig = { diff --git a/open-sse/services/compression/ultra.ts b/open-sse/services/compression/ultra.ts index af02057a2f..aeb7bcc3fa 100644 --- a/open-sse/services/compression/ultra.ts +++ b/open-sse/services/compression/ultra.ts @@ -3,9 +3,41 @@ import { extractPreservedBlocks } from "./preservation.ts"; import { DEFAULT_ULTRA_CONFIG } from "./types.ts"; import type { UltraConfig, CompressionStats, CompressionMode } from "./types.ts"; import { extractTextContent, mapTextContent, type ChatMessageLike } from "./messageContent.ts"; +import { + slmAvailable, + runLlmlinguaUltra, + prewarmLlmlinguaUltra, +} from "./engines/llmlingua/ultraEntry.ts"; const COMPRESSED_PREFIX = "[COMPRESSED:"; +/** + * Async sibling of `mapTextContent`: applies an async transform to each text part + * of a message's content (string content → single call; array content → each + * `{type:"text"}` part). Non-text parts and structure are preserved exactly. + */ +async function mapTextContentAsync( + msg: Message, + fn: (text: string) => Promise +): Promise { + if (typeof msg.content === "string") { + return { ...msg, content: await fn(msg.content) }; + } + if (Array.isArray(msg.content)) { + const next: unknown[] = []; + for (const part of msg.content) { + const p = part as Record; + if (p && p["type"] === "text" && typeof p["text"] === "string") { + next.push({ ...p, text: await fn(p["text"] as string) }); + } else { + next.push(part); + } + } + return { ...msg, content: next }; + } + return msg; +} + /** * Prune PROSE only. Fenced code, inline code, URLs, CONST_CASE, versions, etc. are * tombstoned by `extractPreservedBlocks` and re-stitched verbatim, so the heuristic @@ -34,6 +66,70 @@ function pruneProseOnly(text: string, rate: number, minScore: number): string { .join(""); } +/** + * Compress one prose string with the SLM, preserving code/math/URLs verbatim. + * Reuses `extractPreservedBlocks` (same tombstoning as `pruneProseOnly`), sends + * ONLY prose to the worker backend, and re-stitches preserved blocks unchanged. + * Any backend failure (throw / no-op) falls back to the Tier-A heuristic for that + * segment, so the SLM NEVER touches structured content and NEVER fails the segment. + */ +/** + * One compressed prose segment plus whether the SLM (not the heuristic fallback) + * genuinely produced it. `usedSlm` is what the resolver records the tier from — it + * must reflect "Tier-B ran", NOT merely "the text changed" (a heuristic-fallback + * also shrinks the text, so deriving the tier from text inequality would mislabel + * a fallback as "slm"). + */ +interface ProseSlmResult { + text: string; + usedSlm: boolean; +} + +async function compressProseSlm(text: string, cfg: UltraConfig): Promise { + const { text: withPlaceholders, blocks } = extractPreservedBlocks(text); + if (blocks.length === 0) { + return slmOrHeuristic(text, cfg); + } + const placeholderToContent = new Map(blocks.map((b) => [b.placeholder, b.content])); + const escaped = blocks.map((b) => b.placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); + const splitRe = new RegExp(`(${escaped.join("|")})`, "g"); + const parts = withPlaceholders.split(splitRe); + const out: string[] = []; + let usedSlm = false; + for (const part of parts) { + if (!part) { + out.push(""); + continue; + } + const preserved = placeholderToContent.get(part); + if (preserved !== undefined) { + out.push(preserved); // verbatim — never sent to the model + } else { + const seg = await slmOrHeuristic(part, cfg); + out.push(seg.text); + if (seg.usedSlm) usedSlm = true; + } + } + return { text: out.join(""), usedSlm }; +} + +/** + * Run the SLM on a prose segment; on throw/no-op, fall back to the Tier-A pruner for it. + * `usedSlm` is true ONLY when the SLM backend itself produced the output. + */ +async function slmOrHeuristic(prose: string, cfg: UltraConfig): Promise { + try { + const text = await runLlmlinguaUltra(prose, { + model: cfg.modelPath ? undefined : undefined, + compressionRate: cfg.compressionRate, + modelPath: cfg.modelPath, + }); + return { text, usedSlm: true }; + } catch { + return { text: pruneByScore(prose, cfg.compressionRate, cfg.minScoreThreshold), usedSlm: false }; + } +} + export interface UltraCompressResult { messages: Array<{ role: string; content?: string | unknown[]; [key: string]: unknown }>; stats: CompressionStats; @@ -41,9 +137,19 @@ export interface UltraCompressResult { type Message = ChatMessageLike; -export function ultraCompress( +/** Tier the ultra resolver records on the stats. */ +export type UltraTier = "slm" | "heuristic-fallback" | "heuristic"; + +/** + * Tier-A heuristic ultra (PURE, SYNCHRONOUS). Identical to the pre-B behaviour. + * Used directly by the stacked sync engine (`cavemanAdapter`) and as the fallback + * tier inside `ultraCompress`. `tier` lets the async resolver tag the resolved + * tier as either "heuristic" (chosen directly) or "heuristic-fallback" (SLM failed). + */ +export function ultraCompressHeuristic( messages: Message[], - config: Partial = {} + config: Partial = {}, + tier: UltraTier = "heuristic" ): UltraCompressResult { const start = Date.now(); const effectiveConfig: UltraConfig = { @@ -93,7 +199,123 @@ export function ultraCompress( mode: "ultra" as CompressionMode, timestamp: Date.now(), durationMs: Date.now() - start, + ultraTier: tier, }; return { messages: compressed, stats }; } + +/** + * Ultra compression with the two-tier resolver (Phase 4, Sub-project B). + * + * - `ultraEngine: "slm"` AND `slmAvailable()` → route prose through the SLM worker + * backend (Tier-B). On timeout / worker error / load failure / no-op → fall back + * to the Tier-A heuristic for THIS request and record "heuristic-fallback". + * - otherwise → Tier-A heuristic ("heuristic"). + * + * The structure-preservation wrapper (`extractPreservedBlocks` / re-stitch, inside + * `pruneProseOnly` for Tier-A and `splitProseAndPreserved` inside the worker engine + * path for Tier-B) wraps BOTH tiers, so code/math/URLs stay verbatim regardless. + * A request is NEVER failed or left uncompressed because the SLM was unavailable. + */ +export async function ultraCompress( + messages: Message[], + config: Partial & { ultraEngine?: "heuristic" | "slm" } = {} +): Promise { + if (config.ultraEngine !== "slm" || !slmAvailable()) { + return ultraCompressHeuristic(messages, config, "heuristic"); + } + + const start = Date.now(); + const effectiveConfig: UltraConfig = { ...DEFAULT_ULTRA_CONFIG, ...config }; + const { maxTokensPerMessage } = effectiveConfig; + + let originalChars = 0; + let compressedChars = 0; + let anySlm = false; + + try { + const compressed: Message[] = []; + for (const msg of messages) { + if (effectiveConfig.preserveSystemPrompt !== false && msg.role === "system") { + compressed.push(msg); + continue; + } + const text = extractTextContent(msg.content); + if (!text || text.startsWith(COMPRESSED_PREFIX)) { + compressed.push(msg); + continue; + } + if (maxTokensPerMessage > 0 && Math.ceil(text.length / 4) <= maxTokensPerMessage) { + compressed.push(msg); + continue; + } + + let messageOriginalChars = 0; + let messageCompressedChars = 0; + const next = (await mapTextContentAsync(msg, async (textPart) => { + if (!textPart || textPart.startsWith(COMPRESSED_PREFIX)) return textPart; + messageOriginalChars += textPart.length; + const { text: out, usedSlm } = await compressProseSlm(textPart, effectiveConfig); + if (usedSlm) anySlm = true; + messageCompressedChars += out.length; + return out; + })) as Message; + originalChars += messageOriginalChars; + compressedChars += messageCompressedChars; + compressed.push(next); + } + + const originalTokens = Math.ceil(originalChars / 4); + const compressedTokens = Math.ceil(compressedChars / 4); + const savingsPercent = + originalTokens > 0 + ? Math.round(((originalTokens - compressedTokens) / originalTokens) * 100 * 10) / 10 + : 0; + + const stats: CompressionStats = { + originalTokens, + compressedTokens, + savingsPercent, + techniquesUsed: anySlm ? ["ultra-slm"] : ["ultra-heuristic-pruning"], + mode: "ultra" as CompressionMode, + timestamp: Date.now(), + durationMs: Date.now() - start, + ultraTier: anySlm ? "slm" : "heuristic-fallback", + }; + return { messages: compressed, stats }; + } catch { + // Any unexpected error in the SLM path → whole-request fail-open to Tier-A. + return ultraCompressHeuristic(messages, config, "heuristic-fallback"); + } +} + +/** + * Pure decision: should the ultra SLM model be pre-warmed for this config? + * True only when the SLM tier is selected AND pre-warm is enabled. The CALLER + * decides timing (enable-transition or cold-start) and fires `prewarmLlmlinguaUltra` + * best-effort; this helper stays clock-free / side-effect-free. + */ +export function shouldPrewarmUltraSlm(config: { + ultraEngine?: "heuristic" | "slm"; + ultraSlmPrewarm?: boolean; +}): boolean { + return config.ultraEngine === "slm" && config.ultraSlmPrewarm === true; +} + +/** + * Best-effort: when the resolved config selects the SLM tier WITH pre-warm, + * trigger a single warm call. Awaitable for tests; call sites fire-and-forget + * (`void maybePrewarmUltraSlmOnConfig(cfg)`). Never throws. + */ +export async function maybePrewarmUltraSlmOnConfig(config: { + ultraEngine?: "heuristic" | "slm"; + ultraSlmPrewarm?: boolean; +}): Promise { + if (!shouldPrewarmUltraSlm(config)) return; + try { + await prewarmLlmlinguaUltra(); + } catch { + // best-effort + } +} diff --git a/open-sse/services/tierConfig.ts b/open-sse/services/tierConfig.ts index 52d7aee2c5..7bc5945fe8 100644 --- a/open-sse/services/tierConfig.ts +++ b/open-sse/services/tierConfig.ts @@ -1,10 +1,22 @@ /** * Tier configuration schema with Zod validation and sensible defaults. + * + * `freeProviders` is the union of two sources: + * 1. Legacy explicit list (`LEGACY_FREE_PROVIDERS`) — keeps historical behavior + * for providers that aren't noAuth (e.g., groq, cerebras) so the old test + * suite and existing DB overrides keep working. + * 2. Auto-derived from `NOAUTH_PROVIDERS` where `noAuth === true` AND + * `serviceKinds` is empty or includes "llm" (excludes free audio/video + * gateways like veo-free that would never feed the chat tier resolver). + * See `deriveNoAuthFreeProviders()`. + * + * The two are merged into `DEFAULT_TIER_CONFIG.freeProviders` at module load. */ import { z } from "zod"; import type { TierConfig, ProviderTierOverride, ModelTierOverride } from "./tierTypes"; import { PROVIDER_TIER } from "./tierTypes"; +import { NOAUTH_PROVIDERS } from "@/shared/constants/providers"; export const providerTierOverrideSchema = z.object({ provider: z.string().min(1), @@ -28,6 +40,55 @@ export const tierConfigSchema = z.object({ freeProviders: z.array(z.string()).default([]), }); +/** + * Legacy explicit free providers — kept for back-compat with existing DB rows + * and test fixtures. New free providers should be added by registering them + * in `NOAUTH_PROVIDERS` with `noAuth: true` instead of editing this list. + */ +export const LEGACY_FREE_PROVIDERS: readonly string[] = [ + "kiro", + "qoder", + "pollinations", + "longcat", + "cloudflare-ai", + "qwen", + "gemini-cli", + "nvidia-nim", + "cerebras", + "groq", +]; + +/** + * Derive free provider IDs from `NOAUTH_PROVIDERS`. Only chat-tier noAuth + * providers count (serviceKinds === undefined || includes "llm"), so the + * free video / audio / image noAuth entries (e.g. veo-free, muse-spark) don't + * accidentally mark themselves as chat-free. + * + * Wrapped in try/catch to tolerate the import failing in non-Node runtimes + * (vitest worker setup, MCP server entry) — the legacy list still applies. + */ +export function deriveNoAuthFreeProviders(): string[] { + try { + const ids: string[] = []; + for (const def of Object.values(NOAUTH_PROVIDERS)) { + if (!def || typeof def !== "object") continue; + if (def.noAuth !== true) continue; + const kinds = (def as { serviceKinds?: unknown }).serviceKinds; + const isLlm = + !Array.isArray(kinds) || kinds.length === 0 || (kinds as unknown[]).includes("llm"); + if (!isLlm) continue; + if (typeof def.id === "string" && def.id.length > 0) { + ids.push(def.id); + } + } + return ids; + } catch { + return []; + } +} + +const NOAUTH_FREE_PROVIDERS = deriveNoAuthFreeProviders(); + export const DEFAULT_TIER_CONFIG: TierConfig = { version: "1.0.0", defaults: { @@ -36,18 +97,7 @@ export const DEFAULT_TIER_CONFIG: TierConfig = { }, providerOverrides: [], modelOverrides: [], - freeProviders: [ - "kiro", - "qoder", - "pollinations", - "longcat", - "cloudflare-ai", - "qwen", - "gemini-cli", - "nvidia-nim", - "cerebras", - "groq", - ], + freeProviders: [...new Set([...LEGACY_FREE_PROVIDERS, ...NOAUTH_FREE_PROVIDERS])], }; export function validateTierConfig(raw: unknown): TierConfig { diff --git a/package-lock.json b/package-lock.json index 8a1767faf8..dd45784f65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.8.34", + "version": "3.8.35", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.8.34", + "version": "3.8.35", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -28502,7 +28502,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.8.34" + "version": "3.8.35" } } } diff --git a/package.json b/package.json index a27020b7bc..9d7e41682a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.8.34", + "version": "3.8.35", "description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { @@ -75,6 +75,7 @@ "prebuild:docs": "node scripts/docs/gen-openapi-module.mjs", "gen:provider-reference": "node --import tsx scripts/docs/gen-provider-reference.ts", "bench:compression": "node --import tsx scripts/compression/benchmark.ts", + "eval:compression": "node --import tsx scripts/compression-eval/index.ts", "build": "node scripts/build/build-next-isolated.mjs", "build:secure": "OMNIROUTE_BUILD_PROFILE=minimal node scripts/build/build-next-isolated.mjs", "build:cli": "node --import tsx scripts/build/prepublish.ts", diff --git a/public/openapi.yaml b/public/openapi.yaml new file mode 100644 index 0000000000..ca00d23623 --- /dev/null +++ b/public/openapi.yaml @@ -0,0 +1,6690 @@ +openapi: 3.1.0 +info: + title: OmniRoute API + version: 3.8.35 + description: | + OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible + endpoint that routes requests to multiple AI providers with load balancing, + failover, and usage tracking. + + ## Base URLs + - **Local**: `http://localhost:20128` + + ## Authentication + All proxy endpoints require a Bearer token (API key managed via the dashboard). + Management endpoints are protected when `requireLogin` is enabled. + contact: + name: OmniRoute + license: + name: MIT + +servers: + - url: http://localhost:20128 + description: Local development + +tags: + - name: Playground + description: Playground Studio — preset management and prompt improvement + - name: Memory + description: Conversational memory management — CRUD, engine status, playground preview, summarization, reindex, and Qdrant settings (plan 21 — v3.8.6). All routes require management auth. + - name: Chat + description: OpenAI-compatible chat completions + - name: Messages + description: Anthropic-compatible messages + - name: Responses + description: OpenAI Responses API + - name: Embeddings + description: Text embedding generation + - name: Images + description: Image generation + - name: Audio + description: Audio speech and transcription + - name: Moderations + description: Content moderation + - name: Rerank + description: Document reranking + - name: Models + description: Available model listing + - name: Providers + description: Provider connection management + - name: Provider Nodes + description: Provider node configuration + - name: API Keys + description: API key management + - name: Combos + description: Routing combo management + - name: Settings + description: Application settings + - name: Compression + description: Prompt compression, RTK filters, Caveman rules, and compression combos + - name: Usage + description: Usage analytics and logs + - name: Translator + description: Format translation debug & testing + - name: CLI Tools + description: CLI tool configuration management + - name: Embedded Services + description: >- + Install, start, stop, and monitor locally-running embedded services (9Router, CLIProxyAPI). + All routes are LOCAL_ONLY — accessible from loopback only (hard rule #17). + - name: OAuth + description: OAuth flows for provider authentication + - name: System + description: System management (restart, shutdown, backup) + - name: Pricing + description: Model pricing configuration + - name: Cloud + description: Cloud worker authentication and sync + - name: Fallback + description: Fallback chain management + - name: Telemetry + description: Telemetry and token health monitoring + - name: Agent Skills + description: >- + Agent Skills catalog — 42 SKILL.md files (22 REST API + 20 CLI) for external agents, + MCP clients, and A2A orchestrators to discover OmniRoute capabilities. + - name: AgentBridge + description: >- + MITM proxy manager for 9 IDE agents (Antigravity, Kiro, Copilot, Codex, Cursor, Zed, + Claude Code, Open Code, Trae). Controls server lifecycle, DNS/model mappings, bypass list, + and cert management. All routes are LOCAL_ONLY + SPAWN_CAPABLE (hard rules #15, #17). + See docs/frameworks/AGENTBRIDGE.md. + - name: Traffic Inspector + description: >- + LLM-aware HTTPS traffic debugger with 4 capture modes (AgentBridge, Custom Hosts, + HTTP_PROXY :8080, System-wide). Provides real-time WebSocket stream, session recording, + HAR export, SSE merge, and conversation normalization. + All routes are LOCAL_ONLY + SPAWN_CAPABLE (hard rules #15, #17). + See docs/frameworks/TRAFFIC_INSPECTOR.md. + +paths: + # --- Playground + Search Tools (plans 17+18) --- + /api/playground/improve-prompt: + post: + tags: + - Playground + summary: Improve prompt via LLM + description: | + Rewrites the supplied system prompt and/or user prompt using a meta-prompt + (inspired by Anthropic Console Prompt Improver). Internally calls + `/v1/chat/completions` with the model specified in the request body. + Quota is consumed from the caller's account. + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - model + properties: + system: + type: string + maxLength: 50000 + description: System prompt to improve (at least one of system/prompt required) + prompt: + type: string + maxLength: 50000 + description: User prompt to improve + model: + type: string + description: Model to use for the improvement call (e.g. openai/gpt-4o) + tone: + type: string + enum: + - concise + - detailed + default: concise + responses: + "200": + description: Improved prompt(s) + content: + application/json: + schema: + type: object + properties: + improvedSystem: + type: string + improvedPrompt: + type: string + tokensIn: + type: integer + tokensOut: + type: integer + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + /api/playground/presets: + get: + tags: + - Playground + summary: List playground presets + description: Returns all saved playground presets ordered by creation date (newest first). + security: + - BearerAuth: [] + responses: + "200": + description: Preset list + content: + application/json: + schema: + type: object + properties: + presets: + type: array + items: + $ref: "#/components/schemas/PlaygroundPreset" + "401": + $ref: "#/components/responses/Unauthorized" + post: + tags: + - Playground + summary: Create playground preset + description: Saves the current playground configuration as a named preset in the database. + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PlaygroundPresetCreate" + responses: + "201": + description: Created preset + content: + application/json: + schema: + $ref: "#/components/schemas/PlaygroundPreset" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + /api/playground/presets/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + get: + tags: + - Playground + summary: Get playground preset + security: + - BearerAuth: [] + responses: + "200": + description: Preset found + content: + application/json: + schema: + $ref: "#/components/schemas/PlaygroundPreset" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + description: Preset not found + put: + tags: + - Playground + summary: Update playground preset + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PlaygroundPresetCreate" + responses: + "200": + description: Updated preset + content: + application/json: + schema: + $ref: "#/components/schemas/PlaygroundPreset" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + description: Preset not found + delete: + tags: + - Playground + summary: Delete playground preset + security: + - BearerAuth: [] + responses: + "204": + description: Deleted + "401": + $ref: "#/components/responses/Unauthorized" + "404": + description: Preset not found + # --- Memory Engine (plan 21) --- + /api/memory: + get: + tags: + - Memory + summary: List memory entries + security: + - ManagementSessionAuth: [] + parameters: + - name: apiKeyId + in: query + schema: + type: string + - name: type + in: query + schema: + type: string + enum: + - factual + - episodic + - procedural + - semantic + - name: sessionId + in: query + schema: + type: string + - name: q + in: query + schema: + type: string + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + - name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + - name: offset + in: query + schema: + type: integer + minimum: 0 + responses: + "200": + description: Paginated list of memories with stats + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/MemoryEntry" + total: + type: integer + totalPages: + type: integer + stats: + type: object + properties: + total: + type: integer + tokensUsed: + type: integer + hitRate: + type: number + cacheStats: + type: object + properties: + hits: + type: integer + misses: + type: integer + "401": + $ref: "#/components/responses/Unauthorized" + post: + tags: + - Memory + summary: Create a memory entry + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - content + - key + properties: + content: + type: string + minLength: 1 + key: + type: string + minLength: 1 + type: + type: string + enum: + - factual + - episodic + - procedural + - semantic + default: factual + sessionId: + type: string + nullable: true + apiKeyId: + type: string + metadata: + type: object + additionalProperties: true + expiresAt: + type: string + format: date-time + nullable: true + responses: + "201": + description: Created memory entry + content: + application/json: + schema: + $ref: "#/components/schemas/MemoryEntry" + "400": + description: Validation error + "401": + $ref: "#/components/responses/Unauthorized" + /api/memory/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Memory UUID + get: + tags: + - Memory + summary: Get a single memory entry + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Memory entry + content: + application/json: + schema: + $ref: "#/components/schemas/MemoryEntry" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + description: Memory not found + put: + tags: + - Memory + summary: Update a memory entry + description: Update `type`, `key`, `content`, and/or `metadata` of an existing memory. If an embedding source is available, the vector in `vec_memories` is also regenerated. Corresponds to `MemoryUpdatePutSchema`. + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + type: + type: string + enum: + - factual + - episodic + - procedural + - semantic + key: + type: string + minLength: 1 + content: + type: string + minLength: 1 + metadata: + type: object + additionalProperties: true + additionalProperties: false + responses: + "200": + description: Updated memory entry + content: + application/json: + schema: + $ref: "#/components/schemas/MemoryEntry" + "400": + description: Validation error + "401": + $ref: "#/components/responses/Unauthorized" + "404": + description: Memory not found + delete: + tags: + - Memory + summary: Delete a memory entry + description: Deletes the SQLite row, removes the vector from `vec_memories`, and best-effort deletes the point from Qdrant. + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Deleted + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + "401": + $ref: "#/components/responses/Unauthorized" + "404": + description: Memory not found + /api/memory/health: + get: + tags: + - Memory + summary: Memory store health check + description: Round-trip create→list→delete to verify the store is alive. + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Health result + content: + application/json: + schema: + type: object + properties: + working: + type: boolean + latencyMs: + type: number + error: + type: string + nullable: true + "401": + $ref: "#/components/responses/Unauthorized" + /api/memory/retrieve-preview: + post: + tags: + - Memory + summary: Dry-run memory retrieval (Playground) + description: Simulates `retrieveMemories()` for a given query and returns the ranked results with score, tier, and token count. Does NOT modify any memory. Corresponds to `RetrievePreviewSchema`. + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - query + properties: + query: + type: string + minLength: 1 + strategy: + type: string + enum: + - exact + - semantic + - hybrid + default: hybrid + maxTokens: + type: integer + minimum: 1 + maximum: 16000 + default: 2000 + apiKeyId: + type: string + description: Optional — tests global pool when omitted + limit: + type: integer + minimum: 1 + maximum: 100 + default: 20 + additionalProperties: false + responses: + "200": + description: Preview results + content: + application/json: + schema: + type: object + properties: + memories: + type: array + items: + type: object + properties: + id: + type: string + type: + type: string + enum: + - factual + - episodic + - procedural + - semantic + key: + type: string + content: + type: string + score: + type: number + tokens: + type: integer + tier: + type: string + enum: + - fts5 + - vector + - hybrid-rrf + - qdrant + vecScore: + type: number + nullable: true + ftsScore: + type: number + nullable: true + resolution: + type: object + properties: + embeddingSource: + type: string + enum: + - remote + - static + - transformers + nullable: true + embeddingModel: + type: string + nullable: true + vectorStore: + type: string + enum: + - sqlite-vec + - qdrant + - none + strategyUsed: + type: string + enum: + - exact + - semantic + - hybrid + rerankApplied: + type: boolean + fallbackReason: + type: string + nullable: true + totalTokensUsed: + type: integer + budgetMaxTokens: + type: integer + "400": + description: Validation error + "401": + $ref: "#/components/responses/Unauthorized" + /api/memory/embedding-providers: + get: + tags: + - Memory + summary: List embedding providers + description: Returns all providers that have embedding-capable models, indicating which have an active API key configured. + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Provider list + content: + application/json: + schema: + type: object + properties: + providers: + type: array + items: + type: object + properties: + provider: + type: string + hasKey: + type: boolean + models: + type: array + items: + type: object + properties: + id: + type: string + description: "Format: provider/model" + name: + type: string + dimensions: + type: integer + nullable: true + "401": + $ref: "#/components/responses/Unauthorized" + /api/memory/engine-status: + get: + tags: + - Memory + summary: Memory engine status + description: Returns the full engine status including keyword tier availability, embedding resolution, vector store statistics (sqlite-vec), Qdrant health, and rerank configuration. Corresponds to `MemoryEngineStatusSchema`. + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Engine status + content: + application/json: + schema: + type: object + properties: + keyword: + type: object + properties: + available: + type: boolean + backend: + type: string + enum: + - FTS5 + embedding: + type: object + properties: + source: + type: string + enum: + - remote + - static + - transformers + nullable: true + model: + type: string + nullable: true + dimensions: + type: integer + nullable: true + available: + type: boolean + reason: + type: string + cacheStats: + type: object + properties: + hits: + type: integer + misses: + type: integer + size: + type: integer + vectorStore: + type: object + properties: + backend: + type: string + enum: + - sqlite-vec + - qdrant + - none + available: + type: boolean + rowCount: + type: integer + needsReindex: + type: integer + reason: + type: string + qdrant: + type: object + properties: + enabled: + type: boolean + healthy: + type: boolean + nullable: true + latencyMs: + type: number + nullable: true + error: + type: string + nullable: true + rerank: + type: object + properties: + enabled: + type: boolean + provider: + type: string + nullable: true + model: + type: string + nullable: true + available: + type: boolean + reason: + type: string + "401": + $ref: "#/components/responses/Unauthorized" + /api/memory/summarize: + post: + tags: + - Memory + summary: Compact old memories + description: "Manually triggers memory compaction for memories older than `olderThanDays`. Use `dryRun: true` to preview candidates. Corresponds to `MemorySummarizeSchema`." + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + olderThanDays: + type: integer + minimum: 1 + maximum: 365 + default: 30 + apiKeyId: + type: string + description: Optional — compacts all keys when omitted + dryRun: + type: boolean + default: false + additionalProperties: false + responses: + "200": + description: Summarization result + content: + application/json: + schema: + type: object + properties: + candidates: + type: integer + tokensSaved: + type: integer + dryRun: + type: boolean + "400": + description: Validation error + "401": + $ref: "#/components/responses/Unauthorized" + /api/memory/reindex: + post: + tags: + - Memory + summary: Trigger vector reindex + description: "Starts background reindexing of memories with `needs_reindex = 1`. Use `force: true` to regenerate ALL vectors regardless of index status. Corresponds to `MemoryReindexSchema`." + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + force: + type: boolean + default: false + description: When true, marks all memories needs_reindex=1 before running. + additionalProperties: false + responses: + "200": + description: Reindex started + content: + application/json: + schema: + type: object + properties: + started: + type: boolean + pending: + type: integer + description: Memories still pending after this batch + "400": + description: Validation error + "401": + $ref: "#/components/responses/Unauthorized" + /api/settings/memory: + get: + tags: + - Memory + - Settings + summary: Get memory settings + description: Returns the extended memory settings including 7 new fields added in plan 21 (embeddingSource, embeddingProviderModel, transformersEnabled, staticEnabled, rerankEnabled, rerankProviderModel, vectorStore). + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Extended memory settings + content: + application/json: + schema: + $ref: "#/components/schemas/MemorySettingsExtended" + "401": + $ref: "#/components/responses/Unauthorized" + put: + tags: + - Memory + - Settings + summary: Update memory settings + description: "Update any subset of the extended memory settings. All fields are optional; only provided fields are updated. Schema: `MemorySettingsExtendedSchema`." + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MemorySettingsExtended" + responses: + "200": + description: Updated memory settings + content: + application/json: + schema: + $ref: "#/components/schemas/MemorySettingsExtended" + "400": + description: Validation error + "401": + $ref: "#/components/responses/Unauthorized" + /api/settings/qdrant: + get: + tags: + - Memory + - Settings + summary: Get Qdrant settings + description: Returns current Qdrant configuration. The `apiKey` field is never returned raw — use `hasApiKey` / `apiKeyMasked` instead. + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Qdrant settings + content: + application/json: + schema: + $ref: "#/components/schemas/QdrantSettings" + "401": + $ref: "#/components/responses/Unauthorized" + put: + tags: + - Memory + - Settings + summary: Update Qdrant settings + description: 'Update Qdrant configuration. Pass `apiKey: ""` to remove the stored key. Schema: `QdrantSettingsUpdateSchema`.' + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + host: + type: string + port: + type: integer + minimum: 1 + maximum: 65535 + collection: + type: string + minLength: 1 + embeddingModel: + type: string + minLength: 1 + apiKey: + type: string + description: Empty string removes the key + additionalProperties: false + responses: + "200": + description: Updated Qdrant settings + content: + application/json: + schema: + $ref: "#/components/schemas/QdrantSettings" + "400": + description: Validation error + "401": + $ref: "#/components/responses/Unauthorized" + /api/settings/qdrant/health: + get: + tags: + - Memory + summary: Qdrant health probe + description: Performs a liveness check against the configured Qdrant instance. Returns latency and any connection error (sanitized — no stack traces). + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Health result + content: + application/json: + schema: + $ref: "#/components/schemas/QdrantHealthResult" + "401": + $ref: "#/components/responses/Unauthorized" + /api/settings/qdrant/search: + post: + tags: + - Memory + summary: Qdrant semantic search test + description: "Performs a test semantic search against the Qdrant collection. Useful for validating that the integration works end-to-end. Schema: `QdrantSearchSchema`." + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - query + properties: + query: + type: string + minLength: 1 + topK: + type: integer + minimum: 1 + maximum: 50 + default: 5 + additionalProperties: false + responses: + "200": + description: Search results + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + type: object + "400": + description: Validation error + "401": + $ref: "#/components/responses/Unauthorized" + "503": + description: Qdrant unavailable (structured error, no stack trace) + /api/settings/qdrant/cleanup: + post: + tags: + - Memory + summary: Clean up expired Qdrant points + description: Removes Qdrant points for memories that have expired or exceeded the configured retention window. + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Cleanup result + content: + application/json: + schema: + type: object + properties: + deleted: + type: integer + checked: + type: integer + "401": + $ref: "#/components/responses/Unauthorized" + "503": + description: Qdrant unavailable (structured error, no stack trace) + /api/settings/qdrant/embedding-models: + get: + tags: + - Memory + summary: List Qdrant embedding models + description: Returns the list of embedding models available for use with Qdrant. + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Embedding models list + content: + application/json: + schema: + type: object + properties: + models: + type: array + items: + type: string + "401": + $ref: "#/components/responses/Unauthorized" + # ─── Proxy Endpoints ────────────────────────────────────────── + + /api/v1/chat/completions: + post: + tags: [Chat] + summary: Create chat completion + description: OpenAI-compatible chat completions endpoint. Routes to configured providers. + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ChatCompletionRequest" + responses: + "200": + description: Chat completion response (or SSE stream) + headers: + X-OmniRoute-Response-Cost: + schema: + type: string + description: Request cost in USD, fixed 10 decimals (e.g. `0.0001234500`; `0.0000000000` for free/unpriced). + X-OmniRoute-Tokens-In: + schema: + type: string + description: Input (prompt) token count. + X-OmniRoute-Tokens-Out: + schema: + type: string + description: Output (completion) token count. + X-OmniRoute-Model: + schema: + type: string + description: Resolved model. + X-OmniRoute-Provider: + schema: + type: string + description: Resolved provider alias. + X-OmniRoute-Latency-Ms: + schema: + type: string + description: Handler latency in milliseconds. + X-OmniRoute-Cache-Hit: + schema: + type: string + enum: ["true", "false"] + description: Whether the response was served from cache. + X-OmniRoute-Fallback-Attempts: + schema: + type: string + description: Number of fallback attempts (only present when > 0). + X-OmniRoute-Request-Id: + schema: + type: string + description: Request correlation id (present when known). + X-OmniRoute-Version: + schema: + type: string + description: OmniRoute build version (always present). + X-OmniRoute-Cost-Saved: + schema: + type: string + description: >- + On a semantic-cache HIT, the original (would-have-been) cost in USD that + the cache avoided (fixed 10 decimals). Present only on cache hits; + X-OmniRoute-Response-Cost is 0 for the same response (incremental cost). + content: + application/json: + schema: + $ref: "#/components/schemas/ChatCompletionResponse" + text/event-stream: + schema: + type: string + "401": + $ref: "#/components/responses/Unauthorized" + "502": + description: All upstream providers failed + + /api/v1/ws: + get: + tags: [Chat] + summary: Chat completion over WebSocket (handshake + upgrade) + description: >- + OpenAI-compatible chat over a WebSocket connection. `GET` with + `?handshake=1` returns the connection descriptor (auth path, message + protocol and live-event channels) as JSON; a plain `GET` without an + Upgrade returns `426 Upgrade Required`. After upgrading, the client + exchanges JSON frames — `{type:"request", id, payload:{model, messages}}` + to start a completion and `{type:"cancel", id}` to abort it. A separate + live channel (default port `LIVE_WS_PORT=20129`, path `/live`) streams + dashboard events on the `requests`, `combo` and `credentials` topics with + a 15s heartbeat. Requires an API key. + security: + - BearerAuth: [] + parameters: + - name: handshake + in: query + description: Set to `1` to receive the JSON connection descriptor instead of upgrading. + required: false + schema: + type: string + enum: ["1"] + responses: + "101": + description: WebSocket upgrade successful + "200": + description: Handshake descriptor (auth path, message protocol, live channels) + "401": + description: WebSocket auth required (no credential supplied) + "403": + description: Invalid WebSocket credential + "426": + description: Upgrade Required — connect via WebSocket or use `?handshake=1` + + /api/v1/providers/{provider}/chat/completions: + post: + tags: [Chat] + summary: Create chat completion (provider-specific) + description: Routes to a specific provider by name. + security: + - BearerAuth: [] + parameters: + - name: provider + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ChatCompletionRequest" + responses: + "200": + description: Chat completion response + "401": + $ref: "#/components/responses/Unauthorized" + + /api/v1/api/chat: + post: + tags: [Chat] + summary: Ollama-compatible chat endpoint + description: Provides compatibility with Ollama's /api/chat format. + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Chat response (JSON or streaming) + + /api/v1/messages: + post: + tags: [Messages] + summary: Create message (Anthropic-compatible) + description: Anthropic Messages API endpoint. Routes to Claude providers. + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MessagesRequest" + responses: + "200": + description: >- + Message response (or SSE stream). Non-streaming success responses + carry the `X-OmniRoute-*` cost-telemetry headers (see + `POST /api/v1/chat/completions`), including `X-OmniRoute-Request-Id` + and `X-OmniRoute-Version`. + "401": + $ref: "#/components/responses/Unauthorized" + + /api/v1/messages/count_tokens: + post: + tags: [Messages] + summary: Count tokens for a message + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Token count + + /api/v1/responses: + post: + tags: [Responses] + summary: Create response (OpenAI Responses API) + description: OpenAI Responses API endpoint. + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: >- + Response object or SSE stream. Non-streaming success responses carry + the `X-OmniRoute-*` cost-telemetry headers (see + `POST /api/v1/chat/completions`), including `X-OmniRoute-Request-Id` + and `X-OmniRoute-Version`. + "401": + $ref: "#/components/responses/Unauthorized" + + /api/v1/embeddings: + post: + tags: [Embeddings] + summary: Create embeddings + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [input, model] + properties: + input: + oneOf: + - type: string + - type: array + items: + type: string + model: + type: string + responses: + "200": + description: >- + Embedding vectors. Success responses carry the `X-OmniRoute-*` + cost-telemetry headers (see `POST /api/v1/chat/completions`); media + cost is computed per modality when pricing is available, otherwise + `0` (fail-open). + + /api/v1/providers/{provider}/embeddings: + post: + tags: [Embeddings] + summary: Create embeddings (provider-specific) + security: + - BearerAuth: [] + parameters: + - name: provider + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Embedding vectors + + /api/v1/images/generations: + post: + tags: [Images] + summary: Generate images + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [prompt] + properties: + prompt: + type: string + model: + type: string + n: + type: integer + default: 1 + size: + type: string + default: 1024x1024 + responses: + "200": + description: >- + Generated images. Success responses carry the `X-OmniRoute-*` + cost-telemetry headers (see `POST /api/v1/chat/completions`); image + cost is computed per image when pricing is available, otherwise `0` + (fail-open). + + /api/v1/providers/{provider}/images/generations: + post: + tags: [Images] + summary: Generate images (provider-specific) + security: + - BearerAuth: [] + parameters: + - name: provider + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Generated images + + /api/v1/audio/speech: + post: + tags: [Audio] + summary: Generate speech audio + description: Text-to-speech endpoint. Routes to configured TTS providers. + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [input] + properties: + input: + type: string + model: + type: string + voice: + type: string + responses: + "200": + description: >- + Audio data. Success responses carry the `X-OmniRoute-*` + cost-telemetry headers (see `POST /api/v1/chat/completions`); speech + cost is computed per character when pricing is available, otherwise + `0` (fail-open). + + /api/v1/audio/transcriptions: + post: + tags: [Audio] + summary: Transcribe audio + description: Audio-to-text transcription endpoint. + security: + - BearerAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + model: + type: string + responses: + "200": + description: >- + Transcription result. Success responses carry the `X-OmniRoute-*` + cost-telemetry headers (see `POST /api/v1/chat/completions`); + transcription cost is computed per second when pricing is available, + otherwise `0` (fail-open). + + /api/v1/moderations: + post: + tags: [Moderations] + summary: Create moderation + description: Content moderation endpoint. Routes to configured moderation providers. + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [input] + properties: + input: + oneOf: + - type: string + - type: array + items: + type: string + responses: + "200": + description: >- + Moderation result. Success responses carry the `X-OmniRoute-*` + cost-telemetry headers (see `POST /api/v1/chat/completions`); + moderations are always cost `0` (free). + + /api/v1/rerank: + post: + tags: [Rerank] + summary: Rerank documents + description: Document reranking endpoint. + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [query, documents] + properties: + query: + type: string + documents: + type: array + items: + type: string + model: + type: string + responses: + "200": + description: >- + Reranked documents. Success responses carry the `X-OmniRoute-*` + cost-telemetry headers (see `POST /api/v1/chat/completions`); rerank + cost is computed per search-unit when pricing is available, + otherwise `0` (fail-open). + + /api/v1: + get: + tags: [System] + summary: API v1 root endpoint + description: Returns basic API info and status. + security: + - BearerAuth: [] + responses: + "200": + description: API info + + /api/v1/models: + get: + tags: [Models] + summary: List available models + description: Returns all models available across configured providers. + security: + - BearerAuth: [] + responses: + "200": + description: Model list + content: + application/json: + schema: + type: object + properties: + object: + type: string + example: list + data: + type: array + items: + $ref: "#/components/schemas/Model" + + /api/v1/providers/{provider}/models: + get: + tags: [Models] + summary: List models for a specific provider + description: Returns only models for the selected provider with provider prefix removed from each model id. + security: + - BearerAuth: [] + parameters: + - in: path + name: provider + required: true + schema: + type: string + description: Provider id or alias (for example `openai`, `claude`, `cc`). + responses: + "200": + description: Provider-scoped model list + content: + application/json: + schema: + type: object + properties: + object: + type: string + example: list + data: + type: array + items: + $ref: "#/components/schemas/Model" + "400": + description: Unknown provider + + /api/models: + get: + tags: [Models] + summary: List models (management) + responses: + "200": + description: Internal model list with aliases + + /api/models/alias: + post: + tags: [Models] + summary: Create or update a model alias + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Alias created/updated + + /api/models/catalog: + get: + tags: [Models] + summary: Get full model catalog + responses: + "200": + description: Complete catalog with all providers + + # ─── Management Endpoints ────────────────────────────────────── + + /api/providers: + get: + tags: [Providers] + summary: List provider connections + responses: + "200": + description: Provider connection list + content: + application/json: + schema: + type: object + properties: + connections: + type: array + items: + $ref: "#/components/schemas/ProviderConnection" + post: + tags: [Providers] + summary: Create provider connection + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ProviderConnectionCreate" + responses: + "201": + description: Created provider connection + + /api/providers/{id}: + get: + tags: [Providers] + summary: Get provider connection + parameters: + - $ref: "#/components/parameters/ResourceId" + responses: + "200": + description: Provider connection details + "404": + description: Provider not found + patch: + tags: [Providers] + summary: Update provider connection + parameters: + - $ref: "#/components/parameters/ResourceId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ProviderConnectionCreate" + responses: + "200": + description: Updated provider + delete: + tags: [Providers] + summary: Delete provider connection + parameters: + - $ref: "#/components/parameters/ResourceId" + responses: + "200": + description: Provider deleted + + /api/providers/{id}/test: + post: + tags: [Providers] + summary: Test provider connection + parameters: + - $ref: "#/components/parameters/ResourceId" + responses: + "200": + description: Test result + + /api/providers/{id}/models: + get: + tags: [Providers] + summary: List models for a provider + parameters: + - $ref: "#/components/parameters/ResourceId" + responses: + "200": + description: Provider model list + + /api/providers/test-batch: + post: + tags: [Providers] + summary: Test multiple providers at once + responses: + "200": + description: Batch test results + + /api/providers/validate: + post: + tags: [Providers] + summary: Validate provider credentials + responses: + "200": + description: Validation result + + /api/providers/client: + get: + tags: [Providers] + summary: Get client-side provider info + responses: + "200": + description: Provider info for frontend + + /api/providers/agy-auth/import: + post: + tags: [Providers] + summary: Import an Antigravity CLI (agy) token file as an `agy` connection + responses: + "200": + description: Created or updated provider connection + + /api/providers/agy-auth/import-bulk: + post: + tags: [Providers] + summary: Bulk-import multiple Antigravity CLI (agy) token files (up to 50) + responses: + "200": + description: Per-entry import results (success/failed counts) + + /api/providers/agy-auth/zip-extract: + post: + tags: [Providers] + summary: Extract `.json` token files from an uploaded ZIP for agy bulk import + responses: + "200": + description: Extracted token-file entries + + /api/providers/agy-auth/apply-local: + post: + tags: [Providers] + summary: Auto-detect and import the local Antigravity CLI (agy) login from disk + responses: + "200": + description: Created or updated provider connection + "404": + description: No local agy login found + + /api/provider-nodes: + get: + tags: [Provider Nodes] + summary: List provider nodes + responses: + "200": + description: Provider node list + post: + tags: [Provider Nodes] + summary: Create provider node + responses: + "201": + description: Created node + + /api/provider-nodes/{id}: + patch: + tags: [Provider Nodes] + summary: Update provider node + parameters: + - $ref: "#/components/parameters/ResourceId" + responses: + "200": + description: Updated node + delete: + tags: [Provider Nodes] + summary: Delete provider node + parameters: + - $ref: "#/components/parameters/ResourceId" + responses: + "200": + description: Node deleted + + /api/provider-nodes/validate: + post: + tags: [Provider Nodes] + summary: Validate a provider node + responses: + "200": + description: Validation result + + /api/provider-models: + get: + tags: [Provider Nodes] + summary: List provider models + responses: + "200": + description: Provider model list + + /api/keys: + get: + tags: [API Keys] + summary: List API keys + responses: + "200": + description: API key list + content: + application/json: + schema: + type: object + properties: + keys: + type: array + items: + $ref: "#/components/schemas/ApiKey" + "401": + description: Authentication required + post: + tags: [API Keys] + summary: Create API key + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [label] + properties: + label: + type: string + responses: + "201": + description: Created API key (includes full key value) + "401": + description: Authentication required + + /api/keys/{id}: + get: + tags: [API Keys] + summary: Get API key + parameters: + - $ref: "#/components/parameters/ResourceId" + responses: + "200": + description: API key metadata + "401": + description: Authentication required + "404": + description: Key not found + patch: + tags: [API Keys] + summary: Update API key + parameters: + - $ref: "#/components/parameters/ResourceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: true + responses: + "200": + description: API key settings updated + "400": + description: Invalid update request + "401": + description: Authentication required + "404": + description: Key not found + delete: + tags: [API Keys] + summary: Delete API key + parameters: + - $ref: "#/components/parameters/ResourceId" + responses: + "200": + description: Key deleted + "401": + description: Authentication required + "404": + description: Key not found + + /api/combos: + get: + tags: [Combos] + summary: List routing combos + responses: + "200": + description: Combo list + post: + tags: [Combos] + summary: Create routing combo + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ComboCreate" + responses: + "201": + description: Created combo + + /api/combos/{id}: + patch: + tags: [Combos] + summary: Update combo + parameters: + - $ref: "#/components/parameters/ResourceId" + responses: + "200": + description: Updated combo + delete: + tags: [Combos] + summary: Delete combo + parameters: + - $ref: "#/components/parameters/ResourceId" + responses: + "200": + description: Combo deleted + + /api/combos/metrics: + get: + tags: [Combos] + summary: Get combo metrics + responses: + "200": + description: Metrics for combos + + /api/combos/test: + post: + tags: [Combos] + summary: Test a combo configuration + responses: + "200": + description: Test result + + /api/settings: + get: + tags: [Settings] + summary: Get application settings + responses: + "200": + description: Current settings + patch: + tags: [Settings] + summary: Update settings + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Updated settings + + /api/settings/purge-request-history: + post: + tags: [Settings] + summary: Clear request log history + description: Deletes `call_logs`, legacy `request_detail_logs`, and local request artifact files under `DATA_DIR/call_logs`. + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Request history cleared + content: + application/json: + schema: + type: object + properties: + deleted: + type: integer + deletedArtifacts: + type: integer + deletedDetailedLogs: + type: integer + errors: + type: integer + "401": + description: Unauthorized + "500": + description: Cleanup failed or reported errors + content: + application/json: + schema: + type: object + properties: + deleted: + type: integer + deletedArtifacts: + type: integer + deletedDetailedLogs: + type: integer + errors: + type: integer + error: + type: object + + /api/settings/compression: + get: + tags: [Compression] + summary: Get global compression settings + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Current compression settings + put: + tags: [Compression] + summary: Update global compression settings + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + defaultMode: + type: string + enum: [off, lite, standard, aggressive, ultra, rtk, stacked] + autoTriggerMode: + type: string + enum: [off, lite, standard, aggressive, ultra, rtk, stacked] + autoTriggerTokens: + type: integer + minimum: 0 + rtkConfig: + type: object + additionalProperties: true + stackedPipeline: + type: array + items: + type: object + responses: + "200": + description: Updated compression settings + + /api/settings/compression/mcp-accessibility: + get: + tags: [Compression] + summary: Get the MCP tool-output accessibility (trimming) config + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Current mcpAccessibility config + put: + tags: [Compression] + summary: Update the MCP tool-output accessibility (trimming) config + description: >- + Partial-merge update. Numeric floors (e.g. a maxTextChars below the truncation-tail + reserve) are folded back to the safe defaults server-side, so the response reflects the + effective config. + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + maxTextChars: + type: integer + minimum: 1 + collapseThreshold: + type: integer + minimum: 1 + collapseKeepHead: + type: integer + minimum: 0 + collapseKeepTail: + type: integer + minimum: 0 + minLengthToProcess: + type: integer + minimum: 1 + responses: + "200": + description: Updated mcpAccessibility config (numeric floors applied) + + /api/compression/preview: + post: + tags: [Compression] + summary: Preview compression for a message payload + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [messages, mode] + properties: + mode: + type: string + enum: [off, lite, standard, aggressive, ultra, rtk, stacked] + messages: + type: array + items: + type: object + required: [role, content] + properties: + role: + type: string + content: + oneOf: + - type: string + - type: array + items: {} + config: + type: object + additionalProperties: true + responses: + "200": + description: Compression preview with diff, validation, and stats + + /api/compression/language-packs: + get: + tags: [Compression] + summary: List Caveman compression language packs + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Available languages and rule-pack metadata + + /api/compression/rules: + get: + tags: [Compression] + summary: List Caveman compression rule metadata + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Caveman rule metadata + + /api/context/rtk/config: + get: + tags: [Compression] + summary: Get RTK compression settings + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Current RTK config + put: + tags: [Compression] + summary: Update RTK compression settings + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + intensity: + type: string + enum: [minimal, standard, aggressive] + customFiltersEnabled: + type: boolean + trustProjectFilters: + type: boolean + rawOutputRetention: + type: string + enum: [never, failures, always] + rawOutputMaxBytes: + type: integer + responses: + "200": + description: Updated RTK config + + /api/context/rtk/filters: + get: + tags: [Compression] + summary: List RTK filters and load diagnostics + security: + - ManagementSessionAuth: [] + responses: + "200": + description: RTK filter catalog and diagnostics + + /api/context/rtk/test: + post: + tags: [Compression] + summary: Run RTK compression preview for text + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [text] + properties: + text: + type: string + command: + type: string + config: + type: object + additionalProperties: true + responses: + "200": + description: Detection and RTK compression result + + /api/context/rtk/raw-output/{id}: + get: + tags: [Compression] + summary: Read retained redacted RTK raw output + security: + - ManagementSessionAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + pattern: "^[a-f0-9]{24}$" + responses: + "200": + description: Raw output text + "404": + description: Raw output not found + + /api/settings/payload-rules: + get: + tags: [Settings] + summary: Get payload rules configuration + description: | + Returns the current payload rules used to mutate outgoing request payloads before they + are sent upstream. + + Requires a dashboard management session cookie when management auth is enabled. + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Current payload rules configuration + content: + application/json: + schema: + $ref: "#/components/schemas/PayloadRulesConfig" + "401": + $ref: "#/components/responses/ManagementAuthenticationRequired" + "403": + $ref: "#/components/responses/ManagementInvalidToken" + "500": + description: Failed to read payload rules configuration + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + put: + tags: [Settings] + summary: Update payload rules configuration + description: | + Persists and hot reloads payload rules. The legacy input field `default-raw` is accepted + on writes and normalized to `defaultRaw` in responses/runtime state. + + Requires a dashboard management session cookie when management auth is enabled. + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdatePayloadRulesRequest" + responses: + "200": + description: Updated payload rules configuration + content: + application/json: + schema: + $ref: "#/components/schemas/PayloadRulesConfig" + "400": + $ref: "#/components/responses/ValidationError" + "401": + $ref: "#/components/responses/ManagementAuthenticationRequired" + "403": + $ref: "#/components/responses/ManagementInvalidToken" + "500": + description: Failed to update payload rules configuration + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + + /api/settings/combo-defaults: + get: + tags: [Settings] + summary: Get combo default settings + responses: + "200": + description: Default combo settings + + /api/settings/proxy: + get: + tags: [Settings] + summary: Get proxy settings + responses: + "200": + description: Current proxy settings + patch: + tags: [Settings] + summary: Update proxy settings + responses: + "200": + description: Updated proxy settings + + /api/settings/proxy/test: + post: + tags: [Settings] + summary: Test proxy connection + responses: + "200": + description: Test result + + /api/settings/require-login: + post: + tags: [Settings] + summary: Toggle login requirement + responses: + "200": + description: Updated + + /api/settings/ip-filter: + get: + tags: [Settings] + summary: Get IP filter configuration + description: Returns the current IP filter settings including blacklist, whitelist, and temp bans. + responses: + "200": + description: IP filter configuration + put: + tags: [Settings] + summary: Update IP filter configuration + description: | + Configure IP filtering with blacklist/whitelist modes, add/remove individual IPs, and manage temp bans. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + mode: + type: string + enum: [blacklist, whitelist] + blacklist: + type: array + items: + type: string + whitelist: + type: array + items: + type: string + addBlacklist: + type: string + removeBlacklist: + type: string + addWhitelist: + type: string + removeWhitelist: + type: string + tempBan: + type: object + properties: + ip: + type: string + durationMs: + type: integer + reason: + type: string + removeBan: + type: string + responses: + "200": + description: Updated IP filter configuration + + /api/settings/system-prompt: + get: + tags: [Settings] + summary: Get system prompt configuration + description: Returns the current system prompt injection settings. + responses: + "200": + description: System prompt configuration + put: + tags: [Settings] + summary: Update system prompt configuration + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + prompt: + type: string + enabled: + type: boolean + responses: + "200": + description: Updated system prompt configuration + + /api/settings/thinking-budget: + get: + tags: [Settings] + summary: Get thinking budget configuration + description: Returns the current thinking/reasoning budget settings for AI models. + responses: + "200": + description: Thinking budget configuration + put: + tags: [Settings] + summary: Update thinking budget configuration + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + mode: + type: string + description: Thinking mode (e.g., auto, manual, disabled) + customBudget: + type: integer + minimum: 0 + maximum: 131072 + effortLevel: + type: string + enum: [none, low, medium, high] + responses: + "200": + description: Updated thinking budget configuration + + /api/rate-limit: + get: + tags: [Settings] + summary: Get rate limit configuration + responses: + "200": + description: Rate limit settings + post: + tags: [Settings] + summary: Update rate limit configuration + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Updated rate limit settings + + /api/tags: + get: + tags: [System] + summary: List Ollama-compatible model tags + description: Returns models in Ollama /api/tags format for Ollama client compatibility + responses: + "200": + description: Ollama model tags + + # ─── Usage & Analytics ───────────────────────────────────────── + + /api/usage/analytics: + get: + tags: [Usage] + summary: Get usage analytics + parameters: + - name: period + in: query + schema: + type: string + enum: [day, week, month] + default: day + responses: + "200": + description: Usage analytics data + + /api/usage/call-logs: + get: + tags: [Usage] + summary: Get call logs + parameters: + - name: limit + in: query + schema: + type: integer + default: 50 + - name: offset + in: query + schema: + type: integer + default: 0 + responses: + "200": + description: Paginated call logs + + /api/usage/call-logs/{id}: + get: + tags: [Usage] + summary: Get a specific call log + parameters: + - $ref: "#/components/parameters/ResourceId" + responses: + "200": + description: Call log detail + + /api/usage/{connectionId}: + get: + tags: [Usage] + summary: Get usage for a specific connection + parameters: + - name: connectionId + in: path + required: true + schema: + type: string + responses: + "200": + description: Connection usage data + + /api/usage/history: + get: + tags: [Usage] + summary: Get usage history + responses: + "200": + description: Historical usage data + + /api/usage/logs: + get: + tags: [Usage] + summary: Get usage logs + responses: + "200": + description: Usage log entries + + /api/usage/proxy-logs: + get: + tags: [Usage] + summary: Get proxy logs + responses: + "200": + description: Proxy log entries + + /api/usage/request-logs: + get: + tags: [Usage] + summary: Get request logs + responses: + "200": + description: Request log entries + + /api/usage/budget: + get: + tags: [Usage] + summary: Get usage budget status + description: Returns current budget limits and consumption. + responses: + "200": + description: Budget status + post: + tags: [Usage] + summary: Configure usage budget + description: Set or update budget limits for usage tracking. + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Updated budget configuration + + # ─── Pricing ─────────────────────────────────────────────────── + + /api/pricing: + get: + tags: [Pricing] + summary: Get model pricing + responses: + "200": + description: Current pricing configuration + post: + tags: [Pricing] + summary: Set model pricing + responses: + "200": + description: Updated pricing + + /api/pricing/defaults: + get: + tags: [Pricing] + summary: Get default pricing + responses: + "200": + description: Default pricing data + + /api/pricing/models: + get: + tags: [Pricing] + summary: Get pricing per model + description: Returns pricing information organized by model. + responses: + "200": + description: Per-model pricing data + + # ─── Translator ──────────────────────────────────────────────── + + /api/translator/detect: + post: + tags: [Translator] + summary: Detect request format + description: Detects the API format of a request body (OpenAI, Claude, Gemini, etc.) + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [body] + properties: + body: + type: object + responses: + "200": + description: Detected format + + /api/translator/translate: + post: + tags: [Translator] + summary: Translate between formats + description: Converts a request between API formats (e.g. Claude → OpenAI) + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [sourceFormat, targetFormat, body] + properties: + step: + type: string + sourceFormat: + type: string + targetFormat: + type: string + provider: + type: string + body: + type: object + responses: + "200": + description: Translated request + + /api/translator/send: + post: + tags: [Translator] + summary: Send translated request to provider + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [provider, body] + properties: + provider: + type: string + body: + type: object + responses: + "200": + description: Provider response (may be SSE stream) + + /api/translator/history: + get: + tags: [Translator] + summary: Get translation history + description: Returns recent translation events for the Live Monitor + responses: + "200": + description: Translation history entries + + # ─── CLI Remote Mode ─────────────────────────────────────────── + + /api/cli/connect: + post: + tags: [CLI Remote Mode] + summary: Exchange the management password for a scoped CLI access token + description: > + Remote-mode bootstrap. Public (password-gated) route: verifies the + management password with brute-force lockout, then mints an `oma_` + access token. The plaintext token is returned once. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [password] + properties: + password: { type: string } + name: { type: string } + scope: { type: string, enum: [read, write, admin] } + expiresInDays: { type: integer, minimum: 1, maximum: 3650 } + responses: + "200": + description: Token minted (token returned once) + "401": + description: Invalid password + "429": + description: Too many failed attempts + + /api/cli/whoami: + get: + tags: [CLI Remote Mode] + summary: Report the current credential (scope, name, expiry) + responses: + "200": + description: Authenticated; access-token details when applicable + "401": + description: Authentication required + + /api/cli/tokens: + get: + tags: [CLI Remote Mode] + summary: List access tokens (masked) — admin scope + responses: + "200": + description: Masked token list + "403": + description: Insufficient scope + post: + tags: [CLI Remote Mode] + summary: Create a scoped access token — admin scope + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: { type: string } + scope: { type: string, enum: [read, write, admin] } + expiresInDays: { type: integer, minimum: 1, maximum: 3650 } + responses: + "200": + description: Token created (token returned once) + "403": + description: Insufficient scope + + /api/cli/tokens/{id}: + delete: + tags: [CLI Remote Mode] + summary: Revoke an access token by id or display prefix — admin scope + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + "200": + description: Token revoked + "403": + description: Insufficient scope + "404": + description: Token not found or already revoked + + # ─── CLI Tools ───────────────────────────────────────────────── + + /api/cli-tools/backups: + get: + tags: [CLI Tools] + summary: List CLI tool backups + responses: + "200": + description: Backup list + post: + tags: [CLI Tools] + summary: Create CLI tool backup + responses: + "200": + description: Backup created + + /api/cli-tools/runtime/{toolId}: + get: + tags: [CLI Tools] + summary: Get runtime status for a CLI tool + parameters: + - name: toolId + in: path + required: true + schema: + type: string + responses: + "200": + description: Runtime status + + /api/cli-tools/guide-settings/{toolId}: + get: + tags: [CLI Tools] + summary: Get guide settings for a tool + parameters: + - name: toolId + in: path + required: true + schema: + type: string + responses: + "200": + description: Guide settings + + /api/cli-tools/antigravity-mitm: + get: + tags: [CLI Tools] + summary: Get Antigravity MITM proxy settings + responses: + "200": + description: MITM proxy configuration + post: + tags: [CLI Tools] + summary: Update Antigravity MITM proxy settings + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Updated MITM proxy configuration + delete: + tags: [CLI Tools] + summary: Reset Antigravity MITM proxy settings + responses: + "200": + description: MITM proxy settings reset + + /api/cli-tools/antigravity-mitm/alias: + get: + tags: [CLI Tools] + summary: Get Antigravity MITM alias configuration + responses: + "200": + description: Alias configuration + put: + tags: [CLI Tools] + summary: Update Antigravity MITM alias configuration + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Updated alias configuration + + /api/cli-tools/claude-settings: + get: + tags: [CLI Tools] + summary: Get Claude CLI settings + responses: + "200": + description: Claude CLI configuration + post: + tags: [CLI Tools] + summary: Apply Claude CLI settings + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Claude CLI settings applied + delete: + tags: [CLI Tools] + summary: Reset Claude CLI settings + responses: + "200": + description: Claude CLI settings reset + + /api/cli-tools/cline-settings: + get: + tags: [CLI Tools] + summary: Get Cline CLI settings + responses: + "200": + description: Cline CLI configuration + post: + tags: [CLI Tools] + summary: Apply Cline CLI settings + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Cline CLI settings applied + delete: + tags: [CLI Tools] + summary: Reset Cline CLI settings + responses: + "200": + description: Cline CLI settings reset + + /api/cli-tools/codex-profiles: + get: + tags: [CLI Tools] + summary: Get Codex profiles + responses: + "200": + description: Codex profile list + post: + tags: [CLI Tools] + summary: Create Codex profile + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Profile created + put: + tags: [CLI Tools] + summary: Update Codex profile + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Profile updated + delete: + tags: [CLI Tools] + summary: Delete Codex profile + responses: + "200": + description: Profile deleted + + /api/cli-tools/codex-settings: + get: + tags: [CLI Tools] + summary: Get Codex CLI settings + responses: + "200": + description: Codex CLI configuration + post: + tags: [CLI Tools] + summary: Apply Codex CLI settings + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Codex CLI settings applied + delete: + tags: [CLI Tools] + summary: Reset Codex CLI settings + responses: + "200": + description: Codex CLI settings reset + + /api/cli-tools/droid-settings: + get: + tags: [CLI Tools] + summary: Get Droid CLI settings + responses: + "200": + description: Droid CLI configuration + post: + tags: [CLI Tools] + summary: Apply Droid CLI settings + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Droid CLI settings applied + delete: + tags: [CLI Tools] + summary: Reset Droid CLI settings + responses: + "200": + description: Droid CLI settings reset + + /api/cli-tools/kilo-settings: + get: + tags: [CLI Tools] + summary: Get Kilo CLI settings + responses: + "200": + description: Kilo CLI configuration + post: + tags: [CLI Tools] + summary: Apply Kilo CLI settings + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Kilo CLI settings applied + delete: + tags: [CLI Tools] + summary: Reset Kilo CLI settings + responses: + "200": + description: Kilo CLI settings reset + + /api/cli-tools/openclaw-settings: + get: + tags: [CLI Tools] + summary: Get OpenClaw CLI settings + responses: + "200": + description: OpenClaw CLI configuration + post: + tags: [CLI Tools] + summary: Apply OpenClaw CLI settings + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: OpenClaw CLI settings applied + delete: + tags: [CLI Tools] + summary: Reset OpenClaw CLI settings + responses: + "200": + description: OpenClaw CLI settings reset + + # ─── Embedded Services ───────────────────────────────────────── + # All routes LOCAL_ONLY (loopback only) — hard rule #17. + # See docs/frameworks/EMBEDDED-SERVICES.md for full reference. + + /api/services/9router/install: + post: + tags: [Embedded Services] + summary: Install 9Router from npm + description: >- + Installs the `9router` npm package under DATA_DIR/services/9router/. + Uses execFile (no shell interpolation — hard rule #13). + **LOCAL_ONLY** — loopback only. + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + version: + type: string + default: latest + description: npm version tag or semver to install + responses: + "200": + description: Install succeeded + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + installedVersion: + type: string + path: + type: string + "400": + description: Invalid request body + "500": + description: npm install failed + + /api/services/9router/start: + post: + tags: [Embedded Services] + summary: Start 9Router + description: >- + Spawns the 9Router process. Idempotent if already running. + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Service started (or already running) + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + "409": + description: 9Router is not installed + "503": + description: Start failed + + /api/services/9router/stop: + post: + tags: [Embedded Services] + summary: Stop 9Router + description: >- + Gracefully stops 9Router (SIGTERM → 15 s → SIGKILL). Idempotent. + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Service stopped + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + "503": + description: Stop failed + + /api/services/9router/restart: + post: + tags: [Embedded Services] + summary: Restart 9Router + description: >- + Equivalent to stop() then start() under the operation lock. + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Service restarted + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + + /api/services/9router/update: + post: + tags: [Embedded Services] + summary: Update 9Router to a newer npm version + description: >- + Stops the service (if running), installs the newer npm version, then restarts. + **LOCAL_ONLY** — loopback only. + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + version: + type: string + default: latest + responses: + "200": + description: Update succeeded + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + previousVersion: + type: string + installedVersion: + type: string + "400": + description: Invalid request body + "500": + description: Update failed + + /api/services/9router/rotate-key: + post: + tags: [Embedded Services] + summary: Rotate the 9Router API key + description: >- + Generates a new API key, encrypts it at-rest, and restarts the service to + apply it. The plaintext key is never returned. + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Key rotated + content: + application/json: + schema: + type: object + properties: + keyRotated: + type: boolean + restarted: + type: boolean + "500": + description: Rotation failed + + /api/services/9router/status: + get: + tags: [Embedded Services] + summary: Get 9Router status + description: >- + Returns combined live supervisor state and DB metadata. + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Status response + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatusExtended" + "500": + description: Status read failed + + /api/services/9router/auto-start: + post: + tags: [Embedded Services] + summary: Toggle 9Router auto-start + description: >- + When enabled, 9Router starts automatically on the next OmniRoute boot. + **LOCAL_ONLY** — loopback only. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [enabled] + properties: + enabled: + type: boolean + responses: + "200": + description: Auto-start flag updated + content: + application/json: + schema: + type: object + properties: + autoStart: + type: boolean + "400": + description: Invalid request body + + /api/services/cliproxy/install: + post: + tags: [Embedded Services] + summary: Install CLIProxyAPI from npm + description: >- + Installs the CLIProxyAPI package under DATA_DIR/services/cliproxy/. + **LOCAL_ONLY** — loopback only. + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + version: + type: string + default: latest + responses: + "200": + description: Install succeeded + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + installedVersion: + type: string + "400": + description: Invalid request body + "500": + description: npm install failed + + /api/services/cliproxy/start: + post: + tags: [Embedded Services] + summary: Start CLIProxyAPI + description: >- + Spawns the CLIProxyAPI process. Idempotent if already running. + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Service started + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + "409": + description: CLIProxyAPI is not installed + "503": + description: Start failed + + /api/services/cliproxy/stop: + post: + tags: [Embedded Services] + summary: Stop CLIProxyAPI + description: >- + Gracefully stops CLIProxyAPI. Idempotent. + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Service stopped + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + + /api/services/cliproxy/restart: + post: + tags: [Embedded Services] + summary: Restart CLIProxyAPI + description: >- + stop() then start() under the operation lock. + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Service restarted + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + + /api/services/cliproxy/update: + post: + tags: [Embedded Services] + summary: Update CLIProxyAPI to a newer npm version + description: >- + Stops, installs newer version, restarts. + **LOCAL_ONLY** — loopback only. + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + version: + type: string + default: latest + responses: + "200": + description: Update succeeded + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + installedVersion: + type: string + "500": + description: Update failed + + /api/services/cliproxy/status: + get: + tags: [Embedded Services] + summary: Get CLIProxyAPI status + description: >- + Returns live supervisor state and DB metadata (no apiKeyMasked — CLIProxyAPI + does not use an injected API key). + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Status response + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + + /api/services/cliproxy/auto-start: + post: + tags: [Embedded Services] + summary: Toggle CLIProxyAPI auto-start + description: >- + When enabled, CLIProxyAPI starts automatically on the next OmniRoute boot. + **LOCAL_ONLY** — loopback only. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [enabled] + properties: + enabled: + type: boolean + responses: + "200": + description: Auto-start flag updated + content: + application/json: + schema: + type: object + properties: + autoStart: + type: boolean + "400": + description: Invalid request body + + /api/services/{name}/logs: + get: + tags: [Embedded Services] + summary: Stream service logs via SSE + description: >- + Returns a Server-Sent Events stream from the service's in-memory ring buffer + (5 MB, circular). Sends a `snapshot` event with historical lines first, then + live `log` events, plus a `heartbeat` every 15 s. + **LOCAL_ONLY** — loopback only. + parameters: + - name: name + in: path + required: true + schema: + type: string + enum: [9router, cliproxy] + - name: tail + in: query + schema: + type: integer + default: 200 + maximum: 1000 + description: Number of historical lines to include in the initial snapshot + - name: filter + in: query + schema: + type: string + maxLength: 200 + description: >- + Case-insensitive substring filter applied to log lines. + No regex — ReDoS-safe by design. + responses: + "200": + description: SSE log stream + content: + text/event-stream: + schema: + type: string + description: >- + Events: `snapshot` (LogLine[]), `log` (LogLine), `heartbeat` ({}) + "400": + description: filter parameter exceeds maximum length + "404": + description: Service not found + + # ─── OAuth ───────────────────────────────────────────────────── + + /api/oauth/{provider}/{action}: + get: + tags: [OAuth] + summary: OAuth flow handler + description: Handles OAuth authorization and callback for providers + parameters: + - name: provider + in: path + required: true + schema: + type: string + - name: action + in: path + required: true + schema: + type: string + enum: [authorize, callback, refresh, status] + responses: + "200": + description: OAuth flow response + "302": + description: Redirect to provider auth page + + /api/oauth/cursor/auto-import: + get: + tags: [OAuth] + summary: Auto-import Cursor OAuth credentials + description: Automatically detects and imports Cursor credentials from local config. + responses: + "200": + description: Import result + + /api/oauth/cursor/import: + get: + tags: [OAuth] + summary: Get Cursor import status + responses: + "200": + description: Current import status + post: + tags: [OAuth] + summary: Import Cursor OAuth credentials + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Credentials imported + + /api/oauth/kiro/auto-import: + get: + tags: [OAuth] + summary: Auto-import Kiro OAuth credentials + description: Automatically detects and imports Kiro credentials from local config. + responses: + "200": + description: Import result + + /api/oauth/kiro/import: + get: + tags: [OAuth] + summary: Get Kiro import status + responses: + "200": + description: Current import status + post: + tags: [OAuth] + summary: Import Kiro OAuth credentials + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Credentials imported + + /api/oauth/kiro/social-authorize: + get: + tags: [OAuth] + summary: Initiate Kiro social OAuth authorization + description: Starts the social OAuth flow for Kiro. + responses: + "302": + description: Redirect to OAuth provider + + /api/oauth/kiro/social-exchange: + post: + tags: [OAuth] + summary: Exchange Kiro social OAuth token + description: Exchanges the authorization code for access tokens. + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Token exchange result + + # ─── Cloud ───────────────────────────────────────────────────── + + /api/cloud/auth: + post: + tags: [Cloud] + summary: Authenticate with cloud worker + description: Authenticates with the OmniRoute cloud worker for remote access. + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Authentication result + + /api/cloud/credentials/update: + put: + tags: [Cloud] + summary: Update cloud worker credentials + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Credentials updated + + /api/cloud/model/resolve: + post: + tags: [Cloud] + summary: Resolve model via cloud + description: Resolves a model request through the cloud worker. + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Resolved model info + + /api/cloud/models/alias: + get: + tags: [Cloud] + summary: Get cloud model aliases + responses: + "200": + description: Cloud model alias list + put: + tags: [Cloud] + summary: Update cloud model alias + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Alias updated + + # ─── Fallback ────────────────────────────────────────────────── + + /api/fallback/chains: + get: + tags: [Fallback] + summary: List fallback chains + description: Returns all registered fallback chains for model routing. + responses: + "200": + description: Fallback chain list + post: + tags: [Fallback] + summary: Create fallback chain + description: Registers a fallback routing chain for a model. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [model, chain] + properties: + model: + type: string + chain: + type: array + items: + type: object + properties: + provider: + type: string + priority: + type: integer + enabled: + type: boolean + responses: + "200": + description: Fallback chain created + delete: + tags: [Fallback] + summary: Delete fallback chain + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [model] + properties: + model: + type: string + responses: + "200": + description: Fallback chain deleted + + # ─── System ──────────────────────────────────────────────────── + + /api/auth/login: + post: + tags: [System] + summary: Authenticate user + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [password] + properties: + password: + type: string + minLength: 1 + responses: + "200": + description: JWT token returned + "400": + description: Invalid login request + "401": + description: Invalid password + "403": + description: Password setup required + "429": + description: Too many failed attempts + + /api/auth/logout: + post: + tags: [System] + summary: Log out + responses: + "200": + description: Session cleared + + /api/init: + get: + tags: [System] + summary: Initialize application + responses: + "200": + description: Init status + + /api/restart: + post: + tags: [System] + summary: Restart the application + responses: + "200": + description: Restart initiated + + /api/shutdown: + post: + tags: [System] + summary: Shutdown the application + x-always-protected: true + responses: + "200": + description: Shutdown initiated + + /api/db-backups: + get: + tags: [System] + summary: List database backups + responses: + "200": + description: Backup list + post: + tags: [System] + summary: Create database backup + responses: + "200": + description: Backup created + patch: + tags: [System] + summary: Save database backup retention settings + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + keepLatest: + type: integer + minimum: 1 + maximum: 200 + retentionDays: + type: integer + minimum: 0 + maximum: 3650 + responses: + "200": + description: Backup retention settings saved + + /api/storage/health: + get: + tags: [System] + summary: Check storage health + responses: + "200": + description: Storage health status + + /api/sync/cloud: + post: + tags: [System] + summary: Sync with cloud + responses: + "200": + description: Sync result + + /api/sync/initialize: + post: + tags: [System] + summary: Initialize cloud sync + responses: + "200": + description: Sync initialized + + # ─── Resilience & Monitoring ──────────────────────────────────── + + /api/resilience: + get: + tags: [System] + summary: Get resilience configuration + responses: + "200": + description: Request queue, connection cooldown, provider breaker, and wait settings + patch: + tags: [System] + summary: Update resilience configuration + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Updated resilience configuration + + /api/resilience/reset: + post: + tags: [System] + summary: Reset circuit breakers + responses: + "200": + description: Circuit breakers reset + + /api/monitoring/health: + get: + tags: [System] + summary: System health check + description: Returns system health including uptime, memory, circuit breakers, rate limits + responses: + "200": + description: Health status + + /api/rate-limits: + get: + tags: [System] + summary: Get per-account rate limit status + responses: + "200": + description: Rate limit status by account + + /api/sessions: + get: + tags: [System] + summary: Get active sessions + responses: + "200": + description: Active session list + + /api/cache: + get: + tags: [System] + summary: Get cache statistics + responses: + "200": + description: Semantic cache and idempotency stats + delete: + tags: [System] + summary: Clear all caches + responses: + "200": + description: Caches cleared + + /api/cache/stats: + get: + tags: [System] + summary: Get detailed cache statistics + description: Returns detailed statistics for all cache layers. + responses: + "200": + description: Detailed cache stats + delete: + tags: [System] + summary: Clear cache statistics + responses: + "200": + description: Cache stats cleared + + # ─── Telemetry & Token Health ─────────────────────────────────── + + /api/telemetry/summary: + get: + tags: [Telemetry] + summary: Get telemetry summary + description: Returns aggregated telemetry data including request metrics and performance stats. + responses: + "200": + description: Telemetry summary data + + /api/token-health: + get: + tags: [Telemetry] + summary: Get token health status + description: Returns health status of OAuth tokens across all providers. + responses: + "200": + description: Token health status + + # ─── Evals & Policies ────────────────────────────────────────── + + /api/evals: + get: + tags: [System] + summary: List eval suites + responses: + "200": + description: Eval suite list + post: + tags: [System] + summary: Run evaluation + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Eval results + + /api/evals/{suiteId}: + get: + tags: [System] + summary: Get eval suite details + parameters: + - name: suiteId + in: path + required: true + schema: + type: string + responses: + "200": + description: Eval suite details + + /api/policies: + get: + tags: [System] + summary: List routing policies + responses: + "200": + description: Policy list + post: + tags: [System] + summary: Create routing policy + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "201": + description: Created policy + delete: + tags: [System] + summary: Delete routing policy + responses: + "200": + description: Policy deleted + + /api/compliance/audit-log: + get: + tags: [System] + summary: Get compliance audit log + description: > + Returns paginated audit log entries. Use `level=high` to filter to + high-level actions only (powers the Activity feed). Use `level=all` + (default) for full compliance table. + security: + - bearerAuth: [] + parameters: + - name: level + in: query + schema: + type: string + enum: [high, all] + default: all + description: "high = Activity feed events only; all = all audit events" + - name: action + in: query + schema: + type: string + description: Filter by exact action string (e.g. "provider.added") + - name: actor + in: query + schema: + type: string + description: Filter by actor identifier + - name: limit + in: query + schema: + type: integer + default: 50 + maximum: 500 + - name: offset + in: query + schema: + type: integer + default: 0 + responses: + "200": + description: Audit log entries + "401": + description: Unauthorized + "500": + description: Internal server error + + # ─── Quota Sharing (Group B, plan 22) ──────────────────────────── + + /api/quota/pools: + get: + tags: [Quota] + summary: List quota pools + security: + - bearerAuth: [] + responses: + "200": + description: Array of QuotaPool objects + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/QuotaPool" + "401": + description: Unauthorized + "500": + description: Internal server error + post: + tags: [Quota] + summary: Create quota pool + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PoolCreate" + responses: + "201": + description: Pool created + content: + application/json: + schema: + $ref: "#/components/schemas/QuotaPool" + "400": + description: Validation error (Zod) + "401": + description: Unauthorized + "500": + description: Internal server error + + /api/quota/pools/{id}: + get: + tags: [Quota] + summary: Get quota pool by ID + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: QuotaPool object + content: + application/json: + schema: + $ref: "#/components/schemas/QuotaPool" + "401": + description: Unauthorized + "404": + description: Pool not found + "500": + description: Internal server error + patch: + tags: [Quota] + summary: Update quota pool (name or allocations) + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PoolUpdate" + responses: + "200": + description: Updated pool + "400": + description: Validation error + "401": + description: Unauthorized + "404": + description: Pool not found + "500": + description: Internal server error + delete: + tags: [Quota] + summary: Delete quota pool + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "204": + description: Deleted + "401": + description: Unauthorized + "404": + description: Pool not found + "500": + description: Internal server error + + /api/quota/pools/{id}/usage: + get: + tags: [Quota] + summary: Get pool usage snapshot (per-key consumption + burn rate) + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: PoolUsageSnapshot + content: + application/json: + schema: + $ref: "#/components/schemas/PoolUsageSnapshot" + "401": + description: Unauthorized + "404": + description: Pool not found + "500": + description: Internal server error + + /api/quota/plans: + get: + tags: [Quota] + summary: List resolved provider plans (catalog + manual overrides) + security: + - bearerAuth: [] + responses: + "200": + description: Array of ProviderPlan + "401": + description: Unauthorized + "500": + description: Internal server error + + /api/quota/plans/{connectionId}: + get: + tags: [Quota] + summary: Get resolved plan for a connection + security: + - bearerAuth: [] + parameters: + - name: connectionId + in: path + required: true + schema: + type: string + responses: + "200": + description: ProviderPlan (source = auto | manual) + "401": + description: Unauthorized + "404": + description: Connection not found + "500": + description: Internal server error + put: + tags: [Quota] + summary: Upsert manual plan override for a connection + security: + - bearerAuth: [] + parameters: + - name: connectionId + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PlanUpsert" + responses: + "200": + description: Updated plan + "400": + description: Validation error (Zod) + "401": + description: Unauthorized + "500": + description: Internal server error + delete: + tags: [Quota] + summary: Delete manual plan override (reverts to catalog/auto) + security: + - bearerAuth: [] + parameters: + - name: connectionId + in: path + required: true + schema: + type: string + responses: + "204": + description: Override deleted + "401": + description: Unauthorized + "404": + description: Override not found + "500": + description: Internal server error + + /api/quota/preview: + get: + tags: [Quota] + summary: Dry-run quota enforcement check (preview only, no consumption recorded) + security: + - bearerAuth: [] + parameters: + - name: apiKeyId + in: query + required: true + schema: + type: string + - name: poolId + in: query + required: true + schema: + type: string + - name: estimatedTokens + in: query + schema: + type: number + - name: estimatedUsd + in: query + schema: + type: number + - name: estimatedRequests + in: query + schema: + type: integer + responses: + "200": + description: EnforceDecision (allow/block + reason) + "400": + description: Validation error (Zod) + "401": + description: Unauthorized + "500": + description: Internal server error + + /api/settings/quota-store: + get: + tags: [Settings] + summary: Get current quota store driver settings + description: Redis URL is masked in the response (shows only scheme+host). + security: + - bearerAuth: [] + responses: + "200": + description: QuotaStoreSettings (driver + masked redisUrl) + "401": + description: Unauthorized + "500": + description: Internal server error + put: + tags: [Settings] + summary: Update quota store driver settings + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/QuotaStoreSettings" + responses: + "200": + description: Settings updated + "400": + description: Validation error (Zod) — e.g. driver=redis without valid URL + "401": + description: Unauthorized + "500": + description: Internal server error + + # ─── v1beta (Gemini-Compatible) ───────────────────────────────── + + /api/v1beta/models: + get: + tags: [Models] + summary: List models (Gemini format) + description: Returns models in Gemini v1beta format for native SDK compatibility + security: + - BearerAuth: [] + responses: + "200": + description: Model list in Gemini format + + /api/v1beta/models/{path}: + post: + tags: [Models] + summary: Gemini generateContent + description: Gemini-compatible generateContent endpoint + security: + - BearerAuth: [] + parameters: + - name: path + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Generated content + + # ─── AgentBridge ────────────────────────────────────────────── + + /api/tools/agent-bridge/agents: + get: + tags: [AgentBridge] + summary: List all 9 IDE agents with current state + description: >- + Returns the state (dns_enabled, cert_trusted, setup_completed, last_started_at, + last_error) for all 9 configured IDE agents. LOCAL_ONLY. + responses: + "200": + description: Array of agent state rows + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/AgentBridgeAgentState" + "403": + description: Loopback-only — request came from a non-loopback address + + /api/tools/agent-bridge/state: + get: + tags: [AgentBridge] + summary: Get global AgentBridge server state + description: Returns running status, port, cert info, and intercepted request count. + responses: + "200": + description: Server state + content: + application/json: + schema: + $ref: "#/components/schemas/AgentBridgeServerState" + + /api/tools/agent-bridge/server: + post: + tags: [AgentBridge] + summary: Control AgentBridge MITM server + description: Start, stop, restart, trust-cert, or regenerate-cert. SPAWN_CAPABLE. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AgentBridgeServerAction" + responses: + "200": + description: Action executed + "400": + description: Invalid action + "409": + description: Port 443 conflict + + /api/tools/agent-bridge/agents/{agentId}/dns: + post: + tags: [AgentBridge] + summary: Enable or disable DNS for one agent + description: Adds or removes /etc/hosts entries for the agent's host list. SPAWN_CAPABLE. + parameters: + - name: agentId + in: path + required: true + schema: + $ref: "#/components/schemas/AgentId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AgentBridgeDnsAction" + responses: + "200": + description: DNS updated + "400": + description: Validation error + + /api/tools/agent-bridge/agents/{agentId}/mappings: + get: + tags: [AgentBridge] + summary: Get model mappings for one agent + parameters: + - name: agentId + in: path + required: true + schema: + $ref: "#/components/schemas/AgentId" + responses: + "200": + description: Array of source→target model mappings + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/AgentBridgeMappingRow" + put: + tags: [AgentBridge] + summary: Update model mappings for one agent + parameters: + - name: agentId + in: path + required: true + schema: + $ref: "#/components/schemas/AgentId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AgentBridgeMappingPut" + responses: + "200": + description: Mappings updated + + /api/tools/agent-bridge/bypass: + get: + tags: [AgentBridge] + summary: List bypass patterns (hosts never decrypted) + responses: + "200": + description: Bypass patterns + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/AgentBridgeBypassRow" + put: + tags: [AgentBridge] + summary: Update user bypass patterns + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AgentBridgeBypassUpsert" + responses: + "200": + description: Patterns updated + + /api/tools/agent-bridge/cert: + post: + tags: [AgentBridge] + summary: Download or regenerate the AgentBridge CA certificate + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [action] + properties: + action: + type: string + enum: [download, regenerate] + responses: + "200": + description: CA certificate PEM (download) or regeneration confirmation + + /api/tools/agent-bridge/upstream-ca: + get: + tags: [AgentBridge] + summary: Get configured upstream CA cert path + responses: + "200": + description: Upstream CA configuration + content: + application/json: + schema: + type: object + properties: + path: + type: string + nullable: true + post: + tags: [AgentBridge] + summary: Set upstream CA cert path for corporate TLS environments + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AgentBridgeUpstreamCaPost" + responses: + "200": + description: Upstream CA configured + "400": + description: Path does not exist or is not readable + + # ─── Traffic Inspector ───────────────────────────────────────── + + /api/tools/traffic-inspector/requests: + get: + tags: [Traffic Inspector] + summary: List intercepted requests (filterable) + parameters: + - name: profile + in: query + schema: + type: string + enum: [llm, custom, all] + - name: host + in: query + schema: + type: string + - name: agent + in: query + schema: + $ref: "#/components/schemas/AgentId" + - name: status + in: query + schema: + type: string + enum: ["2xx", "3xx", "4xx", "5xx", error] + - name: source + in: query + schema: + $ref: "#/components/schemas/CaptureSource" + - name: sessionId + in: query + schema: + type: string + format: uuid + responses: + "200": + description: Array of intercepted requests + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/InterceptedRequest" + delete: + tags: [Traffic Inspector] + summary: Clear the in-memory traffic buffer + responses: + "204": + description: Buffer cleared + + /api/tools/traffic-inspector/requests/{id}: + get: + tags: [Traffic Inspector] + summary: Get a single intercepted request by ID + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Intercepted request details + content: + application/json: + schema: + $ref: "#/components/schemas/InterceptedRequest" + "404": + description: Request not found in buffer + + /api/tools/traffic-inspector/requests/{id}/replay: + post: + tags: [Traffic Inspector] + summary: Replay a captured request through OmniRoute router + description: Re-executes the original request body against /v1/chat/completions. Consumes quota. + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Replay response (streaming or JSON) + "404": + description: Request not found + + /api/tools/traffic-inspector/requests/{id}/annotation: + put: + tags: [Traffic Inspector] + summary: Save or update annotation on a request + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/InspectorAnnotationPut" + responses: + "200": + description: Annotation saved + + /api/tools/traffic-inspector/ws: + get: + tags: [Traffic Inspector] + summary: Live WebSocket stream of intercepted requests + description: >- + Upgrade to WebSocket. On connect, server sends `{type:"snapshot", data:[...]}`. + Subsequent events: `{type:"new", data:{...}}`, `{type:"update", data:{...}}`, + `{type:"clear"}`. LOCAL_ONLY. + responses: + "101": + description: WebSocket upgrade successful + "403": + description: Non-loopback origin rejected + + /api/tools/traffic-inspector/export.har: + get: + tags: [Traffic Inspector] + summary: Export current filtered request list as HAR 1.2 + parameters: + - name: profile + in: query + schema: + type: string + enum: [llm, custom, all] + - name: sessionId + in: query + schema: + type: string + format: uuid + responses: + "200": + description: HAR file (JSON) + content: + application/json: + schema: + type: object + description: HAR 1.2 format + + /api/tools/traffic-inspector/hosts: + get: + tags: [Traffic Inspector] + summary: List custom capture hosts + responses: + "200": + description: Custom hosts list + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/InspectorCustomHost" + post: + tags: [Traffic Inspector] + summary: Add a custom capture host (edits /etc/hosts) + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/InspectorCustomHostCreate" + responses: + "201": + description: Host added + "409": + description: Host already exists + + /api/tools/traffic-inspector/hosts/{host}: + delete: + tags: [Traffic Inspector] + summary: Remove a custom capture host + parameters: + - name: host + in: path + required: true + schema: + type: string + responses: + "204": + description: Host removed + patch: + tags: [Traffic Inspector] + summary: Toggle enabled state of a custom host + parameters: + - name: host + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [enabled] + properties: + enabled: + type: boolean + responses: + "200": + description: Host updated + + /api/tools/traffic-inspector/capture-modes: + get: + tags: [Traffic Inspector] + summary: Get state of all 4 capture modes + responses: + "200": + description: Capture modes state + content: + application/json: + schema: + $ref: "#/components/schemas/InspectorCaptureModesState" + + /api/tools/traffic-inspector/capture-modes/http-proxy: + post: + tags: [Traffic Inspector] + summary: Start or stop the HTTP_PROXY listener (port 8080) + description: SPAWN_CAPABLE — spawns a net.Server listener. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/InspectorCaptureModeAction" + responses: + "200": + description: Action executed + "409": + description: Port conflict (EADDRINUSE) when starting + + /api/tools/traffic-inspector/capture-modes/system-proxy: + post: + tags: [Traffic Inspector] + summary: Apply or revert system-wide proxy settings + description: SPAWN_CAPABLE — executes networksetup/gsettings/netsh. Requires admin. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/InspectorSystemProxyAction" + responses: + "200": + description: System proxy updated + "500": + description: OS command failed (permission error) + + /api/tools/traffic-inspector/capture-modes/tls-intercept: + post: + tags: [Traffic Inspector] + summary: Toggle TLS body decryption in HTTP_PROXY mode + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/InspectorTlsInterceptToggle" + responses: + "200": + description: TLS intercept mode updated + + /api/tools/traffic-inspector/sessions: + get: + tags: [Traffic Inspector] + summary: List all saved recording sessions + responses: + "200": + description: Sessions list + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/InspectorSession" + post: + tags: [Traffic Inspector] + summary: Start a new recording session + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/InspectorSessionStart" + responses: + "201": + description: Session started + content: + application/json: + schema: + $ref: "#/components/schemas/InspectorSession" + + /api/tools/traffic-inspector/sessions/{id}: + get: + tags: [Traffic Inspector] + summary: Get session snapshot (all captured requests) + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Session with embedded requests + "404": + description: Session not found + patch: + tags: [Traffic Inspector] + summary: Stop or rename a recording session + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/InspectorSessionPatch" + responses: + "200": + description: Session updated + delete: + tags: [Traffic Inspector] + summary: Delete a recording session + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "204": + description: Session deleted + + /api/tools/traffic-inspector/sessions/{id}/export.har: + get: + tags: [Traffic Inspector] + summary: Export a recorded session as HAR 1.2 + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: HAR file for this session + content: + application/json: + schema: + type: object + description: HAR 1.2 format + "404": + description: Session not found + + /api/tools/traffic-inspector/internal/ingest: + post: + tags: [Traffic Inspector] + summary: Internal ingest endpoint for server.cjs passthrough path + description: >- + Accepts a serialized InterceptedRequest from the CJS MITM server for requests + that do not go through TypeScript handlers (e.g., passthrough hosts). Requires + INSPECTOR_INTERNAL_INGEST_TOKEN header. LOCAL_ONLY. + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/InterceptedRequest" + responses: + "204": + description: Ingested + "401": + description: Invalid or missing ingest token + + # ─── OpenAPI Spec ────────────────────────────────────────────── + + /api/openapi/spec: + get: + tags: [System] + summary: Get OpenAPI specification catalog + description: >- + Returns a structured JSON catalog parsed from this `openapi.yaml`, + including info, servers, tags, schemas, and a flat list of endpoints + (method, path, tags, summary, security, parameters, responses). + Used by the in-app API explorer. + responses: + "200": + description: Parsed OpenAPI catalog + content: + application/json: + schema: + type: object + properties: + info: + type: object + servers: + type: array + items: + type: object + tags: + type: array + items: + type: object + endpoints: + type: array + items: + type: object + properties: + method: + type: string + path: + type: string + tags: + type: array + items: + type: string + summary: + type: string + description: + type: string + security: + type: boolean + parameters: + type: array + items: + type: object + requestBody: + type: boolean + responses: + type: array + items: + type: string + schemas: + type: array + items: + type: string + "404": + description: openapi.yaml file not found on disk + "500": + description: Failed to parse OpenAPI spec + + # ─── Agent Skills Catalog ──────────────────────────────────────────────────── + + /api/agent-skills: + get: + tags: [Agent Skills] + summary: List agent skills catalog + description: | + Returns the full 42-entry Agent Skills catalog with optional filtering. + Skills describe how to use OmniRoute's REST API and CLI — they are structured + SKILL.md documentation files discoverable by external agents, MCP clients, and + A2A orchestrators. No authentication required. + parameters: + - name: category + in: query + required: false + schema: + type: string + enum: [api, cli] + description: Filter by category (api = REST API skills, cli = CLI skills) + - name: area + in: query + required: false + schema: + type: string + description: Filter by area slug (e.g. "providers", "models", "cli-serve") + responses: + "200": + description: Catalog list + content: + application/json: + schema: + type: object + required: [skills, count, coverage] + properties: + skills: + type: array + items: + $ref: "#/components/schemas/AgentSkill" + count: + type: integer + coverage: + $ref: "#/components/schemas/SkillCoverage" + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/InternalError" + + /api/agent-skills/{id}: + get: + tags: [Agent Skills] + summary: Get a single agent skill + description: | + Returns metadata for a single agent skill by its canonical ID + (e.g. `omni-providers`, `cli-serve`). No authentication required. + parameters: + - name: id + in: path + required: true + schema: + type: string + pattern: "^[a-z][a-z0-9-]*$" + description: Canonical skill ID + example: omni-providers + responses: + "200": + description: Agent skill metadata + content: + application/json: + schema: + $ref: "#/components/schemas/AgentSkill" + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalError" + + /api/agent-skills/{id}/raw: + get: + tags: [Agent Skills] + summary: Get raw SKILL.md content + description: | + Returns the SKILL.md content for a skill as `text/markdown`. + Resolution order: local filesystem `skills/{id}/SKILL.md` → GitHub raw URL (1-hour cache). + No authentication required. + parameters: + - name: id + in: path + required: true + schema: + type: string + pattern: "^[a-z][a-z0-9-]*$" + description: Canonical skill ID + example: omni-providers + responses: + "200": + description: SKILL.md content as Markdown + headers: + X-Skill-Source: + schema: + type: string + enum: [filesystem, github, generated] + description: Where the content was loaded from + X-Skill-Fetched-At: + schema: + type: string + format: date-time + description: ISO timestamp of when the content was fetched + Cache-Control: + schema: + type: string + description: "public, max-age=3600" + content: + text/markdown: + schema: + type: string + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" + "502": + description: Upstream GitHub fetch failed + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + $ref: "#/components/responses/InternalError" + + /api/agent-skills/coverage: + get: + tags: [Agent Skills] + summary: Get SKILL.md coverage stats + description: | + Returns how many of the 22 API skills and 20 CLI skills have SKILL.md + files on the local filesystem vs the catalog totals. No authentication required. + responses: + "200": + description: Coverage stats + content: + application/json: + schema: + $ref: "#/components/schemas/SkillCoverage" + "500": + $ref: "#/components/responses/InternalError" + + /api/agent-skills/generate: + post: + tags: [Agent Skills] + summary: Trigger SKILL.md generator + description: | + Runs the Agent Skills generator which writes `skills/{id}/SKILL.md` for + all 42 catalog entries (or a subset via `onlyIds`). Preserves + ` ... ` blocks. + **Requires management authentication.** + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + dryRun: + type: boolean + default: true + description: "If true, reports what would be generated without writing files" + prune: + type: boolean + default: false + description: "If true, deletes skill directories not in the catalog" + onlyIds: + type: array + items: + type: string + description: "If provided, only regenerate these skill IDs" + responses: + "200": + description: Generator report + content: + application/json: + schema: + type: object + required: [generated, unchanged, pruned, orphansDetected, errors] + properties: + generated: + type: array + items: + type: string + description: IDs that got new/updated SKILL.md + unchanged: + type: array + items: + type: string + description: IDs whose content was already up to date + pruned: + type: array + items: + type: string + description: IDs whose directories were deleted (prune mode) + orphansDetected: + type: array + items: + type: string + description: Directories found in skills/ not in the catalog + errors: + type: array + items: + type: object + required: [id, error] + properties: + id: + type: string + error: + type: string + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalError" + "503": + description: Generator module not available + +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + description: API key obtained from the OmniRoute dashboard + ManagementSessionAuth: + type: apiKey + in: cookie + name: auth_token + description: Dashboard management session cookie for protected management routes + + parameters: + ResourceId: + name: id + in: path + required: true + schema: + type: string + + responses: + Unauthorized: + description: Missing or invalid API key + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: Unauthorized + ManagementAuthenticationRequired: + description: Authentication required for management routes + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + example: + error: + message: Authentication required + type: invalid_request + requestId: 3f9f6f5a-509a-4b35-b0a7-2d2d99d73a01 + ManagementInvalidToken: + description: Bearer tokens are not accepted for management routes + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + example: + error: + message: Invalid management token + type: invalid_request + requestId: 1b6a6ff8-d60c-4900-8d0a-25f81749f0a3 + ValidationError: + description: Request body failed validation + content: + application/json: + schema: + $ref: "#/components/schemas/ValidationErrorResponse" + BadRequest: + description: The request was malformed or failed validation + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + example: + error: + message: Invalid request + type: invalid_request_error + requestId: 8c2b1d44-7a3e-4c91-9b0f-1e2d3c4b5a60 + NotFound: + description: The requested resource was not found + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + example: + error: + message: Resource not found + type: not_found_error + requestId: 4d5e6f70-1a2b-3c4d-5e6f-7a8b9c0d1e2f + InternalError: + description: An unexpected server error occurred + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + example: + error: + message: Internal server error + type: api_error + requestId: 0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d + + schemas: + PlaygroundPreset: + type: object + required: + - id + - name + - endpoint + - model + - params + - created_at + properties: + id: + type: string + format: uuid + name: + type: string + maxLength: 100 + endpoint: + type: string + description: Playground endpoint key (e.g. "chat.completions") + model: + type: string + system: + type: string + nullable: true + params: + type: object + additionalProperties: true + description: Serialized parameter values (temperature, max_tokens, etc.) + created_at: + type: string + format: date-time + PlaygroundPresetCreate: + type: object + required: + - name + - endpoint + - model + properties: + name: + type: string + minLength: 1 + maxLength: 100 + endpoint: + type: string + minLength: 1 + model: + type: string + minLength: 1 + system: + type: string + nullable: true + params: + type: object + additionalProperties: true + default: {} + MemoryEntry: + type: object + description: A single persisted memory entry + properties: + id: + type: string + description: UUID + apiKeyId: + type: string + sessionId: + type: string + nullable: true + type: + type: string + enum: + - factual + - episodic + - procedural + - semantic + key: + type: string + description: Stable upsert key (e.g. preference:i_prefer_python) + content: + type: string + metadata: + type: object + additionalProperties: true + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + expiresAt: + type: string + format: date-time + nullable: true + needsReindex: + type: integer + description: 1 if the vector for this memory is stale or missing + MemorySettingsExtended: + type: object + description: Extended memory settings including 7 new fields from plan 21. All fields are optional for PUT (patch semantics). + properties: + enabled: + type: boolean + maxTokens: + type: integer + minimum: 0 + maximum: 16000 + retentionDays: + type: integer + minimum: 1 + maximum: 365 + strategy: + type: string + enum: + - recent + - semantic + - hybrid + skillsEnabled: + type: boolean + embeddingSource: + type: string + enum: + - remote + - static + - transformers + - auto + description: Which embedding source to use. "auto" = remote > static > transformers. + embeddingProviderModel: + type: string + nullable: true + description: Embedding provider/model in "provider/model" format (e.g. openai/text-embedding-3-small). + transformersEnabled: + type: boolean + description: Opt-in for Transformers.js local MiniLM model (~400MB RAM) + staticEnabled: + type: boolean + description: Opt-in for static potion-base-8M local model + rerankEnabled: + type: boolean + description: Enable reranking step (+200-500ms/req) + rerankProviderModel: + type: string + nullable: true + description: Rerank provider/model in "provider/model" format + vectorStore: + type: string + enum: + - sqlite-vec + - qdrant + - auto + description: Which vector backend to use + QdrantSettings: + type: object + description: Qdrant vector database configuration (read shape — no raw apiKey) + properties: + enabled: + type: boolean + host: + type: string + port: + type: integer + minimum: 1 + maximum: 65535 + collection: + type: string + embeddingModel: + type: string + hasApiKey: + type: boolean + apiKeyMasked: + type: string + nullable: true + description: First 4 chars of the configured API key, or null + QdrantHealthResult: + type: object + description: Result of a Qdrant liveness probe + properties: + ok: + type: boolean + latencyMs: + type: number + error: + type: string + nullable: true + description: Sanitized error message (no stack traces) + AgentSkill: + type: object + description: >- + Single entry in the Agent Skills catalog. Describes one OmniRoute REST API surface + (category: api) or CLI subcommand group (category: cli) with a canonical ID and a + link to its SKILL.md documentation file. + required: [id, name, description, category, area, rawUrl, githubUrl] + properties: + id: + type: string + pattern: "^[a-z][a-z0-9-]*$" + description: Canonical skill ID (e.g. "omni-providers", "cli-serve") + example: omni-providers + name: + type: string + minLength: 1 + maxLength: 100 + description: Human-readable skill name + example: Provider Management + description: + type: string + minLength: 1 + maxLength: 2000 + description: One-paragraph description of what the skill covers + category: + type: string + enum: [api, cli] + description: "api = REST API skill; cli = CLI subcommand skill" + area: + type: string + minLength: 1 + maxLength: 50 + description: Functional area slug (e.g. "providers", "combos-routing", "cli-serve") + example: providers + endpoints: + type: array + items: + type: string + description: REST API endpoints (present for api-category skills only) + example: ["POST /api/providers", "GET /api/providers/:id"] + cliCommands: + type: array + items: + type: string + description: CLI subcommand names (present for cli-category skills only) + example: ["providers list", "providers test", "providers rotate"] + icon: + type: string + description: Material symbol icon name for dashboard display + isEntry: + type: boolean + description: Whether this is a recommended starting point + isNew: + type: boolean + description: Whether this skill was added in a recent release + rawUrl: + type: string + format: uri + description: GitHub raw URL of the SKILL.md file + example: "https://raw.githubusercontent.com/diegosouzapw/OmniRoute/refs/heads/main/skills/omni-providers/SKILL.md" + githubUrl: + type: string + format: uri + description: GitHub blob URL for viewing the SKILL.md in the browser + example: "https://github.com/diegosouzapw/OmniRoute/blob/main/skills/omni-providers/SKILL.md" + + SkillCoverage: + type: object + description: >- + Coverage statistics for the Agent Skills catalog: how many of the 22 REST API + skills and 20 CLI skills have generated SKILL.md files on the local filesystem. + required: [api, cli, totalSkills, generatedAt] + properties: + api: + type: object + required: [have, total] + properties: + have: + type: integer + minimum: 0 + maximum: 22 + description: Number of API skills with SKILL.md on disk + total: + type: integer + enum: [22] + description: Canonical API skill count (always 22) + cli: + type: object + required: [have, total] + properties: + have: + type: integer + minimum: 0 + maximum: 20 + description: Number of CLI skills with SKILL.md on disk + total: + type: integer + enum: [20] + description: Canonical CLI skill count (always 20) + totalSkills: + type: integer + minimum: 0 + maximum: 42 + description: Sum of api.have + cli.have + generatedAt: + type: string + format: date-time + description: ISO datetime when coverage was last computed + + ErrorResponse: + type: object + description: Standard error response body + required: [error] + properties: + error: + type: object + required: [message] + properties: + message: + type: string + description: Human-readable error message (never includes stack traces) + code: + type: string + description: Machine-readable error code + + # ─── AgentBridge Schemas ──────────────────────────────────────── + + AgentId: + type: string + enum: + - antigravity + - kiro + - copilot + - codex + - cursor + - zed + - claude-code + - open-code + - trae + description: One of the 9 supported IDE agents + + AgentBridgeAgentState: + type: object + description: Per-agent MITM state + properties: + agent_id: + $ref: "#/components/schemas/AgentId" + dns_enabled: + type: boolean + cert_trusted: + type: boolean + setup_completed: + type: boolean + last_started_at: + type: string + format: date-time + nullable: true + last_error: + type: string + nullable: true + + AgentBridgeServerState: + type: object + description: Global AgentBridge MITM server state + properties: + running: + type: boolean + port: + type: integer + example: 443 + certReady: + type: boolean + interceptedCount: + type: integer + activeConnections: + type: integer + lastStartedAt: + type: string + format: date-time + nullable: true + + AgentBridgeServerAction: + type: object + required: [action] + properties: + action: + type: string + enum: [start, stop, restart, trust-cert, regenerate-cert] + + AgentBridgeDnsAction: + type: object + required: [enabled] + properties: + enabled: + type: boolean + + AgentBridgeMappingRow: + type: object + properties: + agent_id: + $ref: "#/components/schemas/AgentId" + source_model: + type: string + example: gpt-4o + target_model: + type: string + example: claude-sonnet-4.7 + updated_at: + type: string + format: date-time + + AgentBridgeMappingPut: + type: object + required: [mappings] + properties: + mappings: + type: array + items: + type: object + required: [source, target] + properties: + source: + type: string + example: gpt-4o + target: + type: string + example: claude-sonnet-4.7 + + AgentBridgeBypassRow: + type: object + properties: + pattern: + type: string + example: "*.bank.*" + source: + type: string + enum: [default, user] + created_at: + type: string + format: date-time + + AgentBridgeBypassUpsert: + type: object + required: [patterns] + properties: + patterns: + type: array + items: + type: string + example: ["*.bank.*", "*.gov.*"] + + AgentBridgeUpstreamCaPost: + type: object + required: [path] + properties: + path: + type: string + description: Absolute path to a PEM file for corporate upstream CA + example: "/etc/ssl/certs/corporate-ca.pem" + + # ─── Traffic Inspector Schemas ────────────────────────────────── + + CaptureSource: + type: string + enum: [agent-bridge, custom-host, http-proxy, system-proxy] + + DetectedKind: + type: string + enum: [llm, app, unknown] + + InterceptedRequest: + type: object + description: A single intercepted HTTP request captured by the Traffic Inspector + required: + [ + id, + source, + timestamp, + method, + host, + path, + requestHeaders, + requestSize, + responseHeaders, + responseSize, + status, + ] + properties: + id: + type: string + format: uuid + source: + $ref: "#/components/schemas/CaptureSource" + agent: + $ref: "#/components/schemas/AgentId" + timestamp: + type: string + format: date-time + method: + type: string + example: POST + host: + type: string + example: api.githubcopilot.com + path: + type: string + example: /v1/chat/completions + requestHeaders: + type: object + additionalProperties: + type: string + requestBody: + type: string + nullable: true + description: Masked (secrets replaced with ***) + requestSize: + type: integer + responseHeaders: + type: object + additionalProperties: + type: string + responseBody: + type: string + nullable: true + responseSize: + type: integer + status: + oneOf: + - type: integer + - type: string + enum: [in-flight, error] + proxyLatencyMs: + type: number + nullable: true + upstreamLatencyMs: + type: number + nullable: true + totalLatencyMs: + type: number + nullable: true + error: + type: string + nullable: true + description: Sanitized error message (no stack traces) + sourceModel: + type: string + nullable: true + mappedModel: + type: string + nullable: true + detectedKind: + $ref: "#/components/schemas/DetectedKind" + contextKey: + type: string + nullable: true + description: 12-char SHA-256 hex of the system prompt (for conversation grouping) + example: a3f9c2b1d5e4 + annotation: + type: string + nullable: true + sessionId: + type: string + format: uuid + nullable: true + note: + type: string + nullable: true + description: Informational note (e.g. TLS tunnel metadata) + + InspectorCustomHost: + type: object + properties: + host: + type: string + example: api.openai.com + enabled: + type: boolean + label: + type: string + nullable: true + kind: + type: string + enum: [llm, app, custom] + added_at: + type: string + format: date-time + last_seen_at: + type: string + format: date-time + nullable: true + + InspectorCustomHostCreate: + type: object + required: [host] + properties: + host: + type: string + minLength: 1 + example: my-internal-llm.company.com + enabled: + type: boolean + default: true + label: + type: string + nullable: true + kind: + type: string + enum: [llm, app, custom] + default: custom + + InspectorCaptureModesState: + type: object + properties: + agentBridge: + type: object + properties: + active: + type: boolean + customHosts: + type: object + properties: + active: + type: boolean + count: + type: integer + httpProxy: + type: object + properties: + active: + type: boolean + port: + type: integer + example: 8080 + systemProxy: + type: object + properties: + active: + type: boolean + guardMinutes: + type: integer + + InspectorCaptureModeAction: + type: object + required: [action] + properties: + action: + type: string + enum: [start, stop] + + InspectorSystemProxyAction: + type: object + required: [action] + properties: + action: + type: string + enum: [apply, revert] + port: + type: integer + minimum: 1 + maximum: 65535 + example: 8080 + guardMinutes: + type: integer + minimum: 1 + example: 30 + + InspectorTlsInterceptToggle: + type: object + required: [enabled] + properties: + enabled: + type: boolean + + InspectorAnnotationPut: + type: object + required: [annotation] + properties: + annotation: + type: string + maxLength: 10000 + + InspectorSession: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + nullable: true + started_at: + type: string + format: date-time + ended_at: + type: string + format: date-time + nullable: true + request_count: + type: integer + profile: + type: string + enum: [llm, custom, all] + nullable: true + + InspectorSessionStart: + type: object + properties: + name: + type: string + example: "Antigravity test run #1" + + InspectorSessionPatch: + type: object + required: [action] + properties: + action: + type: string + enum: [stop, rename] + name: + type: string + QuotaPool: + type: object + description: A quota sharing pool — binds a provider connection to allocation rules. + required: [id, connectionId, name, createdAt, allocations] + properties: + id: + type: string + connectionId: + type: string + name: + type: string + createdAt: + type: string + format: date-time + allocations: + type: array + items: + $ref: "#/components/schemas/PoolAllocation" + + PoolAllocation: + type: object + required: [apiKeyId, weight, policy] + properties: + apiKeyId: + type: string + weight: + type: number + minimum: 0 + maximum: 100 + description: Share percentage (0–100) + capValue: + type: number + nullable: true + description: Absolute cap value (optional) + capUnit: + type: string + enum: [percent, requests, tokens, usd] + nullable: true + policy: + type: string + enum: [hard, soft, burst] + + PoolCreate: + type: object + required: [connectionId, name] + properties: + connectionId: + type: string + name: + type: string + maxLength: 120 + allocations: + type: array + items: + $ref: "#/components/schemas/PoolAllocation" + default: [] + + PoolUpdate: + type: object + properties: + name: + type: string + maxLength: 120 + allocations: + type: array + items: + $ref: "#/components/schemas/PoolAllocation" + + PoolUsageSnapshot: + type: object + required: [poolId, generatedAt, dimensions] + properties: + poolId: + type: string + generatedAt: + type: string + format: date-time + dimensions: + type: array + items: + type: object + properties: + unit: + type: string + enum: [percent, requests, tokens, usd] + window: + type: string + enum: ["5h", hourly, daily, weekly, monthly] + limit: + type: number + consumedTotal: + type: number + perKey: + type: array + items: + type: object + properties: + apiKeyId: + type: string + consumed: + type: number + fairShare: + type: number + deficit: + type: number + description: "Negative = surplus; positive = over-allocation" + borrowing: + type: boolean + burnRate: + type: object + nullable: true + properties: + tokensPerSecond: + type: number + timeToExhaustionMs: + type: number + nullable: true + + QuotaDimension: + type: object + required: [unit, window, limit] + properties: + unit: + type: string + enum: [percent, requests, tokens, usd] + window: + type: string + enum: ["5h", hourly, daily, weekly, monthly] + limit: + type: number + minimum: 0 + + PlanUpsert: + type: object + required: [dimensions] + properties: + dimensions: + type: array + minItems: 1 + items: + $ref: "#/components/schemas/QuotaDimension" + + QuotaStoreSettings: + type: object + required: [driver] + properties: + driver: + type: string + enum: [sqlite, redis] + redisUrl: + type: string + format: uri + nullable: true + description: Redis connection URL (write-only; masked in GET responses) + + ServiceStatus: + type: object + description: Live supervisor state for an embedded service + properties: + tool: + type: string + example: 9router + state: + type: string + enum: [not_installed, stopped, starting, running, stopping, error] + pid: + type: integer + nullable: true + port: + type: integer + example: 20130 + health: + type: string + enum: [unknown, healthy, degraded] + startedAt: + type: string + format: date-time + nullable: true + lastError: + type: string + nullable: true + + ServiceStatusExtended: + allOf: + - $ref: "#/components/schemas/ServiceStatus" + - type: object + description: >- + Extended status including version metadata and (for 9Router) API key preview. + properties: + installedVersion: + type: string + nullable: true + latestVersion: + type: string + nullable: true + updateAvailable: + type: boolean + apiKeyMasked: + type: string + nullable: true + description: >- + Masked API key preview (e.g. "nr_****abcd"). + Present only for services that use an injected API key (9Router). + autoStart: + type: boolean + providerExpose: + type: boolean + description: >- + Whether models from this service are exposed as a routing provider. + 9Router only. + + ApiErrorResponse: + type: object + properties: + error: + type: object + properties: + message: + type: string + type: + type: string + details: + description: Optional additional error details + requestId: + type: string + format: uuid + + ValidationErrorResponse: + type: object + properties: + error: + type: object + required: [message, details] + properties: + message: + type: string + example: Invalid request + details: + type: array + items: + type: object + required: [field, message] + properties: + field: + type: string + message: + type: string + + PayloadRuleModelSpec: + type: object + additionalProperties: false + required: [name] + properties: + name: + type: string + minLength: 1 + protocol: + type: string + minLength: 1 + + PayloadMutationRule: + type: object + additionalProperties: false + required: [models, params] + properties: + models: + type: array + minItems: 1 + items: + $ref: "#/components/schemas/PayloadRuleModelSpec" + params: + type: object + minProperties: 1 + additionalProperties: true + + PayloadFilterRule: + type: object + additionalProperties: false + required: [models, params] + properties: + models: + type: array + minItems: 1 + items: + $ref: "#/components/schemas/PayloadRuleModelSpec" + params: + type: array + minItems: 1 + items: + type: string + minLength: 1 + + PayloadRulesConfig: + type: object + additionalProperties: false + required: [default, override, filter, defaultRaw] + properties: + default: + type: array + items: + $ref: "#/components/schemas/PayloadMutationRule" + override: + type: array + items: + $ref: "#/components/schemas/PayloadMutationRule" + filter: + type: array + items: + $ref: "#/components/schemas/PayloadFilterRule" + defaultRaw: + type: array + items: + $ref: "#/components/schemas/PayloadMutationRule" + + UpdatePayloadRulesRequest: + type: object + additionalProperties: false + description: At least one payload-rules section must be present in the request body. + properties: + default: + type: array + items: + $ref: "#/components/schemas/PayloadMutationRule" + override: + type: array + items: + $ref: "#/components/schemas/PayloadMutationRule" + filter: + type: array + items: + $ref: "#/components/schemas/PayloadFilterRule" + defaultRaw: + type: array + items: + $ref: "#/components/schemas/PayloadMutationRule" + default-raw: + type: array + items: + $ref: "#/components/schemas/PayloadMutationRule" + anyOf: + - required: [default] + - required: [override] + - required: [filter] + - required: [defaultRaw] + - required: [default-raw] + + ChatCompletionRequest: + type: object + required: [model, messages] + properties: + model: + type: string + example: gpt-4o + messages: + type: array + items: + type: object + required: [role] + properties: + role: + type: string + description: >- + Message role. The proxy accepts any non-empty string; common values + include system, user, assistant, tool, function, and developer. + example: user + content: + description: >- + Message content. May be a plain string, an array of content parts + for multimodal inputs (text, image, audio, etc.), or null when the + message only carries tool/function calls. + oneOf: + - type: string + - type: array + items: + type: object + - type: "null" + name: + type: string + tool_call_id: + type: string + tool_calls: + type: array + items: + type: object + function_call: + type: object + stream: + type: boolean + default: false + temperature: + type: number + minimum: 0 + maximum: 2 + max_tokens: + type: integer + top_p: + type: number + minimum: 0 + maximum: 1 + n: + type: integer + minimum: 1 + default: 1 + stop: + description: Up to 4 stop sequences (string or array of strings). + oneOf: + - type: string + - type: array + items: + type: string + maxItems: 4 + frequency_penalty: + type: number + minimum: -2 + maximum: 2 + presence_penalty: + type: number + minimum: -2 + maximum: 2 + seed: + type: integer + logprobs: + type: boolean + top_logprobs: + type: integer + minimum: 0 + maximum: 20 + response_format: + type: object + description: Output format constraint (e.g. JSON mode or JSON Schema). + properties: + type: + type: string + example: json_object + tools: + type: array + description: Tool definitions available to the model. + items: + type: object + tool_choice: + description: Controls which tool (if any) is invoked by the model. + oneOf: + - type: string + example: auto + - type: object + parallel_tool_calls: + type: boolean + default: true + service_tier: + type: string + example: auto + user: + type: string + description: Stable end-user identifier for abuse monitoring. + + ChatCompletionResponse: + type: object + properties: + id: + type: string + object: + type: string + example: chat.completion + choices: + type: array + items: + type: object + properties: + index: + type: integer + message: + type: object + properties: + role: + type: string + content: + type: string + finish_reason: + type: string + usage: + type: object + properties: + prompt_tokens: + type: integer + completion_tokens: + type: integer + total_tokens: + type: integer + + MessagesRequest: + type: object + required: [model, messages, max_tokens] + properties: + model: + type: string + example: claude-sonnet-4-5-20250514 + messages: + type: array + items: + type: object + required: [role, content] + properties: + role: + type: string + enum: [user, assistant] + content: + type: string + max_tokens: + type: integer + stream: + type: boolean + default: false + system: + type: string + + Model: + type: object + properties: + id: + type: string + object: + type: string + example: model + owned_by: + type: string + + ProviderConnection: + type: object + properties: + id: + type: string + provider: + type: string + name: + type: string + url: + type: string + isActive: + type: boolean + maxConcurrent: + type: integer + nullable: true + minimum: 0 + priority: + type: integer + testStatus: + type: string + enum: [active, error, untested] + createdAt: + type: string + format: date-time + + ProviderConnectionCreate: + type: object + required: [provider, url] + properties: + provider: + type: string + example: openai + name: + type: string + url: + type: string + apiKey: + type: string + isActive: + type: boolean + default: true + maxConcurrent: + type: integer + nullable: true + minimum: 0 + + ApiKey: + type: object + properties: + id: + type: string + label: + type: string + keyPreview: + type: string + description: Last 4 characters of the key + isActive: + type: boolean + createdAt: + type: string + format: date-time + + ComboCreate: + type: object + required: [name, model] + properties: + name: + type: string + model: + type: string + strategy: + type: string + enum: + - priority + - weighted + - round-robin + - context-relay + - fill-first + - p2c + - random + - least-used + - cost-optimized + - reset-aware + - strict-random + - auto + - lkgp + - context-optimized + default: priority + nodes: + type: array + items: + type: object + properties: + connectionId: + type: string + weight: + type: integer + priority: + type: integer diff --git a/scripts/build/pack-artifact-policy.ts b/scripts/build/pack-artifact-policy.ts index b7df9b321e..53331d35d0 100644 --- a/scripts/build/pack-artifact-policy.ts +++ b/scripts/build/pack-artifact-policy.ts @@ -34,7 +34,7 @@ export const APP_STAGING_REMOVAL_PATHS: string[] = [ export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [ ".env.example", "BUILD_SHA", - "docs/reference/openapi.yaml", + "docs/openapi.yaml", "http-method-guard.cjs", "open-sse/mcp-server/server.js", // LLMLingua ONNX worker — esbuild'd standalone .js spawned via worker_threads diff --git a/scripts/check/check-docs-sync.mjs b/scripts/check/check-docs-sync.mjs index c983a26c3b..bb704bbc5f 100644 --- a/scripts/check/check-docs-sync.mjs +++ b/scripts/check/check-docs-sync.mjs @@ -5,7 +5,7 @@ import path from "node:path"; const cwd = process.cwd(); const packageJsonPath = path.resolve(cwd, "package.json"); -const openApiPath = path.resolve(cwd, "docs/reference/openapi.yaml"); +const openApiPath = path.resolve(cwd, "docs/openapi.yaml"); const changelogPath = path.resolve(cwd, "CHANGELOG.md"); const llmPath = path.resolve(cwd, "llm.txt"); const i18nDocsPath = path.resolve(cwd, "docs/i18n"); @@ -209,7 +209,7 @@ try { const openApiVersion = extractOpenApiVersion(readText(openApiPath)); if (!openApiVersion) { - fail("could not extract docs/reference/openapi.yaml info.version"); + fail("could not extract docs/openapi.yaml info.version"); } else if (openApiVersion !== packageVersion) { fail(`OpenAPI version (${openApiVersion}) differs from package.json (${packageVersion})`); } else { diff --git a/scripts/check/check-env-doc-sync.mjs b/scripts/check/check-env-doc-sync.mjs index 050ee75735..93cbb2066a 100644 --- a/scripts/check/check-env-doc-sync.mjs +++ b/scripts/check/check-env-doc-sync.mjs @@ -132,6 +132,10 @@ const IGNORE_FROM_CODE = new Set([ // Test-only opt-out: instructs bin/omniroute.mjs to skip auto-loading the // repository .env so isolation tests get a deterministic environment. "OMNIROUTE_CLI_SKIP_REPO_ENV", + // Eval-harness only: operator-supplied provider credentials JSON read by the + // opt-in `npm run eval:compression` CLI (scripts/compression-eval/index.ts). + // A dev/ops measurement tool, never OmniRoute runtime config. + "OMNIROUTE_EVAL_CREDENTIALS", // Build-time only: set by `build:release` (git short SHA) and read by // write-build-sha.mjs to stamp dist/BUILD_SHA — injected by the build, never // configured by users in .env. diff --git a/scripts/check/check-fabricated-docs.mjs b/scripts/check/check-fabricated-docs.mjs index 02ba62ac33..12ccfba6b2 100644 --- a/scripts/check/check-fabricated-docs.mjs +++ b/scripts/check/check-fabricated-docs.mjs @@ -358,7 +358,7 @@ const ENDPOINT_ALLOWLIST = new Set([ /** Doc files to skip (auto-generated, vendored, or third-party). */ const SKIP_DOC_FILES = new Set([ "docs/reference/PROVIDER_REFERENCE.md", // auto-generated from providers.ts - "docs/reference/openapi.yaml", + "docs/openapi.yaml", "docs/i18n", // translations — separate workflow // Point-in-time documentation audit (v3.8.24): intentionally references drift, // counts, and not-yet-existing files as part of documenting them — not living docs. @@ -370,6 +370,7 @@ const SKIP_DOC_FILES = new Set([ // expected, not fabrications. "docs/research", // DISCOVERY_TOOL_DESIGN.md, UNLIMITED_LLM_ACCESS.md, … "docs/superpowers/plans", // dated implementation plans (files described before they exist) + "docs/superpowers/specs", // dated research/spec reports (point-in-time findings, may cite proposed/not-yet-built endpoints, env vars, and files) — same rationale as the plans/research dirs above // Release notes are historical, point-in-time records: they intentionally describe // modules/paths as they were at that release (e.g. a module later moved or renamed). // Rewriting them to today's layout would falsify history — out of scope for a diff --git a/scripts/check/check-openapi-breaking.mjs b/scripts/check/check-openapi-breaking.mjs index 168f5a07bd..df66545357 100644 --- a/scripts/check/check-openapi-breaking.mjs +++ b/scripts/check/check-openapi-breaking.mjs @@ -2,7 +2,7 @@ // scripts/check/check-openapi-breaking.mjs // Catraca de breaking-change na API pública (Fase 8 B.4 — backlog opcional). // -// Diffa a spec do PR (docs/reference/openapi.yaml na working tree = HEAD) contra +// Diffa a spec do PR (docs/openapi.yaml na working tree = HEAD) contra // a MESMA spec no branch base, via `oasdiff breaking`. Pega regressões de contrato: // endpoint removido, parâmetro novo obrigatório, campo de resposta removido, enum // estreitado, etc. — mudanças que quebram clientes existentes. @@ -32,7 +32,7 @@ // • CI passa BASE_REF=${{ github.base_ref }} (ex.: "release/vX.Y.Z"). // • Local: default derivado da versão do package.json (releaseBranchForVersion), // ex.: package 3.8.29 → "origin/release/v3.8.29" — nunca fica stale entre ciclos. -// A spec base é extraída com `git show :docs/reference/openapi.yaml`. +// A spec base é extraída com `git show :docs/openapi.yaml`. // // Uso: // node scripts/check/check-openapi-breaking.mjs @@ -52,8 +52,8 @@ const QUIET = process.argv.includes("--quiet"); const PRINT_JSON = process.argv.includes("--json"); const RATCHET = process.argv.includes("--ratchet"); -const SPEC_REL = "docs/reference/openapi.yaml"; -const SPEC_PATH = path.join(ROOT, "docs", "reference", "openapi.yaml"); +const SPEC_REL = "docs/openapi.yaml"; +const SPEC_PATH = path.join(ROOT, "docs", "openapi.yaml"); /** * Deriva o branch base de release a partir de uma versão semver diff --git a/scripts/check/check-openapi-coverage.mjs b/scripts/check/check-openapi-coverage.mjs index 0476107ea8..cc66d0fc6d 100644 --- a/scripts/check/check-openapi-coverage.mjs +++ b/scripts/check/check-openapi-coverage.mjs @@ -13,7 +13,7 @@ import * as yaml from "js-yaml"; const ROOT = process.cwd(); const API_ROOT = path.join(ROOT, "src", "app", "api"); -const OPENAPI_PATH = path.join(ROOT, "docs", "reference", "openapi.yaml"); +const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml"); // Floor recorded on 2026-05-26 for release/v3.8.4: 137/365 routes documented. // The original ≥99% target tracks the OpenAPI audit follow-up (#2701); // until the backlog (services, free-proxies, relay-tokens, key-groups, diff --git a/scripts/check/check-openapi-routes.mjs b/scripts/check/check-openapi-routes.mjs index 7f8b8b4923..db4db2a702 100644 --- a/scripts/check/check-openapi-routes.mjs +++ b/scripts/check/check-openapi-routes.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node // scripts/check/check-openapi-routes.mjs -// Gate anti-alucinação (docs): toda `path` documentada em docs/reference/openapi.yaml +// Gate anti-alucinação (docs): toda `path` documentada em docs/openapi.yaml // deve resolver para um route.ts real em src/app/api/. Pega endpoint INVENTADO/obsoleto // na spec (a IA escreve docs descrevendo rota que não existe). Complementa // check-openapi-coverage.mjs (que mede a direção inversa: % de rotas documentadas). @@ -14,7 +14,7 @@ import { assertNoStale } from "./lib/allowlist.mjs"; const ROOT = process.cwd(); const API_ROOT = path.join(ROOT, "src", "app", "api"); -const OPENAPI_PATH = path.join(ROOT, "docs", "reference", "openapi.yaml"); +const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml"); // Entradas da spec sem rota real, congeladas para triagem (catraca: bloqueia NOVAS). export const KNOWN_STALE_SPEC = new Set([ diff --git a/scripts/check/check-openapi-security-tiers.mjs b/scripts/check/check-openapi-security-tiers.mjs index c2acfd6d23..812e4a0c1b 100644 --- a/scripts/check/check-openapi-security-tiers.mjs +++ b/scripts/check/check-openapi-security-tiers.mjs @@ -11,7 +11,7 @@ import path from "node:path"; import * as yaml from "js-yaml"; const ROOT = process.cwd(); -const OPENAPI_PATH = path.join(ROOT, "docs", "reference", "openapi.yaml"); +const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml"); const ROUTE_GUARD_PATH = path.join(ROOT, "src", "server", "authz", "routeGuard.ts"); function parseStringArray(match) { diff --git a/scripts/cli/generate-api-commands.mjs b/scripts/cli/generate-api-commands.mjs index ef4ce7d3a3..b937ceb389 100644 --- a/scripts/cli/generate-api-commands.mjs +++ b/scripts/cli/generate-api-commands.mjs @@ -10,7 +10,7 @@ import * as yaml from "js-yaml"; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, "..", ".."); -const SPEC_PATH = process.env.OPENAPI_SPEC || join(ROOT, "docs/reference/openapi.yaml"); +const SPEC_PATH = process.env.OPENAPI_SPEC || join(ROOT, "docs/openapi.yaml"); const OUT_DIR = join(ROOT, "bin/cli/api-commands"); // Operations already covered by hand-crafted commands — skip in generated output. diff --git a/scripts/compression-eval/index.ts b/scripts/compression-eval/index.ts new file mode 100644 index 0000000000..d968bf2812 --- /dev/null +++ b/scripts/compression-eval/index.ts @@ -0,0 +1,72 @@ +/** + * Compression evaluation harness CLI (D1). Runs the offline corpus eval (full-vs-compressed + * + self-validating judge + gold grading + mechanical savings) and prints a markdown report. + * + * Real model calls cost money — a full run is OPT-IN. Use --sample and --cost-cap-usd. + * + * Usage: + * npm run eval:compression -- --answer-model --judge-model --provider

\ + * --sample 10 --cost-cap-usd 2 --mode lite + * + * Implementer-config (spec leaves open): the answer/judge model ids, the provider, and the + * default cost cap / sample. There is NO safe default that calls a real model — the CLI errors + * if --answer-model / --judge-model / --provider are missing, so a bare run never spends money. + */ +import { runEval } from "../../open-sse/services/compression/eval/runner.ts"; +import { createExecutorModelClient } from "../../open-sse/services/compression/eval/executorModelClient.ts"; +import { formatReport } from "../../open-sse/services/compression/eval/report.ts"; +import { SEED_CORPUS } from "../../open-sse/services/compression/eval/seedCorpus.ts"; +import { getDefaultCompressionConfig } from "../../open-sse/services/compression/stats.ts"; +import type { CompressionConfig } from "../../open-sse/services/compression/types.ts"; + +function flag(name: string): string | undefined { + const i = process.argv.indexOf(`--${name}`); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +async function main(): Promise { + const answerModel = flag("answer-model"); + const judgeModel = flag("judge-model"); + const provider = flag("provider"); + if (!answerModel || !judgeModel || !provider) { + console.error("eval:compression requires --answer-model, --judge-model and --provider (no model is called without them)."); + process.exitCode = 2; + return; + } + const sample = flag("sample") ? Number(flag("sample")) : undefined; + const costCapUsd = flag("cost-cap-usd") ? Number(flag("cost-cap-usd")) : 0; + const mode = (flag("mode") ?? "lite") as CompressionConfig["defaultMode"]; + + // Credentials wiring is operator-supplied (env / connection store). Documented in Rule #18: + // the adapter is validated against a real account; this CLI reads the credential the operator + // exports for the chosen provider. Placeholder lookup left to the operator's environment. + const credentials = JSON.parse(process.env.OMNIROUTE_EVAL_CREDENTIALS ?? "{}"); + const client = createExecutorModelClient(provider, credentials); + + const config: CompressionConfig = { ...getDefaultCompressionConfig(), enabled: true, defaultMode: mode }; + + const result = await runEval({ + corpus: SEED_CORPUS, + client, + config, + comboId: null, + combos: {}, + answerModel, + judgeModel, + provider, + costCapUsd, + sample, + }); + + if (result.aborted) { + console.error(`eval aborted: ${result.abortReason}`); + process.exitCode = 1; + return; + } + console.log(formatReport(result.report!)); +} + +main().catch((err) => { + console.error("eval:compression failed:", err instanceof Error ? err.message : err); + process.exitCode = 1; +}); diff --git a/scripts/docs/gen-openapi-module.mjs b/scripts/docs/gen-openapi-module.mjs index 785ee055c2..184ec404e0 100644 --- a/scripts/docs/gen-openapi-module.mjs +++ b/scripts/docs/gen-openapi-module.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * gen-openapi-module.mjs — build helper that reads docs/reference/openapi.yaml, + * gen-openapi-module.mjs — build helper that reads docs/openapi.yaml, * flattens the path/method matrix, and emits * src/app/docs/lib/openapi.generated.ts so the Api Explorer client can * iterate endpoints without parsing YAML at runtime. @@ -23,7 +23,7 @@ import * as yaml from "js-yaml"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const ROOT = path.resolve(__dirname, "..", ".."); -const OPENAPI_PATH = path.join(ROOT, "docs", "reference", "openapi.yaml"); +const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml"); const OUT_PATH = path.join(ROOT, "src", "app", "docs", "lib", "openapi.generated.ts"); const HTTP_METHODS = ["get", "post", "put", "delete", "patch", "options", "head"]; @@ -108,7 +108,7 @@ async function main() { const header = `// AUTO-GENERATED by scripts/docs/gen-openapi-module.mjs — DO NOT EDIT MANUALLY // Regenerate with: node scripts/docs/gen-openapi-module.mjs // -// Source of truth: docs/reference/openapi.yaml +// Source of truth: docs/openapi.yaml // // The Api Explorer consumes \`OPENAPI_ENDPOINTS\`; the spec metadata // (\`OPENAPI_VERSION\`, \`OPENAPI_TITLE\`) is surfaced in the page header. diff --git a/scripts/quality/collect-metrics.mjs b/scripts/quality/collect-metrics.mjs index 7baa98fb65..eaa7b6bc8d 100644 --- a/scripts/quality/collect-metrics.mjs +++ b/scripts/quality/collect-metrics.mjs @@ -119,7 +119,7 @@ function coverageByModule() { // 4) OpenAPI coverage: percentage of implemented routes documented in openapi.yaml function openapiCoverage() { const API_ROOT = path.join(cwd, "src", "app", "api"); - const OPENAPI_PATH = path.join(cwd, "docs", "reference", "openapi.yaml"); + const OPENAPI_PATH = path.join(cwd, "docs", "openapi.yaml"); if (!fs.existsSync(API_ROOT) || !fs.existsSync(OPENAPI_PATH)) return; function collectRoutePaths(dir) { diff --git a/scripts/quality/validate-release-green.mjs b/scripts/quality/validate-release-green.mjs index 477cacea4c..a13253e32b 100644 --- a/scripts/quality/validate-release-green.mjs +++ b/scripts/quality/validate-release-green.mjs @@ -12,21 +12,32 @@ // the real state of the release branch at any time. // // DESIGN — never blocking to contributors: -// • HARD checks (typecheck, lint errors, unit, vitest, db-rules, public-creds, -// optionally package-artifact) → a failure here is a real defect; exit 1. -// • DRIFT checks (eslint WARNINGS, cognitive-complexity, file-size) → ratchet -// drift accrued across the cycle is NOT a contributor's fault; it is reported -// and rebaselined by the maintainer at release. Drift NEVER changes the exit -// code, so wiring this as a check can never block anyone on drift. +// • HARD checks (typecheck, lint errors, db-rules, public-creds, docs-all, +// unit, vitest, integration, optionally package-artifact) → a failure here is +// a real defect; exit 1. +// • DRIFT checks (eslint WARNINGS, cognitive-complexity, file-size, cyclomatic +// complexity, dead-code, type-coverage, compression-budget, openapi-coverage, +// workflow-lint/zizmor, codeql-ratchet) → ratchet drift accrued across the +// cycle is NOT a contributor's fault; it is reported and rebaselined by the +// maintainer at release. Drift NEVER changes the exit code, so wiring this as +// a check can never block anyone on drift. +// +// COMPLETENESS: this mirrors the FULL release-PR gate set (quality-gate + +// quality-extended + docs-sync-strict + integration), not a subset — and reports +// EVERY red in one pass (the report is collected, not fail-fast), so the release +// PR is green on its first CI run instead of revealing reds in ~40-min layers. The +// only release-PR gates it cannot reproduce locally are GitHub-side CodeQL semantic +// analysis and SonarQube/SonarCloud (external services). // // This script DIAGNOSES + REPORTS only (no auto-fix). The fix-to-green -// orchestration lives in the (future) /green-prs + review-prs flows that call it. +// orchestration lives in the /green-prs + review-prs flows that call it. // // Usage: // node scripts/quality/validate-release-green.mjs [--json] [--with-build] [--quick] // --json emit machine-readable JSON to stdout (report goes to stderr) // --with-build also run check:pack-artifact (needs a dist/ build — slow) -// --quick skip the slow unit + vitest suites (drift + typecheck + lint only) +// --quick skip the slow unit + vitest + integration suites (drift + fast +// gates only) import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; @@ -144,6 +155,16 @@ function main() { record({ id, label, kind: "hard", ok: code === 0, detail: code === 0 ? "pass" : firstFailureLine(out) }); }; + // A ratchet command (check:complexity, check:dead-code, …) exits 1 ONLY on a + // measured regression and self-skips (exit 0) when its tooling is absent — so a + // non-zero exit here is drift to rebaseline at release, never a contributor block. + // ALL checks run regardless of earlier failures (the report is collected, not + // fail-fast) so one pass surfaces every red instead of revealing them in layers. + const driftCmd = (id, label, cmd, cmdArgs, okDetail = "within baseline") => { + const { code, out } = run(cmd, cmdArgs); + record({ id, label, kind: "drift", ok: code === 0, detail: code === 0 ? okDetail : firstFailureLine(out) }); + }; + process.stderr.write("🔎 Release-green validation (current working tree)\n\n"); hardCmd("typecheck", "Typecheck (core)", npmCmd, ["run", "typecheck:core"]); @@ -205,9 +226,29 @@ function main() { }); } + // Remaining quality-gate / quality-extended ratchets that the PR→release + // fast-gates skip and that historically surfaced — one at a time, because the + // CI Quality Ratchet job is fail-fast — only on the release PR. Running them all + // here (drift, never blocking) means a single rebaseline pass at release. + driftCmd("complexity", "Cyclomatic complexity (ratchet)", npmCmd, ["run", "check:complexity"]); + driftCmd("dead-code", "Dead-code (ratchet)", npmCmd, ["run", "check:dead-code"]); + driftCmd("type-coverage", "Type coverage (ratchet)", npmCmd, ["run", "check:type-coverage"]); + driftCmd("compression-budget", "Compression budget (ratchet)", npmCmd, ["run", "check:compression-budget"]); + driftCmd("openapi-coverage", "OpenAPI route coverage (ratchet)", npmCmd, ["run", "check:openapi-coverage"]); + driftCmd("workflow-lint", "Workflow lint (zizmor ratchet)", npmCmd, ["run", "check:workflows", "--", "--ratchet"]); + driftCmd("codeql-ratchet", "CodeQL alerts (ratchet)", npmCmd, ["run", "check:codeql-ratchet"]); + + // Docs sync + fabricated-docs (strict) is a real-defect gate (invented env vars / + // routes, i18n mirror drift) — HARD. + hardCmd("docs-all", "Docs sync + fabricated-docs (strict)", npmCmd, ["run", "check:docs-all"]); + if (!QUICK) { hardCmd("unit", "Unit tests (full, CI concurrency)", npmCmd, ["run", "test:unit:ci"]); hardCmd("vitest", "Vitest (MCP / autoCombo / cache)", npmCmd, ["run", "test:vitest"]); + // Integration tests run ONLY on the release PR full CI (PR→main), so an assertion + // regression here (e.g. a contributor flipping a Codex fingerprint key order) is + // invisible until release — run them in the pre-flight as a HARD gate. + hardCmd("integration", "Integration tests", npmCmd, ["run", "test:integration"]); } if (WITH_BUILD) { hardCmd("pack-artifact", "Package artifact (npm pack policy)", npmCmd, ["run", "check:pack-artifact"]); diff --git a/skills/omni-agents-a2a/SKILL.md b/skills/omni-agents-a2a/SKILL.md index b2d3c5788b..4dcf94ed5c 100644 --- a/skills/omni-agents-a2a/SKILL.md +++ b/skills/omni-agents-a2a/SKILL.md @@ -17,7 +17,7 @@ All requests require a valid Bearer token or session cookie. Obtain a token via _No endpoints mapped for this area yet._ ## Payloads -See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/reference/openapi.yaml` for detailed request/response schemas. +See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/openapi.yaml` for detailed request/response schemas. diff --git a/skills/omni-api-keys/SKILL.md b/skills/omni-api-keys/SKILL.md index c6c2a2b374..a2563b0ea2 100644 --- a/skills/omni-api-keys/SKILL.md +++ b/skills/omni-api-keys/SKILL.md @@ -45,4 +45,4 @@ curl -X DELETE https://localhost:20128/api/keys/{id} \ ## Payloads -See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/reference/openapi.yaml` for detailed request/response schemas. +See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/openapi.yaml` for detailed request/response schemas. diff --git a/skills/omni-auth/SKILL.md b/skills/omni-auth/SKILL.md index 67a75981b3..f010763008 100644 --- a/skills/omni-auth/SKILL.md +++ b/skills/omni-auth/SKILL.md @@ -38,7 +38,7 @@ curl -X POST https://localhost:20128/api/auth/logout \ ## Payloads -See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/reference/openapi.yaml` for detailed request/response schemas. +See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/openapi.yaml` for detailed request/response schemas. diff --git a/skills/omni-budget/SKILL.md b/skills/omni-budget/SKILL.md index d3f4f9f6ac..09744616d2 100644 --- a/skills/omni-budget/SKILL.md +++ b/skills/omni-budget/SKILL.md @@ -36,4 +36,4 @@ curl -X POST https://localhost:20128/api/rate-limit \ ## Payloads -See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/reference/openapi.yaml` for detailed request/response schemas. +See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/openapi.yaml` for detailed request/response schemas. diff --git a/skills/omni-cache/SKILL.md b/skills/omni-cache/SKILL.md index 79a15c841b..e0fb260d1c 100644 --- a/skills/omni-cache/SKILL.md +++ b/skills/omni-cache/SKILL.md @@ -54,4 +54,4 @@ curl -X DELETE https://localhost:20128/api/cache/stats \ ## Payloads -See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/reference/openapi.yaml` for detailed request/response schemas. +See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/openapi.yaml` for detailed request/response schemas. diff --git a/skills/omni-cli-tools/SKILL.md b/skills/omni-cli-tools/SKILL.md index 1755add8b4..ff468040af 100644 --- a/skills/omni-cli-tools/SKILL.md +++ b/skills/omni-cli-tools/SKILL.md @@ -317,4 +317,4 @@ curl -X DELETE https://localhost:20128/api/cli-tools/openclaw-settings \ ## Payloads -See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/reference/openapi.yaml` for detailed request/response schemas. +See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/openapi.yaml` for detailed request/response schemas. diff --git a/skills/omni-combos-routing/SKILL.md b/skills/omni-combos-routing/SKILL.md index 315d46cd59..4517bb6d47 100644 --- a/skills/omni-combos-routing/SKILL.md +++ b/skills/omni-combos-routing/SKILL.md @@ -109,7 +109,7 @@ curl -X DELETE https://localhost:20128/api/fallback/chains \ ## Payloads -See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/reference/openapi.yaml` for detailed request/response schemas. +See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/openapi.yaml` for detailed request/response schemas. diff --git a/skills/omni-compression/SKILL.md b/skills/omni-compression/SKILL.md index 9addc4d4a3..eb89f08ccf 100644 --- a/skills/omni-compression/SKILL.md +++ b/skills/omni-compression/SKILL.md @@ -45,7 +45,7 @@ curl https://localhost:20128/api/compression/rules \ ## Payloads -See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/reference/openapi.yaml` for detailed request/response schemas. +See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/openapi.yaml` for detailed request/response schemas. diff --git a/skills/omni-context-rtk/SKILL.md b/skills/omni-context-rtk/SKILL.md index 34f24d19d6..8cb4d8193f 100644 --- a/skills/omni-context-rtk/SKILL.md +++ b/skills/omni-context-rtk/SKILL.md @@ -65,4 +65,4 @@ curl https://localhost:20128/api/context/rtk/raw-output/{id} \ ## Payloads -See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/reference/openapi.yaml` for detailed request/response schemas. +See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/openapi.yaml` for detailed request/response schemas. diff --git a/skills/omni-db-backups/SKILL.md b/skills/omni-db-backups/SKILL.md index e483f997d0..93fe053482 100644 --- a/skills/omni-db-backups/SKILL.md +++ b/skills/omni-db-backups/SKILL.md @@ -17,4 +17,4 @@ All requests require a valid Bearer token or session cookie. Obtain a token via _No endpoints mapped for this area yet._ ## Payloads -See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/reference/openapi.yaml` for detailed request/response schemas. +See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/openapi.yaml` for detailed request/response schemas. diff --git a/skills/omni-inference/SKILL.md b/skills/omni-inference/SKILL.md index 488bd70b27..8c9e0e0a42 100644 --- a/skills/omni-inference/SKILL.md +++ b/skills/omni-inference/SKILL.md @@ -210,7 +210,7 @@ curl https://localhost:20128/api/v1/providers/{provider}/models \ ## Payloads -See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/reference/openapi.yaml` for detailed request/response schemas. +See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/openapi.yaml` for detailed request/response schemas. diff --git a/skills/omni-mcp/SKILL.md b/skills/omni-mcp/SKILL.md index 68aeb1b7a7..e65e4795c8 100644 --- a/skills/omni-mcp/SKILL.md +++ b/skills/omni-mcp/SKILL.md @@ -17,7 +17,7 @@ All requests require a valid Bearer token or session cookie. Obtain a token via _No endpoints mapped for this area yet._ ## Payloads -See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/reference/openapi.yaml` for detailed request/response schemas. +See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/openapi.yaml` for detailed request/response schemas. diff --git a/skills/omni-models/SKILL.md b/skills/omni-models/SKILL.md index 9c7706623b..a86ac9fd79 100644 --- a/skills/omni-models/SKILL.md +++ b/skills/omni-models/SKILL.md @@ -56,4 +56,4 @@ curl https://localhost:20128/api/models/catalog \ ## Payloads -See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/reference/openapi.yaml` for detailed request/response schemas. +See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/openapi.yaml` for detailed request/response schemas. diff --git a/skills/omni-providers/SKILL.md b/skills/omni-providers/SKILL.md index 0dd1dfab38..1856ca0ff8 100644 --- a/skills/omni-providers/SKILL.md +++ b/skills/omni-providers/SKILL.md @@ -176,7 +176,7 @@ curl https://localhost:20128/api/provider-models \ ## Payloads -See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/reference/openapi.yaml` for detailed request/response schemas. +See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/openapi.yaml` for detailed request/response schemas. ## Additional endpoints diff --git a/skills/omni-proxies/SKILL.md b/skills/omni-proxies/SKILL.md index aaac2fd8c8..ea6860effe 100644 --- a/skills/omni-proxies/SKILL.md +++ b/skills/omni-proxies/SKILL.md @@ -17,4 +17,4 @@ All requests require a valid Bearer token or session cookie. Obtain a token via _No endpoints mapped for this area yet._ ## Payloads -See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/reference/openapi.yaml` for detailed request/response schemas. +See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/openapi.yaml` for detailed request/response schemas. diff --git a/skills/omni-resilience/SKILL.md b/skills/omni-resilience/SKILL.md index ecacdb7a4f..b1a4b8f7d2 100644 --- a/skills/omni-resilience/SKILL.md +++ b/skills/omni-resilience/SKILL.md @@ -27,7 +27,7 @@ curl https://localhost:20128/api/monitoring/health \ ## Payloads -See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/reference/openapi.yaml` for detailed request/response schemas. +See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/openapi.yaml` for detailed request/response schemas. diff --git a/skills/omni-settings/SKILL.md b/skills/omni-settings/SKILL.md index 778b5d7a25..fc9bcae210 100644 --- a/skills/omni-settings/SKILL.md +++ b/skills/omni-settings/SKILL.md @@ -219,4 +219,4 @@ curl https://localhost:20128/api/tags \ ## Payloads -See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/reference/openapi.yaml` for detailed request/response schemas. +See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/openapi.yaml` for detailed request/response schemas. diff --git a/skills/omni-sync-cloud/SKILL.md b/skills/omni-sync-cloud/SKILL.md index 474c33781a..d6d7c5d41a 100644 --- a/skills/omni-sync-cloud/SKILL.md +++ b/skills/omni-sync-cloud/SKILL.md @@ -95,4 +95,4 @@ curl -X POST https://localhost:20128/api/sync/initialize \ ## Payloads -See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/reference/openapi.yaml` for detailed request/response schemas. +See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/openapi.yaml` for detailed request/response schemas. diff --git a/skills/omni-tunnels/SKILL.md b/skills/omni-tunnels/SKILL.md index 669d3c80a8..19cc2725a6 100644 --- a/skills/omni-tunnels/SKILL.md +++ b/skills/omni-tunnels/SKILL.md @@ -17,4 +17,4 @@ All requests require a valid Bearer token or session cookie. Obtain a token via _No endpoints mapped for this area yet._ ## Payloads -See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/reference/openapi.yaml` for detailed request/response schemas. +See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/openapi.yaml` for detailed request/response schemas. diff --git a/skills/omni-usage-logs/SKILL.md b/skills/omni-usage-logs/SKILL.md index 5010258ff1..120e6d8106 100644 --- a/skills/omni-usage-logs/SKILL.md +++ b/skills/omni-usage-logs/SKILL.md @@ -112,4 +112,4 @@ curl -X POST https://localhost:20128/api/usage/budget \ ## Payloads -See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/reference/openapi.yaml` for detailed request/response schemas. +See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/openapi.yaml` for detailed request/response schemas. diff --git a/skills/omni-version-manager/SKILL.md b/skills/omni-version-manager/SKILL.md index 29ce881028..167b5815a7 100644 --- a/skills/omni-version-manager/SKILL.md +++ b/skills/omni-version-manager/SKILL.md @@ -218,4 +218,4 @@ curl https://localhost:20128/api/services/{name}/logs \ ## Payloads -See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/reference/openapi.yaml` for detailed request/response schemas. +See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/openapi.yaml` for detailed request/response schemas. diff --git a/skills/omni-webhooks/SKILL.md b/skills/omni-webhooks/SKILL.md index 707241b96c..251b5f5817 100644 --- a/skills/omni-webhooks/SKILL.md +++ b/skills/omni-webhooks/SKILL.md @@ -17,4 +17,4 @@ All requests require a valid Bearer token or session cookie. Obtain a token via _No endpoints mapped for this area yet._ ## Payloads -See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/reference/openapi.yaml` for detailed request/response schemas. +See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/openapi.yaml` for detailed request/response schemas. diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index 1461f2215b..37ff007715 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -114,8 +114,11 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { Array<{ id?: string; prefix?: string; name?: string }> >([]); - // Live in-flight requests for Provider Topology pulse animation (#3507) - const { activeRequests: liveActiveRequests } = useLiveRequests(); + // The live in-flight request feed for the Provider Topology pulse animation is owned by + // , which subscribes to it (gated by the `enabled` prop) + // only when the topology is actually shown. HomePageClient must NOT open its own + // unconditional live socket: the binding here was unused (ReferenceError in prod, + // #4759/#4745) and the socket opened even when topology was hidden (#4596). const [versionInfo, setVersionInfo] = useState(null); const [updating, setUpdating] = useState(false); diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 5906352812..b64dcbd959 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -174,9 +174,31 @@ const ADVANCED_FIELD_HELP_FALLBACK = { }; const LEGACY_COMBO_RESILIENCE_KEYS = new Set([ + // UI-removed knobs (replaced by per-target timeoutMs on each step) "timeoutMs", "healthCheckEnabled", "healthCheckTimeoutMs", + // queueTimeoutMs is still in the schema but the dashboard UI no longer surfaces + // it; carrying it forward through edit+save leaves a stale knob in the modal + // that surprises operators. Strip it pre-PUT so the persisted config matches + // what the UI is currently able to display. + "queueTimeoutMs", + // Keys that were present in v3.8.31-era combo configs but have since been + // removed from comboRuntimeConfigSchema. Mirrors the server-side strip list + // in src/app/api/combos/[id]/route.ts so the modal never re-introduces them + // when the user clicks Save. See #4382 (combo update returns 400). + "queueDepth", + "fallbackDelayMs", + "handoffProviders", + "maxComboDepth", + "manifestRouting", + "complexityAwareRouting", + "pipeline_enabled", + "pipelineConcurrency", + "shadowRouting", + "evalRouting", + "resetAwareEnabled", + "resetAwareWindow", ]); const MS_PER_SECOND = 1000; diff --git a/src/app/(dashboard)/dashboard/context/CompressionStylesTile.tsx b/src/app/(dashboard)/dashboard/context/CompressionStylesTile.tsx new file mode 100644 index 0000000000..98a79a916d --- /dev/null +++ b/src/app/(dashboard)/dashboard/context/CompressionStylesTile.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; + +interface Summary { + totalRuns: number; + totalTokensSaved: number; + runsWithStyles: number; + bypassCount: number; + totalOutputTokens: number; + appliedStyleCounts: Record; +} + +const EMPTY: Summary = { + totalRuns: 0, + totalTokensSaved: 0, + runsWithStyles: 0, + bypassCount: 0, + totalOutputTokens: 0, + appliedStyleCounts: {}, +}; + +export default function CompressionStylesTile() { + const t = useTranslations("settings"); + const [summary, setSummary] = useState

(EMPTY); + + useEffect(() => { + fetch("/api/settings/compression/run-telemetry") + .then((r) => (r.ok ? r.json() : null)) + .then((data: Summary | null) => { + if (data) setSummary(data); + }) + .catch(() => {}); + }, []); + + const styles = Object.entries(summary.appliedStyleCounts); + + return ( +
+

{t("compressionStylesTileTitle")}

+

+ {summary.totalTokensSaved.toLocaleString("en-US", { useGrouping: false })} +

+

tokens saved · {summary.runsWithStyles} runs styled

+
+ {styles.length === 0 ? ( + No styled runs yet. + ) : ( + styles.map(([id, count]) => ( + + {id} · {count} + + )) + )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx b/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx index 4838aa89c1..192ba9f39b 100644 --- a/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx +++ b/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx @@ -13,7 +13,7 @@ import Link from "next/link"; import { useEffect, useState } from "react"; -import { useTranslations } from "next-intl"; +import { useTranslations, useLocale } from "next-intl"; // Import Card/Toggle from their direct module paths rather than the @/shared/components // barrel: the barrel transitively pulls a heavy/Node-only module that hangs the // vitest/jsdom component test. Direct imports resolve identically under Next.js. @@ -23,7 +23,16 @@ import { ENGINE_IDS, engineMeta, } from "../../../../../../open-sse/services/compression/engineCatalog.ts"; +import { + OUTPUT_STYLE_IDS, + 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"; @@ -45,6 +54,17 @@ interface CompressionConfig { engines: Record; activeComboId: string | null; cavemanOutputMode?: CavemanOutputModeConfig; + outputStyles?: Array<{ id: string; level: CavemanIntensity }>; + // Phase 4 (B): two-tier `ultra` mode controls. + // ultraEngine "heuristic" = Tier-A token pruner (default, byte-identical to pre-B); + // "slm" = Tier-B LLMLingua-2 ONNX worker when available, else fail-open to Tier-A. + 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"]; @@ -56,6 +76,9 @@ const DEFAULT_CONFIG: CompressionConfig = { engines: {}, activeComboId: null, cavemanOutputMode: { enabled: false, intensity: "full", autoClarity: true }, + outputStyles: [], + ultraEngine: "heuristic", + ultraSlmPrewarm: false, }; function normalizeEngines(raw: unknown): Record { @@ -70,6 +93,9 @@ function normalizeEngines(raw: unknown): Record { export default function CompressionPanel() { const t = useTranslations("settings"); + // D-A6/§7: locale-gated styles (e.g. terse-cjk → zh) are only OFFERED under their locale. + // Compare the UI language base ("zh-CN" → "zh") against the style's `locale`. + const uiLang = (useLocale() || "en").split("-")[0]; const [config, setConfig] = useState(DEFAULT_CONFIG); const [mcpAccessibility, setMcpAccessibility] = useState(true); const [loading, setLoading] = useState(true); @@ -86,6 +112,7 @@ export default function CompressionPanel() { ...data, engines: normalizeEngines(data.engines), cavemanOutputMode: data.cavemanOutputMode ?? DEFAULT_CONFIG.cavemanOutputMode, + outputStyles: data.outputStyles ?? DEFAULT_CONFIG.outputStyles, }); } }) @@ -135,12 +162,24 @@ export default function CompressionPanel() { save({ engines }); }; - const setCavemanOutput = (patch: Partial) => { - const cavemanOutputMode: CavemanOutputModeConfig = { - ...(config.cavemanOutputMode ?? DEFAULT_CONFIG.cavemanOutputMode!), - ...patch, - }; - save({ cavemanOutputMode }); + const setOutputStyle = (id: string, patch: { enabled?: boolean; level?: CavemanIntensity }) => { + const current = config.outputStyles ?? []; + const existing = current.find((s) => s.id === id); + let next = current; + if (patch.enabled === false) { + next = current.filter((s) => s.id !== id); + } else { + const level = patch.level ?? existing?.level ?? "full"; + next = existing + ? current.map((s) => (s.id === id ? { id, level } : s)) + : [...current, { id, level }]; + } + // Persist in catalog order so injection order is stable. + const ordered = OUTPUT_STYLE_IDS.flatMap((sid) => { + const hit = next.find((s) => s.id === sid); + return hit ? [hit] : []; + }); + save({ outputStyles: ordered }); }; const toggleMcpAccessibility = async (enabled: boolean) => { @@ -218,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) => { @@ -273,40 +320,105 @@ export default function CompressionPanel() { })}
- {/* cavemanOutput — response-output instruction injection (separate from the input engine) */} -
+ {/* Output Styles — response-output instruction injection (Phase 4A, catalog-driven) */} +

- {t("compressionSettingsCavemanOutputMode")} + {t("compressionSettingsOutputStyles")}

- Injects terse response instructions without rewriting provider output. + Inject response-shaping instructions without rewriting provider output. Combine freely.

-
- - - setCavemanOutput({ enabled })} - disabled={saving} - ariaLabel={t("compressionSettingsCavemanOutputMode")} - /> + {OUTPUT_STYLE_IDS.filter((id) => { + const m = outputStyleMeta(id); + return !m?.locale || m.locale === uiLang; + }).map((id) => { + const meta = outputStyleMeta(id); + const sel = config.outputStyles?.find((s) => s.id === id); + return ( +
+
+

{meta.label}

+ {meta.description && ( +

{meta.description}

+ )} +
+
+ + + setOutputStyle(id, { enabled })} + disabled={saving} + ariaLabel={meta.label} + /> + +
+
+ ); + })} +
+ + {/* Ultra SLM tier — Phase 4 (B): pick the `ultra`-mode engine (heuristic Tier-A + or the opt-in LLMLingua-2 SLM Tier-B) + best-effort pre-warm. */} +
+
+ + + + {config.ultraEngine === "slm" && ( + <> +

{t("compressionUltraSlmHint")}

+ + + )}
{/* mcpAccessibility — writes its own endpoint / separate store */} 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/src/app/(dashboard)/dashboard/context/settings/page.tsx b/src/app/(dashboard)/dashboard/context/settings/page.tsx index 78cdf1a77b..6550899d4b 100644 --- a/src/app/(dashboard)/dashboard/context/settings/page.tsx +++ b/src/app/(dashboard)/dashboard/context/settings/page.tsx @@ -1,11 +1,14 @@ "use client"; import CompressionPanel from "./CompressionPanel"; +import CompressionStylesTile from "../CompressionStylesTile"; export default function CompressionSettingsPage() { return ( -
+
+ {/* D0: read-only telemetry tile (output-token savings + applied styles) */} +
); } diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index 8a2567af1b..e0f56500cd 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -22,6 +22,7 @@ import { filterConfiguredProviderEntries, shouldFilterProviderEntriesForDisplayMode, shouldShowFirstProviderHint, + upsertProviderNodeById, } from "./providerPageUtils"; import type { ProviderEntry } from "./providerPageUtils"; import { @@ -1745,7 +1746,7 @@ export default function ProvidersPage() { mode="openai" onClose={() => setShowAddCompatibleModal(false)} onCreated={(node) => { - setProviderNodes((prev) => [...prev, node]); + setProviderNodes((prev) => upsertProviderNodeById(prev, node)); setShowAddCompatibleModal(false); router.push(`/dashboard/providers/${node.id}`); }} @@ -1755,7 +1756,7 @@ export default function ProvidersPage() { mode="anthropic" onClose={() => setShowAddAnthropicCompatibleModal(false)} onCreated={(node) => { - setProviderNodes((prev) => [...prev, node]); + setProviderNodes((prev) => upsertProviderNodeById(prev, node)); setShowAddAnthropicCompatibleModal(false); router.push(`/dashboard/providers/${node.id}`); }} @@ -1767,7 +1768,7 @@ export default function ProvidersPage() { title={addCcCompatibleLabel} onClose={() => setShowAddCcCompatibleModal(false)} onCreated={(node) => { - setProviderNodes((prev) => [...prev, node]); + setProviderNodes((prev) => upsertProviderNodeById(prev, node)); setShowAddCcCompatibleModal(false); router.push(`/dashboard/providers/${node.id}`); }} diff --git a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts index 32da435927..72184b1769 100644 --- a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts @@ -276,3 +276,24 @@ export function resolveDashboardProviderInfo( ): ResolvedProviderCatalogEntry | null { return resolveProviderCatalogEntry(providerId, options); } + +/** + * Append or replace a provider node by `id`, never appending a duplicate (#4746). + * + * The compatible-provider "add" modals previously did `setProviderNodes((prev) => [...prev, node])`, + * so adding the same provider twice (refresh-then-add, double-click, retry, or React StrictMode + * double-invocation in dev) left the same `id` in the array twice — surfacing duplicate cards and + * invalidating the `compatibleProviderGroups` memo on every no-op add. This upsert dedups by id: + * - new id → append a new array, + * - same id, deep-equal payload → return `prev` unchanged (stable identity ⇒ memo does not re-run), + * - same id, changed payload → replace in place. + */ +export function upsertProviderNodeById(prev: T[], node: T): T[] { + if (!node || node.id == null) return [...prev, node]; + const idx = prev.findIndex((p) => p?.id === node.id); + if (idx === -1) return [...prev, node]; + if (JSON.stringify(prev[idx]) === JSON.stringify(node)) return prev; + const next = prev.slice(); + next[idx] = node; + return next; +} diff --git a/src/app/api/combos/[id]/route.ts b/src/app/api/combos/[id]/route.ts index 4c25beda01..22dff324e4 100644 --- a/src/app/api/combos/[id]/route.ts +++ b/src/app/api/combos/[id]/route.ts @@ -18,6 +18,47 @@ import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { buildErrorBody } from "@omniroute/open-sse/utils/error"; import { QUOTA_MODEL_PREFIX } from "@/lib/quota/quotaModelNaming"; +/** + * Keys that were present in older combo configs (≤ v3.8.31) but have since been + * removed from comboRuntimeConfigSchema. The dashboard modal sanitises the three + * UI-level keys (timeoutMs, healthCheckEnabled, healthCheckTimeoutMs) before PUT, + * but v3.8.31-era stored configs also carry these 12 keys which were spread back + * into the body on edit+save. We strip them server-side so removed keys don't + * accumulate in `combos.data` and so the next read produces a clean config. + * + * Idempotent — running twice is a no-op. + */ +const LEGACY_REMOVED_COMBO_CONFIG_KEYS = Object.freeze([ + "queueDepth", + "fallbackDelayMs", + "handoffProviders", + "maxComboDepth", + "manifestRouting", + "complexityAwareRouting", + "pipeline_enabled", + "pipelineConcurrency", + "shadowRouting", + "evalRouting", + "resetAwareEnabled", + "resetAwareWindow", +]); + +function stripLegacyComboConfigKeys(rawConfig) { + if (!rawConfig || typeof rawConfig !== "object" || Array.isArray(rawConfig)) { + return rawConfig; + } + let mutated = false; + const next = {}; + for (const [key, value] of Object.entries(rawConfig)) { + if (LEGACY_REMOVED_COMBO_CONFIG_KEYS.includes(key)) { + mutated = true; + continue; + } + next[key] = value; + } + return mutated ? next : rawConfig; +} + // GET /api/combos/[id] - Get combo by ID export async function GET(request, { params }) { const authError = await requireManagementAuth(request); @@ -97,6 +138,9 @@ export async function PUT(request, { params }) { normalizedUpdate.config = nextConfig; delete normalizedUpdate.compressionOverride; } + if (normalizedUpdate.config && typeof normalizedUpdate.config === "object") { + normalizedUpdate.config = stripLegacyComboConfigKeys(normalizedUpdate.config); + } const body = normalizedUpdate.models ? { diff --git a/src/app/api/db-backups/import/route.ts b/src/app/api/db-backups/import/route.ts index 12ee0c678a..b118e83d7b 100644 --- a/src/app/api/db-backups/import/route.ts +++ b/src/app/api/db-backups/import/route.ts @@ -11,7 +11,30 @@ import { getSettings } from "@/lib/db/settings"; import { setSystemPromptConfig } from "@omniroute/open-sse/services/systemPrompt.ts"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; -const MAX_UPLOAD_SIZE = 100 * 1024 * 1024; // 100 MB +const DEFAULT_MAX_UPLOAD_MB = 100; +// Hard ceiling so a misconfigured/hostile value can't ask the route to buffer an +// unbounded file into memory. +const MAX_UPLOAD_MB_CEILING = 4096; + +/** + * Resolve the maximum accepted backup size (bytes) from the environment. + * + * Real databases bloat well past the historical 100 MB cap (#4719 — a 156 MB file that + * VACUUMs down to 5 MB still can't be re-imported), so the limit is now operator-tunable + * via `OMNIROUTE_DB_IMPORT_MAX_MB`. Invalid / out-of-range values fall back to the 100 MB + * default and are clamped to a 4 GB ceiling. + */ +export function resolveMaxUploadSizeBytes( + env: NodeJS.ProcessEnv = process.env +): number { + const raw = env.OMNIROUTE_DB_IMPORT_MAX_MB; + const parsed = raw === undefined ? NaN : Number(raw); + const mb = + Number.isFinite(parsed) && parsed >= 1 + ? Math.min(Math.floor(parsed), MAX_UPLOAD_MB_CEILING) + : DEFAULT_MAX_UPLOAD_MB; + return mb * 1024 * 1024; +} // Required tables that must exist in a valid OmniRoute database const REQUIRED_TABLES = ["provider_connections", "provider_nodes", "combos", "api_keys"]; @@ -68,10 +91,15 @@ export async function POST(request: Request) { } // Validate file size + const maxUploadSize = resolveMaxUploadSizeBytes(); const fileSize = fileBuffer.length; - if (fileSize > MAX_UPLOAD_SIZE) { + if (fileSize > maxUploadSize) { return NextResponse.json( - { error: `File too large. Maximum allowed size is ${MAX_UPLOAD_SIZE / (1024 * 1024)} MB.` }, + { + error: + `File too large. Maximum allowed size is ${maxUploadSize / (1024 * 1024)} MB. ` + + `Set OMNIROUTE_DB_IMPORT_MAX_MB to raise it, or VACUUM the database before exporting.`, + }, { status: 400 } ); } diff --git a/src/app/api/docs/route.ts b/src/app/api/docs/route.ts new file mode 100644 index 0000000000..978449f1d1 --- /dev/null +++ b/src/app/api/docs/route.ts @@ -0,0 +1,89 @@ +/** + * GET /api/docs — Rendered API reference (Redoc UI). + * + * Serves an HTML page that loads Redoc from a CDN and points it at + * `/openapi.yaml` (the same canonical spec maintained at + * `docs/openapi.yaml` in the repo). The CDN dependency is documented in + * `docs/architecture/standalone-renderer-strategy.md`; if the deployment + * is air-gapped, mirror the Redoc assets under `public/vendor/redoc/` + * and update the + + + +`; + +export function GET() { + return new Response(REDOC_HTML, { + status: 200, + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "public, max-age=300, s-maxage=300", + "X-Robots-Tag": "noindex", + }, + }); +} diff --git a/src/app/api/openapi/spec/route.ts b/src/app/api/openapi/spec/route.ts index a4092bd143..37c5a9daf8 100644 --- a/src/app/api/openapi/spec/route.ts +++ b/src/app/api/openapi/spec/route.ts @@ -11,11 +11,12 @@ import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; let cachedSpec: { data: any; mtime: number } | null = null; const OPENAPI_SPEC_CANDIDATES = [ - path.join(/* turbopackIgnore: true */ process.cwd(), "docs", "reference", "openapi.yaml"), - path.join(/* turbopackIgnore: true */ process.cwd(), "app", "docs", "reference", "openapi.yaml"), - // Legacy locations kept as fallback for old standalone bundles. path.join(/* turbopackIgnore: true */ process.cwd(), "docs", "openapi.yaml"), path.join(/* turbopackIgnore: true */ process.cwd(), "app", "docs", "openapi.yaml"), + // Legacy locations kept as fallback for old standalone bundles (pre-#4781 move + // from docs/reference/openapi.yaml to the canonical docs/openapi.yaml). + path.join(/* turbopackIgnore: true */ process.cwd(), "docs", "reference", "openapi.yaml"), + path.join(/* turbopackIgnore: true */ process.cwd(), "app", "docs", "reference", "openapi.yaml"), ]; /** diff --git a/src/app/api/settings/compression/run-telemetry/route.ts b/src/app/api/settings/compression/run-telemetry/route.ts new file mode 100644 index 0000000000..04288d713c --- /dev/null +++ b/src/app/api/settings/compression/run-telemetry/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { getCompressionRunTelemetrySummary } from "@/lib/db/compressionRunTelemetry"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const summary = getCompressionRunTelemetrySummary(); + return NextResponse.json(summary); + } catch { + return NextResponse.json( + { + totalRuns: 0, + totalTokensSaved: 0, + runsWithStyles: 0, + bypassCount: 0, + totalOutputTokens: 0, + appliedStyleCounts: {}, + }, + { status: 200 } + ); + } +} diff --git a/src/app/docs/lib/openapi.generated.ts b/src/app/docs/lib/openapi.generated.ts index 3681b7770c..7f0053095e 100644 --- a/src/app/docs/lib/openapi.generated.ts +++ b/src/app/docs/lib/openapi.generated.ts @@ -1,7 +1,7 @@ // AUTO-GENERATED by scripts/docs/gen-openapi-module.mjs — DO NOT EDIT MANUALLY // Regenerate with: node scripts/docs/gen-openapi-module.mjs // -// Source of truth: docs/reference/openapi.yaml +// Source of truth: docs/openapi.yaml // // The Api Explorer consumes `OPENAPI_ENDPOINTS`; the spec metadata // (`OPENAPI_VERSION`, `OPENAPI_TITLE`) is surfaced in the page header. @@ -25,7 +25,7 @@ export interface OpenApiEndpoint { hasRequestBody: boolean; } -export const OPENAPI_VERSION = "3.8.24"; +export const OPENAPI_VERSION = "3.8.35"; export const OPENAPI_TITLE = "OmniRoute API"; export const OPENAPI_ENDPOINTS: OpenApiEndpoint[] = [ @@ -173,8 +173,7 @@ export const OPENAPI_ENDPOINTS: OpenApiEndpoint[] = [ path: "/api/v1/providers/{provider}/models", method: "GET", summary: "List models for a specific provider", - description: - "Returns only models for the selected provider with provider prefix removed from each model id.", + description: "Returns only models for the selected provider with provider prefix removed from each model id.", tag: "Models", tags: ["Models"], requiresAuth: true, @@ -200,6 +199,16 @@ export const OPENAPI_ENDPOINTS: OpenApiEndpoint[] = [ requiresAuth: true, hasRequestBody: true, }, + { + path: "/api/v1/ws", + method: "GET", + summary: "Chat completion over WebSocket (handshake + upgrade)", + description: "OpenAI-compatible chat over a WebSocket connection. `GET` with `?handshake=1` returns the connection descriptor (auth path, message protocol and live-event channels) as JSON; a plain `GET` without an Upgrade returns `426 Upgrade Required`. After upgrading, the client exchanges JSON frames — `{type:\"request\", id, payload:{model, messages}}` to start a completion and `{type:\"cancel\", id}` to abort it. A separate live channel (default port `LIVE_WS_PORT=20129`, path `/live`) streams dashboard events on the `requests`, `combo` and `credentials` topics with a 15s heartbeat. Requires an API key.", + tag: "Chat", + tags: ["Chat"], + requiresAuth: true, + hasRequestBody: false, + }, { path: "/api/v1beta/models", method: "GET", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 6d650df399..beb013043f 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -3816,6 +3816,7 @@ }, "onboarding": { "welcome": "Welcome", + "tiers": "Tiers", "security": "Security", "test": "Test", "ready": "Ready!", @@ -5594,6 +5595,11 @@ "compressionUltraMinScore": "Minimum Score Threshold", "compressionUltraSlmFallback": "Fallback to Aggressive", "compressionUltraModelPath": "SLM Model Path", + "compressionUltraEngine": "Ultra tier", + "compressionUltraEngineHeuristic": "Heuristic (Tier-A, default)", + "compressionUltraEngineSlm": "SLM (LLMLingua-2, opt-in)", + "compressionUltraSlmHint": "SLM downloads a small ONNX model on first use (cold-start) and transparently falls back to the heuristic on timeout or if unavailable.", + "compressionUltraSlmPrewarm": "Pre-warm SLM model on enable", "compressionSummarizerEnabled": "Enable Summarizer", "compressionMaxTokensPerMessage": "Max Tokens Per Message", "compressionMinSavings": "Min Savings Threshold", @@ -5818,6 +5824,8 @@ "mcpAccessibilityTitle": "MCP accessibility output", "compressionSettingsCavemanIntensity": "Caveman intensity", "compressionSettingsCavemanOutputMode": "Caveman output mode", + "compressionSettingsOutputStyles": "Output styles", + "compressionStylesTileTitle": "Output styles", "compressionSettingsOutputIntensity": "Output intensity", "compressionSettingsAutoClarityBypass": "Auto clarity bypass", "resilienceWaitForCooldown": "Wait for Cooldown", diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index 2a2ee6241a..32debc0f35 100755 --- a/src/instrumentation-node.ts +++ b/src/instrumentation-node.ts @@ -239,10 +239,8 @@ export async function registerNodejs(): Promise { await import("@/lib/db/core").then(({ ensureDbInitialized }) => ensureDbInitialized()); - // Scheduled VACUUM (#4437): the previous compressionScheduler.ts was orphaned - // (read the wrong settings namespace, never imported anywhere). This call wires - // the new vacuumScheduler into the lifecycle: registers the timer and persists - // lastVacuumAt to the key_value table so the UI's "Last vacuum" card can read it. + // Storage-configured scheduled VACUUM (#4437): registers the timer from + // Settings > System & Storage and persists lastVacuumAt for the UI. try { const { initVacuumScheduler } = await import("@/lib/db/vacuumScheduler"); initVacuumScheduler(); diff --git a/src/lib/agentSkills/generator.ts b/src/lib/agentSkills/generator.ts index 23b3a10955..96836a5bd0 100644 --- a/src/lib/agentSkills/generator.ts +++ b/src/lib/agentSkills/generator.ts @@ -124,7 +124,7 @@ function buildApiBody(skill: AgentSkill, sources: BuildSources): string { lines.push("## Payloads\n"); lines.push( "See the full OpenAPI specification at `GET /api/openapi/spec` or " + - "`docs/reference/openapi.yaml` for detailed request/response schemas.", + "`docs/openapi.yaml` for detailed request/response schemas.", ); lines.push(""); diff --git a/src/lib/agentSkills/openapiParser.ts b/src/lib/agentSkills/openapiParser.ts index c6cc0648fb..25fa40a912 100644 --- a/src/lib/agentSkills/openapiParser.ts +++ b/src/lib/agentSkills/openapiParser.ts @@ -1,5 +1,5 @@ /** - * openapiParser.ts — parses docs/reference/openapi.yaml to extract endpoint info + * openapiParser.ts — parses docs/openapi.yaml to extract endpoint info * grouped by SkillArea. Used by the catalog and the generator. * * Reads the OpenAPI YAML synchronously at runtime (same pattern as @@ -139,7 +139,7 @@ function extractOperations(pathsObj: Record): OpenapiPath[] { } /** - * Parses `docs/reference/openapi.yaml` and returns: + * Parses `docs/openapi.yaml` and returns: * - `paths`: all operations keyed by `"METHOD /path"` * - `areas`: operations grouped by SkillArea (api skills only) * @@ -147,7 +147,7 @@ function extractOperations(pathsObj: Record): OpenapiPath[] { * and standalone scripts without async machinery. */ export function parseOpenapi(): ParsedOpenapi { - const yamlPath = path.resolve(process.cwd(), "docs", "reference", "openapi.yaml"); + const yamlPath = path.resolve(process.cwd(), "docs", "openapi.yaml"); let rawContent: string; try { diff --git a/src/lib/db/compression.ts b/src/lib/db/compression.ts index 7c9fa76526..75f8c18027 100644 --- a/src/lib/db/compression.ts +++ b/src/lib/db/compression.ts @@ -17,6 +17,7 @@ import { type AggressiveConfig, type CavemanConfig, type CavemanOutputModeConfig, + type OutputStyleSelectionEntry, type CompressionLanguageConfig, type CompressionPipelineStep, type CompressionConfig, @@ -27,6 +28,7 @@ import { type RtkConfig, type UltraConfig, } from "@omniroute/open-sse/services/compression/types.ts"; +import { maybePrewarmUltraSlmOnConfig } from "@omniroute/open-sse/services/compression/ultra.ts"; const NAMESPACE = "compression"; const COMPRESSION_MODES = new Set([ @@ -47,6 +49,12 @@ let compressionSettingsCache: { dbRef: WeakRef; } | null = null; +// Phase 4 (B): one cold-start SLM pre-warm attempt per process. The save path fires +// on every enable transition; this guard keeps the read path from re-warming on every +// cache miss (the read path runs at most once per 5s, but a cold start should warm once, +// not repeatedly). Best-effort either way (`maybePrewarmUltraSlmOnConfig` never throws). +let _ultraSlmColdPrewarmAttempted = false; + function toRecord(value: unknown): JsonRecord { return value && typeof value === "object" ? (value as JsonRecord) : {}; } @@ -108,6 +116,21 @@ function normalizeCavemanOutputModeConfig(value: unknown): CavemanOutputModeConf }; } +function normalizeOutputStyleSelection(value: unknown): OutputStyleSelectionEntry[] { + if (!Array.isArray(value)) return []; + const out: OutputStyleSelectionEntry[] = []; + for (const raw of value) { + const record = toRecord(raw); + const id = typeof record.id === "string" ? record.id.trim() : ""; + const level = + record.level === "lite" || record.level === "full" || record.level === "ultra" + ? record.level + : null; + if (id && level) out.push({ id, level }); + } + return out; +} + function normalizeRtkConfig(value: unknown): RtkConfig { const record = toRecord(value); return { @@ -512,6 +535,7 @@ export async function getCompressionSettings(): Promise { ...DEFAULT_COMPRESSION_CONFIG, cavemanConfig: { ...DEFAULT_CAVEMAN_CONFIG }, cavemanOutputMode: { ...DEFAULT_CAVEMAN_OUTPUT_MODE_CONFIG }, + outputStyles: [], rtkConfig: { ...DEFAULT_RTK_CONFIG }, languageConfig: { ...DEFAULT_COMPRESSION_LANGUAGE_CONFIG }, stackedPipeline: normalizeStackedPipeline(undefined), @@ -590,6 +614,9 @@ export async function getCompressionSettings(): Promise { case "cavemanOutputMode": config.cavemanOutputMode = normalizeCavemanOutputModeConfig(parsed); break; + case "outputStyles": + config.outputStyles = normalizeOutputStyleSelection(parsed); + break; case "rtkConfig": config.rtkConfig = normalizeRtkConfig(parsed); break; @@ -614,6 +641,14 @@ export async function getCompressionSettings(): Promise { config.activeComboId = typeof parsed === "string" && parsed.trim() ? parsed.trim() : null; break; + case "ultraEngine": + // Phase 4 (B): SLM tier selector. Only the two known values; anything else + // falls back to the heuristic default so a malformed row can never enable SLM. + config.ultraEngine = parsed === "slm" ? "slm" : "heuristic"; + break; + case "ultraSlmPrewarm": + config.ultraSlmPrewarm = parsed === true; + break; } } @@ -638,6 +673,17 @@ export async function getCompressionSettings(): Promise { dbRef: new WeakRef(db), }; + // Phase 4 (B): cold-restart pre-warm — when the stored config already selects the SLM + // tier with pre-warm on, warm the model once (best-effort, fire-and-forget, guarded so + // a frequently-hit read path warms at most once per process). Cache hits return above. + if (!_ultraSlmColdPrewarmAttempted) { + _ultraSlmColdPrewarmAttempted = true; + void maybePrewarmUltraSlmOnConfig({ + ultraEngine: config.ultraEngine, + ultraSlmPrewarm: config.ultraSlmPrewarm, + }); + } + return config; } @@ -666,7 +712,14 @@ export async function updateCompressionSettings( backupDbFile("pre-write"); compressionSettingsCache = null; invalidateDbCache(); - return getCompressionSettings(); + const next = await getCompressionSettings(); + // Phase 4 (B): the SAVE path covers the enable transition — if this write turns the + // SLM tier + pre-warm on, warm the model once (best-effort, fire-and-forget). + void maybePrewarmUltraSlmOnConfig({ + ultraEngine: next.ultraEngine, + ultraSlmPrewarm: next.ultraSlmPrewarm, + }); + return next; } function normalizeMcpAccessibilityConfig(value: unknown): McpAccessibilityConfig { diff --git a/src/lib/db/compressionRunTelemetry.ts b/src/lib/db/compressionRunTelemetry.ts new file mode 100644 index 0000000000..ecddc62a2f --- /dev/null +++ b/src/lib/db/compressionRunTelemetry.ts @@ -0,0 +1,126 @@ +import { getDbInstance } from "./core"; + +export interface CompressionRunTelemetryInput { + requestId: string; + model: string; + provider: string; + source: string; + tokensBefore: number; + tokensAfter: number; + ratio: number; + costDelta?: number; + outputStyles?: Array<{ id: string; level: "lite" | "full" | "ultra" }>; + outputStyleBypass?: string; + outputTokens?: number; +} + +export interface CompressionRunTelemetrySummary { + totalRuns: number; + totalTokensSaved: number; + runsWithStyles: number; + bypassCount: number; + totalOutputTokens: number; + appliedStyleCounts: Record; +} + +function ensureCompressionRunTelemetryTable(): void { + const db = getDbInstance(); + // `CREATE TABLE IF NOT EXISTS` is idempotent and cheap; run it unconditionally so the + // table self-heals if it was dropped (e.g. test isolation) under the same db handle. + db.exec(` + CREATE TABLE IF NOT EXISTS compression_run_telemetry ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER NOT NULL, + request_id TEXT, + model TEXT, + provider TEXT, + source TEXT, + tokens_before INTEGER NOT NULL, + tokens_after INTEGER NOT NULL, + ratio REAL, + cost_delta REAL, + output_styles TEXT, + output_style_bypass TEXT, + output_tokens INTEGER + ) + `); +} + +/** + * Persist one CompressionRunTelemetry record (D0). Best-effort and off the hot path: + * the `timestamp` is stamped here (never inside the pure resolvers). Mirrors the + * compression-stats / compressionAnalytics recording discipline — never throws into a request. + */ +export function insertCompressionRunTelemetryRow(row: CompressionRunTelemetryInput): void { + try { + const db = getDbInstance(); + ensureCompressionRunTelemetryTable(); + db.prepare( + `INSERT INTO compression_run_telemetry ( + timestamp, request_id, model, provider, source, + tokens_before, tokens_after, ratio, cost_delta, + output_styles, output_style_bypass, output_tokens + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + Date.now(), + row.requestId ?? null, + row.model ?? null, + row.provider ?? null, + row.source ?? null, + row.tokensBefore, + row.tokensAfter, + row.ratio, + row.costDelta ?? null, + row.outputStyles && row.outputStyles.length > 0 ? JSON.stringify(row.outputStyles) : null, + row.outputStyleBypass ?? null, + row.outputTokens ?? null + ); + } catch { + // best-effort telemetry — a write failure never affects a request + } +} + +export function getCompressionRunTelemetrySummary(): CompressionRunTelemetrySummary { + const db = getDbInstance(); + ensureCompressionRunTelemetryTable(); + const rows = db + .prepare( + `SELECT tokens_before, tokens_after, output_styles, output_style_bypass, output_tokens + FROM compression_run_telemetry` + ) + .all() as Array<{ + tokens_before: number; + tokens_after: number; + output_styles: string | null; + output_style_bypass: string | null; + output_tokens: number | null; + }>; + + const summary: CompressionRunTelemetrySummary = { + totalRuns: rows.length, + totalTokensSaved: 0, + runsWithStyles: 0, + bypassCount: 0, + totalOutputTokens: 0, + appliedStyleCounts: {}, + }; + + for (const row of rows) { + summary.totalTokensSaved += Math.max(0, row.tokens_before - row.tokens_after); + summary.totalOutputTokens += row.output_tokens ?? 0; + if (row.output_style_bypass) summary.bypassCount += 1; + if (row.output_styles) { + summary.runsWithStyles += 1; + try { + const styles = JSON.parse(row.output_styles) as Array<{ id: string }>; + for (const style of styles) { + summary.appliedStyleCounts[style.id] = + (summary.appliedStyleCounts[style.id] ?? 0) + 1; + } + } catch { + // ignore a corrupt JSON cell + } + } + } + return summary; +} diff --git a/src/lib/db/databaseSettings.ts b/src/lib/db/databaseSettings.ts index bae2e8c005..567ffcf5ba 100644 --- a/src/lib/db/databaseSettings.ts +++ b/src/lib/db/databaseSettings.ts @@ -6,7 +6,7 @@ import { backupDbFile } from "./backup"; import { DATA_DIR, SQLITE_FILE, getDbInstance } from "./core"; import { invalidateDbCache } from "./readCache"; import { getDatabaseStats } from "./stats"; -import { getState as getVacuumSchedulerState } from "./vacuumScheduler"; +import { getState as getVacuumSchedulerState, refreshVacuumScheduler } from "./vacuumScheduler"; const DATABASE_SETTINGS_NAMESPACE = "databaseSettings"; @@ -240,7 +240,8 @@ export function getDatabaseSettings(): DatabaseSettings { databaseSizeBytes: dbStats.totalSize, pageCount: dbStats.pageCount, freelistCount: getFreelistCount(), - lastVacuumAt: vacuumState.lastRunAt !== null ? new Date(vacuumState.lastRunAt).toISOString() : null, + lastVacuumAt: + vacuumState.lastRunAt !== null ? new Date(vacuumState.lastRunAt).toISOString() : null, lastOptimizationAt: null, integrityCheck: getIntegrityCheck(), }, @@ -251,6 +252,7 @@ export function updateDatabaseSettings( updates: Partial ): UserDatabaseSettings { const nextSettings = getUserDatabaseSettings(); + const optimizationUpdated = updates.optimization !== undefined; for (const section of DATABASE_SETTINGS_SECTIONS) { if (updates[section] !== undefined) { @@ -287,6 +289,7 @@ export function updateDatabaseSettings( backupDbFile("pre-write"); invalidateDbCache("settings"); + if (optimizationUpdated) refreshVacuumScheduler(); return nextSettings; } diff --git a/src/lib/db/migrations/103_strip_legacy_combo_config_keys.sql b/src/lib/db/migrations/103_strip_legacy_combo_config_keys.sql new file mode 100644 index 0000000000..7a702dd122 --- /dev/null +++ b/src/lib/db/migrations/103_strip_legacy_combo_config_keys.sql @@ -0,0 +1,54 @@ +-- 103_strip_legacy_combo_config_keys.sql +-- One-shot sweep over `combos.data` to remove v3.8.31-era config keys that were +-- subsequently dropped from comboRuntimeConfigSchema. Without this sweep, a +-- combo created on ≤ v3.8.31 still carries the legacy keys in its persisted +-- JSON; on the next edit+save the modal spreads the existing config back into +-- the PUT body, and comboRuntimeConfigSchema.strict() rejects the unknown +-- keys with a 400. See diegosouzapw/OmniRoute#4382. +-- +-- Belt-and-suspenders: +-- - src/shared/validation/schemas/combo.ts now uses .passthrough() so the +-- server accepts unknown legacy keys during the upgrade window +-- - src/app/api/combos/[id]/route.ts strips the same keys before persistence +-- so new writes are clean +-- - src/app/(dashboard)/dashboard/combos/page.tsx strips them client-side +-- +-- This migration handles pre-existing rows. It is idempotent: running it again +-- on a clean DB no-ops because json_remove on a missing path is a no-op, and +-- the WHERE clause skips rows that don't carry any of the legacy keys. + +-- Strip the 12 known removed keys from any persisted combo config. +UPDATE combos +SET data = json_remove( + data, + '$.config.queueDepth', + '$.config.fallbackDelayMs', + '$.config.handoffProviders', + '$.config.maxComboDepth', + '$.config.manifestRouting', + '$.config.complexityAwareRouting', + '$.config.pipeline_enabled', + '$.config.pipelineConcurrency', + '$.config.shadowRouting', + '$.config.evalRouting', + '$.config.resetAwareEnabled', + '$.config.resetAwareWindow' +) +WHERE EXISTS ( + SELECT 1 + FROM json_each(data, '$.config') AS cfg + WHERE cfg.key IN ( + 'queueDepth', + 'fallbackDelayMs', + 'handoffProviders', + 'maxComboDepth', + 'manifestRouting', + 'complexityAwareRouting', + 'pipeline_enabled', + 'pipelineConcurrency', + 'shadowRouting', + 'evalRouting', + 'resetAwareEnabled', + 'resetAwareWindow' + ) +); \ No newline at end of file diff --git a/src/lib/db/tierConfig.ts b/src/lib/db/tierConfig.ts index 1a83fea0f7..0c1d0e7b06 100644 --- a/src/lib/db/tierConfig.ts +++ b/src/lib/db/tierConfig.ts @@ -1,8 +1,10 @@ import { getDbInstance } from "./core"; import type { TierConfig } from "../../../open-sse/services/tierTypes"; import { validateTierConfig, DEFAULT_TIER_CONFIG } from "../../../open-sse/services/tierConfig"; +import { defaultLogger as log } from "@omniroute/open-sse/utils/logger"; const TABLE = "tier_config"; +const CORRUPTED_VALUE_PREVIEW_LEN = 200; export function initTierConfigTable(): void { const db = getDbInstance(); @@ -23,15 +25,60 @@ export function saveTierConfig(config: TierConfig): void { ).run(serialized); } +/** + * Truncate an unknown value (string) for safe inclusion in a log payload. + * Returns the string verbatim when shorter than the cap, otherwise a + * 200-char preview with an ellipsis. `String(value)` is used as a final + * fallback so a non-string never throws here. + */ +function previewCorruptedValue(value: unknown): string { + if (typeof value !== "string") return String(value); + if (value.length <= CORRUPTED_VALUE_PREVIEW_LEN) return value; + return `${value.slice(0, CORRUPTED_VALUE_PREVIEW_LEN)}…`; +} + +/** + * Load the persisted tier config from SQLite. Returns `null` when no row exists + * OR when the stored value is unreadable (invalid JSON, fails Zod validation). + * + * The function NEVER throws on parse failure — instead it logs a structured + * warning so operators can spot the corruption in logs and either: + * 1. Manually delete the bad row: + * DELETE FROM tier_config WHERE key = 'tier_config'; + * 2. Re-save a clean config via the dashboard's Tier settings page. + * + * The caller (`loadTierConfig()`) then falls back to `DEFAULT_TIER_CONFIG`, + * so a corrupted row never silently feeds invalid pricing into the router. + */ export function loadTierConfigFromDb(): TierConfig | null { const db = getDbInstance(); const row = db.prepare(`SELECT value FROM ${TABLE} WHERE key = 'tier_config'`).get() as | { value: string } | undefined; if (!row) return null; + + const raw = row.value; + let parsed: unknown; try { - return validateTierConfig(JSON.parse(row.value)); - } catch { + parsed = JSON.parse(raw); + } catch (err) { + log.warn( + { err: err instanceof Error ? err.message : String(err), value: previewCorruptedValue(raw) }, + "tier_config JSON.parse failed; falling back to DEFAULT_TIER_CONFIG" + ); + return null; + } + + try { + return validateTierConfig(parsed); + } catch (err) { + log.warn( + { + err: err instanceof Error ? err.message : String(err), + value: previewCorruptedValue(raw), + }, + "tier_config Zod validation failed; falling back to DEFAULT_TIER_CONFIG" + ); return null; } } diff --git a/src/lib/db/vacuumScheduler.ts b/src/lib/db/vacuumScheduler.ts index 99853f30b4..ad0e183556 100644 --- a/src/lib/db/vacuumScheduler.ts +++ b/src/lib/db/vacuumScheduler.ts @@ -1,15 +1,16 @@ +import { DEFAULT_DATABASE_SETTINGS } from "@/types/databaseSettings"; +import { MAX_TIMER_TIMEOUT_MS } from "@/shared/utils/runtimeTimeouts"; + import { getDbInstance } from "./core"; // Direct `key_value` access — the existing `keyValueStore` helpers only exist // in test fixtures; the 3 production call sites (pricingSync, jsonMigration, // serviceModels) all use `getDbInstance().prepare(...).run()` directly. We // follow the same convention to avoid introducing a new abstraction. -const READ_KV_SQL = - "SELECT value FROM key_value WHERE namespace = ? AND key = ? LIMIT 1"; +const READ_KV_SQL = "SELECT value FROM key_value WHERE namespace = ? AND key = ? LIMIT 1"; // The key_value table is (namespace, key, value) — no updated_at column // (see migrations/001_initial_schema.sql). Match the canonical write shape // used by serviceModels.ts / jsonMigration.ts. -const WRITE_KV_SQL = - "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)"; +const WRITE_KV_SQL = "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)"; function setKeyValue(namespace: string, key: string, value: string): void { const db = getDbInstance(); @@ -18,21 +19,19 @@ function setKeyValue(namespace: string, key: string, value: string): void { function getKeyValue(namespace: string, key: string): string | null { const db = getDbInstance(); - const row = db.prepare(READ_KV_SQL).get(namespace, key) as - | { value: string } - | undefined; + const row = db.prepare(READ_KV_SQL).get(namespace, key) as { value: string } | undefined; return row?.value ?? null; } /** * Persisted scheduler state for the SQLite VACUUM loop. * - * Diego's `auto_vacuum` setting turns on SQLite's per-transaction - * incremental vacuum (PRAGMA auto_vacuum = INCREMENTAL), but it does - * NOT itself schedule a full VACUUM. This module is the missing - * scheduler: it kicks off a full VACUUM on a configurable interval, - * persists the result to the `key_value` table, and exposes a - * getState() / runNow() / stop() surface for the API + UI. + * SQLite's `auto_vacuum` pragma controls page reclamation behavior + * inside SQLite itself; it does not schedule full VACUUM runs. This + * module is the app-level scheduler for full VACUUM: it follows the + * Storage page's scheduledVacuum / vacuumHour settings, persists the + * result to the `key_value` table, and exposes a getState() / runNow() / + * stop() surface for the API + UI. * * The previous `compressionScheduler.ts` was orphaned dead code that * read the wrong settings namespace (`compression.*` instead of @@ -49,13 +48,26 @@ export interface VacuumSchedulerState { nextRunAt: number | null; } -const DEFAULT_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h -const MIN_INTERVAL_MS = 60 * 60 * 1000; // 1h — never vacuum more than once an hour +export type ScheduledVacuum = (typeof DEFAULT_DATABASE_SETTINGS)["optimization"]["scheduledVacuum"]; +export type VacuumScheduleSettings = { + scheduledVacuum: ScheduledVacuum; + vacuumHour: number; +}; + +const HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * HOUR_MS; +const NOMINAL_INTERVAL_MS: Record = { + never: 0, + daily: DAY_MS, + weekly: 7 * DAY_MS, + monthly: 30 * DAY_MS, +}; +const VALID_SCHEDULES = new Set(["never", "daily", "weekly", "monthly"]); const KEY_VALUE_NAMESPACE = "scheduler"; const KEY_VALUE_KEY = "vacuum"; const STATE_DEFAULTS: VacuumSchedulerState = { enabled: false, - intervalMs: DEFAULT_INTERVAL_MS, + intervalMs: 0, lastRunAt: null, lastError: null, lastDurationMs: null, @@ -66,44 +78,150 @@ const STATE_DEFAULTS: VacuumSchedulerState = { let timer: ReturnType | null = null; let currentState: VacuumSchedulerState = { ...STATE_DEFAULTS }; -function readIntervalFromSettings(): number { - // Read the canonical `optimization.scheduledVacuumIntervalHours` setting, - // fall back to the env var, then the 24h default. Floor at 1h to prevent - // accidental OOM from too-frequent vacuum loops on a large DB. - const fromEnv = Number.parseInt(process.env.OMNIROUTE_VACUUM_INTERVAL_HOURS ?? "", 10); - if (Number.isFinite(fromEnv) && fromEnv >= 1) { - return Math.max(fromEnv * 60 * 60 * 1000, MIN_INTERVAL_MS); +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function parseJsonSafe(raw: string | null): unknown { + if (raw === null) return undefined; + try { + return JSON.parse(raw); + } catch { + return raw; } - return DEFAULT_INTERVAL_MS; } -function readEnabledFromSettings(): boolean { - // Master switch. Default-on for new installs — the issue is the opposite - // problem (vacuum never runs), not over-vacuuming. To disable, set to 0 - // (or any value other than "1"). Matches the OMNIROUTE_BIFROST_ENABLED - // convention from PR #4433. - const raw = process.env.OMNIROUTE_VACUUM_ENABLED; - if (raw === "0" || raw === "false") return false; - if (raw === "1" || raw === "true") return true; - return true; +function readNamespace(namespace: string): Record { + const db = getDbInstance(); + const rows = db + .prepare("SELECT key, value FROM key_value WHERE namespace = ?") + .all(namespace) as Array<{ key: string; value: string | null }>; + const values: Record = {}; + for (const row of rows) values[row.key] = parseJsonSafe(row.value); + return values; } -function scheduleNext(): void { +function normalizeSchedule(value: unknown, fallback: ScheduledVacuum): ScheduledVacuum { + return typeof value === "string" && VALID_SCHEDULES.has(value as ScheduledVacuum) + ? (value as ScheduledVacuum) + : fallback; +} + +function normalizeVacuumHour(value: unknown, fallback: number): number { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return fallback; + return Math.min(23, Math.max(0, Math.floor(numeric))); +} + +function mergeOptimization(target: VacuumScheduleSettings, value: unknown): VacuumScheduleSettings { + if (!isRecord(value)) return target; + return { + scheduledVacuum: normalizeSchedule(value.scheduledVacuum, target.scheduledVacuum), + vacuumHour: normalizeVacuumHour(value.vacuumHour, target.vacuumHour), + }; +} + +function readScheduleSettings(): VacuumScheduleSettings { + let settings: VacuumScheduleSettings = { + scheduledVacuum: DEFAULT_DATABASE_SETTINGS.optimization.scheduledVacuum, + vacuumHour: DEFAULT_DATABASE_SETTINGS.optimization.vacuumHour, + }; + + const mainSettings = readNamespace("settings"); + const databaseSettingsValue = mainSettings.databaseSettings; + if (isRecord(databaseSettingsValue)) { + settings = mergeOptimization(settings, databaseSettingsValue.optimization); + } + settings = mergeOptimization(settings, mainSettings.optimization); + + const databaseSettings = readNamespace("databaseSettings"); + settings = mergeOptimization(settings, databaseSettings.optimization); + settings = { + scheduledVacuum: normalizeSchedule( + databaseSettings["optimization.scheduledVacuum"] ?? databaseSettings.scheduledVacuum, + settings.scheduledVacuum + ), + vacuumHour: normalizeVacuumHour( + databaseSettings["optimization.vacuumHour"] ?? databaseSettings.vacuumHour, + settings.vacuumHour + ), + }; + + return settings; +} + +function atVacuumHour(timestamp: number, hour: number): Date { + const date = new Date(timestamp); + date.setHours(hour, 0, 0, 0); + return date; +} + +function addFrequency(date: Date, frequency: Exclude): Date { + const next = new Date(date.getTime()); + if (frequency === "daily") next.setDate(next.getDate() + 1); + else if (frequency === "weekly") next.setDate(next.getDate() + 7); + else next.setMonth(next.getMonth() + 1); + return next; +} + +export function resolveNextRunAt( + settings: VacuumScheduleSettings, + lastRunAt: number | null, + now: number = Date.now() +): number | null { + const frequency = settings.scheduledVacuum; + if (frequency === "never") return null; + + const hour = normalizeVacuumHour( + settings.vacuumHour, + DEFAULT_DATABASE_SETTINGS.optimization.vacuumHour + ); + let candidate: Date; + if (typeof lastRunAt === "number" && Number.isFinite(lastRunAt) && lastRunAt > 0) { + candidate = atVacuumHour(lastRunAt, hour); + if (candidate.getTime() <= lastRunAt) candidate = addFrequency(candidate, frequency); + } else { + candidate = atVacuumHour(now, hour); + if (candidate.getTime() <= now) candidate = addFrequency(candidate, "daily"); + } + + while (candidate.getTime() <= now) { + candidate = addFrequency(candidate, frequency); + } + + return candidate.getTime(); +} + +function applySchedule(now: number = Date.now(), anchorLastRunAt = currentState.lastRunAt): void { + const settings = readScheduleSettings(); + currentState.enabled = settings.scheduledVacuum !== "never"; + currentState.intervalMs = NOMINAL_INTERVAL_MS[settings.scheduledVacuum]; + currentState.nextRunAt = resolveNextRunAt(settings, anchorLastRunAt, now); +} + +function armTimer(): void { if (timer) { clearTimeout(timer); timer = null; } - if (!currentState.enabled) { + if (!currentState.enabled || currentState.nextRunAt === null || currentState.isRunning) { currentState.nextRunAt = null; return; } - const nextAt = Date.now() + currentState.intervalMs; - currentState.nextRunAt = nextAt; - timer = setTimeout(() => { - void runNow().catch((err) => { - currentState.lastError = err instanceof Error ? err.message : String(err); - }); - }, currentState.intervalMs); + + const delayMs = Math.max(0, currentState.nextRunAt - Date.now()); + timer = setTimeout( + () => { + if (currentState.nextRunAt !== null && currentState.nextRunAt > Date.now()) { + armTimer(); + return; + } + void runNow().catch((err) => { + currentState.lastError = err instanceof Error ? err.message : String(err); + }); + }, + Math.min(delayMs, MAX_TIMER_TIMEOUT_MS) + ); // Don't keep the event loop alive just for vacuum if (typeof timer.unref === "function") timer.unref(); } @@ -127,6 +245,13 @@ export function getState(): VacuumSchedulerState { return { ...currentState }; } +export function refresh(): VacuumSchedulerState { + applySchedule(); + persistState(); + armTimer(); + return getState(); +} + export async function runNow(): Promise<{ success: boolean; durationMs: number; error?: string }> { if (currentState.isRunning) { return { success: false, durationMs: 0, error: "already_running" }; @@ -143,17 +268,16 @@ export async function runNow(): Promise<{ success: boolean; durationMs: number; currentState.lastError = null; currentState.lastDurationMs = duration; currentState.isRunning = false; - persistState(); - scheduleNext(); // reset the next-run clock from this successful run + refresh(); // reset the next-run clock from this successful run return { success: true, durationMs: duration }; } catch (err) { const message = err instanceof Error ? err.message : String(err); currentState.lastError = message; currentState.lastDurationMs = Date.now() - start; currentState.isRunning = false; + applySchedule(Date.now(), Date.now()); persistState(); - // Don't reschedule on error — let the next interval tick retry - scheduleNext(); + armTimer(); return { success: false, durationMs: currentState.lastDurationMs, error: message }; } } @@ -173,19 +297,12 @@ export function init(): VacuumSchedulerState { isRunning: false, // never resume a "running" state across restarts nextRunAt: null, // recompute below }; - currentState.intervalMs = readIntervalFromSettings(); - currentState.enabled = readEnabledFromSettings(); - - if (currentState.enabled) { - scheduleNext(); - } else { - currentState.nextRunAt = null; - } - - persistState(); - return getState(); + return refresh(); } +export const initVacuumScheduler = init; +export const refreshVacuumScheduler = refresh; + /** * Stop the scheduler. Called from `closeDbInstance()` so we don't * leak a setTimeout handle across DB reconnects. Idempotent. diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 43b3c5823b..0979fa4138 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -91,6 +91,7 @@ export { export * from "./db/compressionCacheStats"; export * from "./db/compressionCombos"; +export * from "./db/compressionRunTelemetry"; export { // API Keys diff --git a/src/lib/usage/usageHistory.ts b/src/lib/usage/usageHistory.ts index 599619eb0a..f22d053e46 100644 --- a/src/lib/usage/usageHistory.ts +++ b/src/lib/usage/usageHistory.ts @@ -341,7 +341,10 @@ export function trackPendingRequest( } const now = Date.now(); const newDetail = { - id: `${now}-${Math.random().toString(36).slice(2, 8)}`, + // crypto RNG (not Math.random) to satisfy CodeQL js/insecure-randomness — + // this pending-request id flows into attempt logging; it's a correlation + // id, not a security secret. + id: `${now}-${globalThis.crypto.randomUUID().slice(0, 6)}`, model, provider, connectionId, diff --git a/src/shared/validation/compressionConfigSchemas.ts b/src/shared/validation/compressionConfigSchemas.ts index 02cf42f3c8..293c11694f 100644 --- a/src/shared/validation/compressionConfigSchemas.ts +++ b/src/shared/validation/compressionConfigSchemas.ts @@ -33,6 +33,13 @@ export const cavemanOutputModeSchema = z }) .strict(); +export const outputStyleSelectionSchema = z + .object({ + id: z.string().trim().min(1), + level: cavemanIntensitySchema, + }) + .strict(); + export const rtkConfigSchema = z .object({ enabled: z.boolean().optional(), @@ -189,6 +196,7 @@ export const compressionSettingsUpdateSchema = z stackedPipeline: z.array(stackedPipelineStepSchema).optional(), cavemanConfig: cavemanConfigSchema.optional(), cavemanOutputMode: cavemanOutputModeSchema.optional(), + outputStyles: z.array(outputStyleSelectionSchema).optional(), rtkConfig: rtkConfigSchema.optional(), languageConfig: languageConfigSchema.optional(), aggressive: aggressiveConfigSchema.optional(), @@ -197,6 +205,8 @@ export const compressionSettingsUpdateSchema = z engines: z.record(z.string(), engineToggleSchema).optional(), enginesExplicit: z.boolean().optional(), activeComboId: z.string().nullable().optional(), + ultraEngine: z.enum(["heuristic", "slm"]).optional(), + ultraSlmPrewarm: z.boolean().optional(), }) .strict(); diff --git a/src/shared/validation/schemas/combo.ts b/src/shared/validation/schemas/combo.ts index 65e27bceb2..64874daa3b 100644 --- a/src/shared/validation/schemas/combo.ts +++ b/src/shared/validation/schemas/combo.ts @@ -186,28 +186,26 @@ export const comboRuntimeConfigSchema = z shadowRouting: shadowRoutingSchema.optional(), evalRouting: evalRoutingSchema.optional(), }) - .strict() - .superRefine((config, ctx) => { - if (config.zeroLatencyOptimizationsEnabled === true) return; + .passthrough() + .transform((config) => { + // Backward-compat shim: combos stored prior to v3.8.33 may carry zero-latency + // feature flags (fallbackCompressionMode !== "off", hedging === true, or + // predictiveTtftMs > 0) without the accompanying zeroLatencyOptimizationsEnabled + // gate that the new schema requires. Auto-promote the flag when any such feature + // is enabled but the gate is unset/false, so stored combos continue to round-trip + // through PUT /api/combos/{id} without returning 400. This replaces the prior + // superRefine that hard-rejected these payloads (see issue #4382). + if (config.zeroLatencyOptimizationsEnabled === true) return config; - const addZeroLatencyIssue = (path: string[]) => { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - "zeroLatencyOptimizationsEnabled must be true to enable zero-latency combo features", - path, - }); - }; + const hasZeroLatencyFeature = + config.hedging === true || + (typeof config.predictiveTtftMs === "number" && config.predictiveTtftMs > 0) || + (!!config.fallbackCompressionMode && config.fallbackCompressionMode !== "off"); - if (config.hedging === true) { - addZeroLatencyIssue(["hedging"]); - } - if (typeof config.predictiveTtftMs === "number" && config.predictiveTtftMs > 0) { - addZeroLatencyIssue(["predictiveTtftMs"]); - } - if (config.fallbackCompressionMode && config.fallbackCompressionMode !== "off") { - addZeroLatencyIssue(["fallbackCompressionMode"]); + if (hasZeroLatencyFeature) { + return { ...config, zeroLatencyOptimizationsEnabled: true }; } + return config; }); export const comboNameSchema = z diff --git a/tests/integration/chatcore-compression-integration.test.ts b/tests/integration/chatcore-compression-integration.test.ts index 5e492d70d0..cd1b3a2ffb 100644 --- a/tests/integration/chatcore-compression-integration.test.ts +++ b/tests/integration/chatcore-compression-integration.test.ts @@ -687,7 +687,7 @@ test("chatCore integration: assigned compression combo applies language packs an assert.ok(capturedBody, "Fetch should receive the request body"); const firstMessage = capturedBody.messages?.[0]; assert.equal(firstMessage?.role, "system"); - assert.match(firstMessage?.content ?? "", /OmniRoute Caveman Output Mode/); + assert.match(firstMessage?.content ?? "", /OmniRoute Output Styles/); assert.match(firstMessage?.content ?? "", /Responda conciso/); for ( @@ -782,7 +782,7 @@ test("chatCore integration: default stacked compression combo applies for unassi assert.ok(capturedBody, "Fetch should receive the request body"); const firstMessage = capturedBody.messages?.[0]; assert.equal(firstMessage?.role, "system"); - assert.match(firstMessage?.content ?? "", /OmniRoute Caveman Output Mode/); + assert.match(firstMessage?.content ?? "", /OmniRoute Output Styles/); assert.match(firstMessage?.content ?? "", /Responda conciso/); let summary = compressionAnalyticsDb.getCompressionAnalyticsSummary(); @@ -1037,7 +1037,7 @@ test("chatCore integration: caveman output mode skipped when compression is glob "user", "No system message should be injected when compression is disabled" ); - assert.doesNotMatch(capturedBody.messages[0].content ?? "", /Caveman Output Mode/); + assert.doesNotMatch(capturedBody.messages[0].content ?? "", /Output Styles/); } finally { globalThis.fetch = originalFetch; } @@ -1103,7 +1103,7 @@ test("chatCore integration: caveman output mode injected when both compression a assert.ok(result.success, "Request should succeed"); assert.equal(capturedBody.messages[0].role, "system"); - assert.match(capturedBody.messages[0].content ?? "", /Caveman Output Mode/); + assert.match(capturedBody.messages[0].content ?? "", /Output Styles/); } finally { globalThis.fetch = originalFetch; } diff --git a/tests/integration/integration-wiring.test.ts b/tests/integration/integration-wiring.test.ts index cbe47150b7..acad6b66c6 100644 --- a/tests/integration/integration-wiring.test.ts +++ b/tests/integration/integration-wiring.test.ts @@ -309,7 +309,7 @@ describe("API Routes — dashboard and tool consumers", () => { it("keeps legacy usage history and raw request-log APIs explicitly classified", () => { const usageStats = readProjectFile("src/shared/components/UsageStats.tsx"); const apiReference = readProjectFile("docs/reference/API_REFERENCE.md"); - const openApi = readProjectFile("docs/reference/openapi.yaml"); + const openApi = readProjectFile("docs/openapi.yaml"); assert.ok(usageStats, "UsageStats compatibility component should exist"); assert.ok(apiReference, "API reference should exist"); @@ -359,7 +359,7 @@ describe("Dashboard Wiring — T05 payload rules", () => { const payloadRulesTabSrc = readProjectFile( "src/app/(dashboard)/dashboard/settings/components/PayloadRulesTab.tsx" ); - const openapiSrc = readProjectFile("docs/reference/openapi.yaml"); + const openapiSrc = readProjectFile("docs/openapi.yaml"); it.skip("settings page should surface payload rules inside advanced settings", () => { assert.ok(settingsPageSrc, "settings page source should exist"); @@ -379,7 +379,7 @@ describe("Dashboard Wiring — T05 payload rules", () => { }); it("openapi should document the payload rules management surface", () => { - assert.ok(openapiSrc, "docs/reference/openapi.yaml should exist"); + assert.ok(openapiSrc, "docs/openapi.yaml should exist"); assert.match(openapiSrc, /\/api\/settings\/payload-rules:/); assert.match(openapiSrc, /summary:\s+Get payload rules configuration/); assert.match(openapiSrc, /ManagementSessionAuth:/); diff --git a/tests/integration/proxy-pipeline.test.ts b/tests/integration/proxy-pipeline.test.ts index b91259dba7..48e1536202 100644 --- a/tests/integration/proxy-pipeline.test.ts +++ b/tests/integration/proxy-pipeline.test.ts @@ -71,8 +71,11 @@ describe("Chat Pipeline — handleSingleModelChat decomposition", () => { }); it("chatCore should record cost for both non-streaming and streaming responses", () => { + // Non-streaming cost is still recorded inline; streaming cost was extracted to + // the recordStreamingCost leaf (open-sse/handlers/chatCore/streamingCost.ts, + // #4790 / #3501), so chatCore now delegates streaming cost to it. assert.match(coreSrc, /if \(apiKeyInfo\?\.id && estimatedCost > 0\)/); - assert.match(coreSrc, /if \(apiKeyInfo\?\.id && streamUsage\)/); + assert.match(coreSrc, /recordStreamingCost\(/); }); }); @@ -208,12 +211,22 @@ describe("Plugin Architecture — plugins/hooks.ts", () => { it("should run onRequest hooks in priority order", async () => { const order = []; - hooks.registerHook("onRequest", "first", () => { - order.push("first"); - }, 1); - hooks.registerHook("onRequest", "second", () => { - order.push("second"); - }, 2); + hooks.registerHook( + "onRequest", + "first", + () => { + order.push("first"); + }, + 1 + ); + hooks.registerHook( + "onRequest", + "second", + () => { + order.push("second"); + }, + 2 + ); const ctx = { requestId: "r1", body: {}, model: "test", metadata: {} }; await hooks.runOnRequest(ctx); @@ -221,13 +234,23 @@ describe("Plugin Architecture — plugins/hooks.ts", () => { }); it("should support request blocking via emitHookBlocking", async () => { - hooks.registerHook("onRequest", "blocker", () => ({ - blocked: true, - response: { error: "denied" }, - }), 1); - hooks.registerHook("onRequest", "never-runs", () => { - throw new Error("should not run"); - }, 2); + hooks.registerHook( + "onRequest", + "blocker", + () => ({ + blocked: true, + response: { error: "denied" }, + }), + 1 + ); + hooks.registerHook( + "onRequest", + "never-runs", + () => { + throw new Error("should not run"); + }, + 2 + ); const ctx = { requestId: "r2", body: {}, model: "test", metadata: {} }; const result = await hooks.emitHookBlocking("onRequest", ctx); diff --git a/tests/unit/agentSkills-openapiParser.test.ts b/tests/unit/agentSkills-openapiParser.test.ts index cbe70cf541..a3273dbce8 100644 --- a/tests/unit/agentSkills-openapiParser.test.ts +++ b/tests/unit/agentSkills-openapiParser.test.ts @@ -5,7 +5,8 @@ import path from "node:path"; import os from "node:os"; // Dynamic import to pick up ESM module -const { parseOpenapi, getEndpointsForArea } = await import("../../src/lib/agentSkills/openapiParser.ts"); +const { parseOpenapi, getEndpointsForArea } = + await import("../../src/lib/agentSkills/openapiParser.ts"); // ─── Fixture helpers ────────────────────────────────────────────────────────── @@ -15,7 +16,9 @@ const { parseOpenapi, getEndpointsForArea } = await import("../../src/lib/agentS */ function withFixtureOpenapi(yamlContent: string): { cleanup: () => void } { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-openapi-test-")); - const docsDir = path.join(tmpDir, "docs", "reference"); + // openapiParser reads docs/openapi.yaml (consolidated location since #4781, + // previously docs/reference/openapi.yaml) — the fixture must mirror that path. + const docsDir = path.join(tmpDir, "docs"); fs.mkdirSync(docsDir, { recursive: true }); fs.writeFileSync(path.join(docsDir, "openapi.yaml"), yamlContent, "utf-8"); @@ -118,7 +121,7 @@ test("parseOpenapi() groups /api/providers/* under 'providers' area", () => { assert.ok(providerOps, "Expected 'providers' area to exist"); assert.ok( providerOps!.length >= 5, - `Expected at least 5 provider endpoints, got ${providerOps!.length}`, + `Expected at least 5 provider endpoints, got ${providerOps!.length}` ); const paths = providerOps!.map((op) => op.path); @@ -150,11 +153,11 @@ test("parseOpenapi() groups /api/v1/* under 'inference' area", () => { assert.ok(inferenceOps, "Expected 'inference' area to exist"); assert.ok( inferenceOps!.length >= 1, - `Expected at least 1 inference endpoint, got ${inferenceOps!.length}`, + `Expected at least 1 inference endpoint, got ${inferenceOps!.length}` ); assert.ok( inferenceOps!.some((op) => op.path === "/api/v1/chat/completions"), - "Expected /api/v1/chat/completions in inference area", + "Expected /api/v1/chat/completions in inference area" ); } finally { cleanup(); @@ -184,7 +187,7 @@ test("parseOpenapi() throws if openapi.yaml is missing", () => { assert.throws( () => parseOpenapi(), /openapiParser: could not read/, - "Expected error when openapi.yaml is missing", + "Expected error when openapi.yaml is missing" ); } finally { process.chdir(originalCwd); @@ -225,9 +228,9 @@ test( assert.ok(providerOps, "Expected 'providers' area in real OpenAPI spec"); assert.ok( providerOps!.length >= 5, - `Expected ≥5 provider endpoints in real spec, got ${providerOps!.length}`, + `Expected ≥5 provider endpoints in real spec, got ${providerOps!.length}` ); - }, + } ); test( @@ -237,11 +240,11 @@ test( const endpoints = getEndpointsForArea("providers"); assert.ok( endpoints.length >= 5, - `Expected ≥5 provider endpoint strings, got ${endpoints.length}: ${endpoints.join(", ")}`, + `Expected ≥5 provider endpoint strings, got ${endpoints.length}: ${endpoints.join(", ")}` ); // Each entry should match "METHOD /path" for (const ep of endpoints) { assert.match(ep, /^[A-Z]+ \//, `Endpoint "${ep}" does not match METHOD /path format`); } - }, + } ); diff --git a/tests/unit/api-docs-route.test.ts b/tests/unit/api-docs-route.test.ts new file mode 100644 index 0000000000..e4d0a13dd8 --- /dev/null +++ b/tests/unit/api-docs-route.test.ts @@ -0,0 +1,24 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// GET /api/docs serves the Redoc-rendered API reference (#4781). Static HTML +// shell that loads Redoc from a CDN and points it at /openapi.yaml (the +// canonical spec served from public/openapi.yaml). PUBLIC tier — no auth. +const docsRoute = await import("../../src/app/api/docs/route.ts"); + +test("GET /api/docs returns a 200 text/html Redoc shell", async () => { + const response = docsRoute.GET(); + assert.equal(response.status, 200); + assert.match(response.headers.get("content-type") ?? "", /text\/html/); + + const body = await response.text(); + // Renders Redoc and points it at the canonical spec. + assert.match(body, /redoc/i); + assert.ok(body.includes("/openapi.yaml"), "Redoc must load the canonical /openapi.yaml spec"); + // No stack traces / secrets leaked into the static shell. + assert.ok(!body.includes("at /"), "static shell must not leak stack-trace frames"); +}); + +test("GET /api/docs is statically renderable (no per-request work)", () => { + assert.equal(docsRoute.dynamic, "force-static"); +}); diff --git a/tests/unit/autoCombo/suffixComposition-4517.test.ts b/tests/unit/autoCombo/suffixComposition-4517.test.ts new file mode 100644 index 0000000000..88a4adf333 --- /dev/null +++ b/tests/unit/autoCombo/suffixComposition-4517.test.ts @@ -0,0 +1,98 @@ +/** + * Unit tests for the `auto/:` suffix composition filter. + * + * See: `open-sse/services/autoCombo/suffixComposition.ts` + * + * Focus: the `:free` tier filter. Regression test for the bug where + * opencode (noAuth, free) and mimocode (noAuth, free) were NOT being + * included in the free pool because the legacy `freeProviders` list + * only contained paid-API-key providers with free tiers (kiro, qoder, ...). + * + * #4517. + */ +// NOTE: tests/unit/autoCombo/ is a vitest-only scope (the node:test runner in +// test:unit:ci does not walk this dir), so this suite uses the vitest API even +// though it asserts via node:assert. (#4753 originally landed it with node:test +// imports → vitest reported "No test suite found" and the test ran nowhere.) +import { describe, it, beforeAll, afterAll, beforeEach } from "vitest"; +import assert from "node:assert/strict"; +import { + buildAutoCandidateFilter, + parseAutoSuffix, +} from "../../../open-sse/services/autoCombo/suffixComposition"; + +describe("suffixComposition :free tier (#4517)", () => { + const ORIGINAL_ENV = { ...process.env }; + + beforeAll(() => { + // Snapshot env so we can restore it after each test. + }); + + beforeEach(() => { + // Reset env to a known state so OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL + // doesn't leak between cases. + process.env = { ...ORIGINAL_ENV }; + }); + + afterAll(() => { + process.env = ORIGINAL_ENV; + }); + + it("parseAutoSuffix recognizes coding:free", () => { + assert.deepEqual(parseAutoSuffix("coding:free"), { + valid: true, + category: "coding", + tier: "free", + }); + }); + + it("buildAutoCandidateFilter keeps noAuth free providers", () => { + // Regression: opencode and mimocode are noAuth and free, but the + // pre-fix `freeProviders` list omitted them, so the filter rejected + // their candidates even though they ARE free upstream. + const filter = buildAutoCandidateFilter("coding", "free"); + assert.notEqual(filter, null); + + assert.equal(filter!({ provider: "opencode", model: "big-pickle" }), true); + assert.equal(filter!({ provider: "opencode", model: "minimax-m3-free" }), true); + assert.equal(filter!({ provider: "mimocode", model: "mimo-auto" }), true); + assert.equal(filter!({ provider: "duckduckgo-web", model: "gpt-4o-mini" }), true); + }); + + it("buildAutoCandidateFilter keeps legacy free providers", () => { + // Don't break the existing list — kiro, qoder, groq, etc. must still pass. + const filter = buildAutoCandidateFilter("coding", "free"); + assert.equal(filter!({ provider: "kiro", model: "claude-sonnet-4-5" }), true); + assert.equal(filter!({ provider: "groq", model: "llama-3.3-70b" }), true); + assert.equal(filter!({ provider: "qoder", model: "qwen3-coder-plus" }), true); + }); + + it("buildAutoCandidateFilter rejects paid models under :free", () => { + // The bug: opencode-go/glm-5.1 was being picked because the filter + // fell back to the full pool when no free candidate was found. + // After the fix, glm-5.1 must be rejected by the :free filter. + const filter = buildAutoCandidateFilter("coding", "free"); + assert.equal(filter!({ provider: "opencode-go", model: "glm-5.1" }), false); + assert.equal(filter!({ provider: "openai", model: "gpt-4o" }), false); + assert.equal(filter!({ provider: "anthropic", model: "claude-sonnet-4-6" }), false); + assert.equal(filter!({ provider: "deepseek", model: "deepseek-chat" }), false); + }); + + it("buildAutoCandidateFilter returns null for category-only (no tier)", () => { + // "coding" with no tier must NOT filter by tier — pass-through. + const filter = buildAutoCandidateFilter("coding", undefined); + assert.equal(filter, null); + }); + + it("buildAutoCandidateFilter keeps free candidates alongside capability checks", () => { + // The category and tier checks are AND-combined. For "coding:free" the + // category check is a pass-through (no vision/reasoning filter), so + // any free model should be kept. + const filter = buildAutoCandidateFilter("coding", "free"); + assert.equal(filter!({ provider: "opencode", model: "minimax-m3-free" }), true); + // The "reasoning" category also pairs with ":free" and keeps free models. + const reasoningFilter = buildAutoCandidateFilter("reasoning", "free"); + // big-pickle (model_capabilities: reasoning=1) should pass the reasoning check. + assert.equal(reasoningFilter!({ provider: "opencode", model: "big-pickle" }), true); + }); +}); diff --git a/tests/unit/chatcore-attempt-logging.test.ts b/tests/unit/chatcore-attempt-logging.test.ts new file mode 100644 index 0000000000..d4871162f3 --- /dev/null +++ b/tests/unit/chatcore-attempt-logging.test.ts @@ -0,0 +1,99 @@ +// tests/unit/chatcore-attempt-logging.test.ts +// Characterization of persistAttemptLogs — the per-attempt call-log persistence extracted from +// handleChatCore (chatCore god-file decomposition, #3501). Uses a real temp DB and polls the +// persisted row (saveCallLog is async + fire-and-forget). Locks: the field mapping, the +// cacheSource semantic/upstream normalization, the connectionId → credentials.connectionId +// fallback, and error persistence. +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-attempt-logging-test-")); +process.env.DATA_DIR = testDataDir; + +const coreDb = await import("../../src/lib/db/core.ts"); +const { getCallLogById } = await import("../../src/lib/usage/callLogs.ts"); +const { persistAttemptLogs } = await import("../../open-sse/handlers/chatCore/attemptLogging.ts"); + +function baseCtx(overrides: Record = {}) { + return { + provider: "openai", + connectionId: "conn-1", + model: "gpt-x", + skillRequestId: "skill-1", + detailedLoggingEnabled: false, + reqLogger: null, + pendingRequestId: "REPLACE", + clientRawRequest: { endpoint: "/v1/chat/completions" }, + requestedModel: "gpt-x-requested", + credentials: { connectionId: "cred-conn" }, + startTime: Date.now(), + body: { messages: [{ role: "user", content: "hi" }] }, + sourceFormat: "openai", + targetFormat: "openai", + comboName: null, + comboStepId: null, + comboExecutionKey: null, + tokensCompressed: 0, + apiKeyInfo: { id: "key-1", name: "Key One" }, + noLogEnabled: false, + ...overrides, + } as Parameters[1]; +} + +async function pollForCallLog(id: string, tries = 120) { + for (let i = 0; i < tries; i++) { + const row = await getCallLogById(id); + if (row) return row as Record; + await new Promise((r) => setTimeout(r, 20)); + } + return null; +} + +before(async () => { + await coreDb.ensureDbInitialized(); +}); + +after(() => { + coreDb.resetDbInstance(); + fs.rmSync(testDataDir, { recursive: true, force: true }); +}); + +test("persists a call log row with the mapped fields (default cacheSource=upstream)", async () => { + const id = "attempt-basic-1"; + persistAttemptLogs({ status: 200, tokens: { input: 1, output: 2 } }, baseCtx({ pendingRequestId: id })); + const row = await pollForCallLog(id); + assert.ok(row, "call log row should be persisted"); + assert.equal(row.status, 200); + assert.equal(row.model, "gpt-x"); + assert.equal(row.provider, "openai"); + assert.equal(row.requestedModel, "gpt-x-requested"); + assert.equal(row.connectionId, "conn-1"); + assert.equal(row.cacheSource, "upstream"); +}); + +test("cacheSource 'semantic' is preserved", async () => { + const id = "attempt-semantic-1"; + persistAttemptLogs( + { status: 200, cacheSource: "semantic" }, + baseCtx({ pendingRequestId: id }) + ); + const row = await pollForCallLog(id); + assert.ok(row); + assert.equal(row.cacheSource, "semantic"); +}); + +test("connectionId falls back to credentials.connectionId when null, and error is persisted", async () => { + const id = "attempt-fallback-1"; + persistAttemptLogs( + { status: 502, error: "upstream boom" }, + baseCtx({ pendingRequestId: id, connectionId: null }) + ); + const row = await pollForCallLog(id); + assert.ok(row); + assert.equal(row.connectionId, "cred-conn"); + assert.equal(row.status, 502); + assert.match(String(row.error ?? ""), /upstream boom/); +}); diff --git a/tests/unit/chatcore-cache-usage-meta.test.ts b/tests/unit/chatcore-cache-usage-meta.test.ts new file mode 100644 index 0000000000..0a247c0e7e --- /dev/null +++ b/tests/unit/chatcore-cache-usage-meta.test.ts @@ -0,0 +1,58 @@ +// tests/unit/chatcore-cache-usage-meta.test.ts +// Characterization of toPositiveNumber / buildCacheUsageLogMeta / attachLogMeta — cache-usage log +// meta helpers extracted from handleChatCore (chatCore god-file decomposition, #3501). Locks: the +// positive-number coercion, the cache-token derivation across top-level and prompt_tokens_details +// shapes (null when no cache fields), and the _omniroute meta attachment/merge. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + toPositiveNumber, + buildCacheUsageLogMeta, + attachLogMeta, +} from "../../open-sse/handlers/chatCore/cacheUsageMeta.ts"; + +test("toPositiveNumber keeps finite positives, zeros everything else", () => { + assert.equal(toPositiveNumber(5), 5); + assert.equal(toPositiveNumber(0), 0); + assert.equal(toPositiveNumber(-3), 0); + assert.equal(toPositiveNumber(Infinity), 0); + assert.equal(toPositiveNumber("5"), 0); + assert.equal(toPositiveNumber(null), 0); +}); + +test("buildCacheUsageLogMeta returns null when there are no cache fields", () => { + assert.equal(buildCacheUsageLogMeta(null), null); + assert.equal(buildCacheUsageLogMeta({ prompt_tokens: 10 }), null); +}); + +test("buildCacheUsageLogMeta reads top-level cache fields", () => { + const meta = buildCacheUsageLogMeta({ + cache_read_input_tokens: 12, + cache_creation_input_tokens: 4, + }); + assert.deepEqual(meta, { cacheReadTokens: 12, cacheCreationTokens: 4 }); +}); + +test("buildCacheUsageLogMeta reads prompt_tokens_details shapes", () => { + const meta = buildCacheUsageLogMeta({ + prompt_tokens_details: { cached_tokens: 7, cache_creation_tokens: 2 }, + }); + assert.deepEqual(meta, { cacheReadTokens: 7, cacheCreationTokens: 2 }); +}); + +test("attachLogMeta returns the payload untouched when meta is empty", () => { + const payload = { a: 1 }; + assert.equal(attachLogMeta(payload, null), payload); + assert.equal(attachLogMeta(payload, {}), payload); + assert.equal(attachLogMeta(payload, { x: null, y: undefined }), payload); +}); + +test("attachLogMeta merges compact meta into _omniroute", () => { + const out = attachLogMeta({ a: 1, _omniroute: { keep: true } }, { added: 2, drop: null }); + assert.deepEqual(out, { a: 1, _omniroute: { keep: true, added: 2 } }); +}); + +test("attachLogMeta wraps non-object payloads", () => { + const out = attachLogMeta(null, { added: 2 }); + assert.deepEqual(out, { _omniroute: { added: 2 }, _payload: null }); +}); diff --git a/tests/unit/chatcore-caveman-output-analytics.test.ts b/tests/unit/chatcore-caveman-output-analytics.test.ts new file mode 100644 index 0000000000..e776cacb28 --- /dev/null +++ b/tests/unit/chatcore-caveman-output-analytics.test.ts @@ -0,0 +1,75 @@ +// Characterization of writeCavemanOutputAnalytics — the caveman-output-only compression analytics +// write extracted from handleChatCore's request-setup compression path (chatCore god-file +// decomposition, #3501). Returns the write promise; uses a real temp DB. Locks: the row written +// (mode=output-caveman, engine=caveman-output, original==compressed==estimatedTokens, tokens_saved +// 0, output_mode) and that the returned promise never rejects (best-effort). +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-caveman-test-")); +process.env.DATA_DIR = testDataDir; + +const coreDb = await import("../../src/lib/db/core.ts"); +const { writeCavemanOutputAnalytics } = await import( + "../../open-sse/handlers/chatCore/cavemanOutputAnalytics.ts" +); + +function rowFor(requestId: string): Record | undefined { + return coreDb + .getDbInstance() + .prepare( + "SELECT mode, engine, original_tokens AS orig, compressed_tokens AS comp, tokens_saved AS saved, output_mode AS om FROM compression_analytics WHERE request_id = ?" + ) + .get(requestId) as Record | undefined; +} + +before(async () => { + await coreDb.ensureDbInitialized(); +}); + +after(() => { + coreDb.resetDbInstance(); + try { + fs.rmSync(testDataDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +}); + +test("writes a caveman-output analytics row and the promise resolves", async () => { + await writeCavemanOutputAnalytics({ + comboName: null, + provider: "openai", + compressionComboId: null, + estimatedTokens: 321, + skillRequestId: "caveman-req-1", + cavemanOutputModeIntensity: "heavy", + }); + const row = rowFor("caveman-req-1"); + assert.ok(row, "expected a compression_analytics row"); + assert.equal(row!.mode, "output-caveman"); + assert.equal(row!.engine, "caveman-output"); + assert.equal(row!.orig, 321); + assert.equal(row!.comp, 321); + assert.equal(row!.saved, 0); + assert.equal(row!.om, "heavy"); +}); + +test("the returned promise never rejects even on a bad write", async () => { + // resetting the DB instance makes the dynamic insert path fail; the helper must swallow it + coreDb.resetDbInstance(); + await assert.doesNotReject( + writeCavemanOutputAnalytics({ + comboName: "combo-x", + provider: "openai", + compressionComboId: "cc-1", + estimatedTokens: 10, + skillRequestId: "caveman-req-2", + cavemanOutputModeIntensity: null, + }) + ); + await coreDb.ensureDbInitialized(); +}); diff --git a/tests/unit/chatcore-claude-upstream-messages.test.ts b/tests/unit/chatcore-claude-upstream-messages.test.ts new file mode 100644 index 0000000000..ddf1892b44 --- /dev/null +++ b/tests/unit/chatcore-claude-upstream-messages.test.ts @@ -0,0 +1,109 @@ +// tests/unit/chatcore-claude-upstream-messages.test.ts +// Characterization of extractSystemMessagesToBody / normalizeClaudeUpstreamMessages — the Claude +// upstream-message transforms extracted from handleChatCore (chatCore god-file decomposition, +// #3501). Locks: system/developer role lifting into top-level `system` (string/array/none merge +// shapes), empty-text-block dropping, tool_result collapse vs preserve, file/document inlining, and +// unsupported-part dropping (with the debug log). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + extractSystemMessagesToBody, + normalizeClaudeUpstreamMessages, +} from "../../open-sse/handlers/chatCore/claudeUpstreamMessages.ts"; + +test("extractSystemMessagesToBody lifts system/developer roles into top-level system", () => { + const payload: Record = { + messages: [ + { role: "system", content: "be terse" }, + { role: "developer", content: "use json" }, + { role: "user", content: "hi" }, + ], + }; + extractSystemMessagesToBody(payload); + assert.deepEqual(payload.system, [ + { type: "text", text: "be terse" }, + { type: "text", text: "use json" }, + ]); + assert.deepEqual(payload.messages, [{ role: "user", content: "hi" }]); +}); + +test("extractSystemMessagesToBody prepends an existing string system", () => { + const payload: Record = { + system: "base", + messages: [{ role: "system", content: "extra" }], + }; + extractSystemMessagesToBody(payload); + assert.deepEqual(payload.system, [ + { type: "text", text: "base" }, + { type: "text", text: "extra" }, + ]); +}); + +test("extractSystemMessagesToBody is a no-op without system messages or a messages array", () => { + const p1: Record = { messages: [{ role: "user", content: "hi" }] }; + extractSystemMessagesToBody(p1); + assert.equal(p1.system, undefined); + const p2: Record = { messages: "nope" }; + extractSystemMessagesToBody(p2); + assert.equal(p2.system, undefined); +}); + +test("normalizeClaudeUpstreamMessages drops empty text blocks", () => { + const payload: Record = { + messages: [ + { role: "user", content: [{ type: "text", text: "" }, { type: "text", text: "keep" }] }, + ], + }; + normalizeClaudeUpstreamMessages(payload); + const msg = (payload.messages as Record[])[0]; + assert.deepEqual(msg.content, [{ type: "text", text: "keep" }]); +}); + +test("normalizeClaudeUpstreamMessages collapses tool_result to text by default", () => { + const payload: Record = { + messages: [ + { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "out" }] }, + ], + }; + normalizeClaudeUpstreamMessages(payload); + const msg = (payload.messages as Record[])[0]; + assert.deepEqual(msg.content, [{ type: "text", text: "[Tool Result: t1]\nout" }]); +}); + +test("normalizeClaudeUpstreamMessages preserves tool_result when asked", () => { + const payload: Record = { + messages: [ + { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "out" }] }, + ], + }; + normalizeClaudeUpstreamMessages(payload, { preserveToolResultBlocks: true }); + const msg = (payload.messages as Record[])[0]; + assert.deepEqual(msg.content, [{ type: "tool_result", tool_use_id: "t1", content: "out" }]); +}); + +test("normalizeClaudeUpstreamMessages inlines a file block without url/data as text", () => { + const payload: Record = { + messages: [ + { + role: "user", + content: [{ type: "file", file: { name: "notes.txt", content: "hello" } }], + }, + ], + }; + normalizeClaudeUpstreamMessages(payload); + const msg = (payload.messages as Record[])[0]; + assert.deepEqual(msg.content, [{ type: "text", text: "[notes.txt]\nhello" }]); +}); + +test("normalizeClaudeUpstreamMessages drops unsupported parts and logs via the bound logger", () => { + const logged: string[] = []; + const log = { debug: (_tag: string, msg: string) => logged.push(msg) }; + const payload: Record = { + messages: [{ role: "user", content: [{ type: "thinking", text: "x" }] }], + }; + normalizeClaudeUpstreamMessages(payload, undefined, log); + const msg = (payload.messages as Record[])[0]; + assert.deepEqual(msg.content, []); + assert.equal(logged.length, 1); + assert.match(logged[0], /Dropped unsupported content part type="thinking"/); +}); diff --git a/tests/unit/chatcore-compression-cache-stats.test.ts b/tests/unit/chatcore-compression-cache-stats.test.ts new file mode 100644 index 0000000000..fe1e3904b2 --- /dev/null +++ b/tests/unit/chatcore-compression-cache-stats.test.ts @@ -0,0 +1,90 @@ +// Characterization of recordCompressionCacheStats — the compression cache-stats hook extracted +// from handleChatCore's request-setup compression path (chatCore god-file decomposition, #3501). +// Fire-and-forget; uses a real temp DB and polls compression_cache_stats. Locks: a compressed +// prompt records a cache-stats row with the resolved provider/mode/tokens-saved, and the helper +// returns synchronously without throwing (fail-open). +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-comp-cache-test-")); +process.env.DATA_DIR = testDataDir; + +const coreDb = await import("../../src/lib/db/core.ts"); +const { recordCompressionCacheStats } = await import( + "../../open-sse/handlers/chatCore/compressionCacheStats.ts" +); + +function rowsFor(provider: string): Array> { + return coreDb + .getDbInstance() + .prepare( + "SELECT provider, compression_mode AS mode, tokens_saved_compression AS saved FROM compression_cache_stats WHERE provider = ?" + ) + .all(provider) as Array>; +} + +async function waitForRows(provider: string, min: number, timeoutMs = 3000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (rowsFor(provider).length >= min) break; + await new Promise((r) => setTimeout(r, 25)); + } + return rowsFor(provider); +} + +before(async () => { + await coreDb.ensureDbInitialized(); +}); + +after(() => { + coreDb.resetDbInstance(); + try { + fs.rmSync(testDataDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +}); + +test("returns synchronously without throwing (fire-and-forget)", () => { + assert.doesNotThrow(() => + recordCompressionCacheStats({ + compressionInputBody: { messages: [{ role: "user", content: "hi" }] }, + provider: "openai", + targetFormat: "openai", + effectiveModel: "gpt-x", + mode: "balanced", + stats: { originalTokens: 100, compressedTokens: 60 }, + }) + ); +}); + +test("records a cache-stats row with the resolved provider/mode/tokens-saved", async () => { + recordCompressionCacheStats({ + compressionInputBody: { messages: [{ role: "user", content: "hello world" }] }, + provider: "ccs-prov", + targetFormat: "openai", + effectiveModel: "gpt-ccs", + mode: "aggressive", + stats: { originalTokens: 200, compressedTokens: 75 }, + }); + const rows = await waitForRows("ccs-prov", 1); + assert.equal(rows.length, 1); + assert.equal(rows[0].mode, "aggressive"); + assert.equal(rows[0].saved, 125); +}); + +test("clamps negative tokens-saved to 0", async () => { + recordCompressionCacheStats({ + compressionInputBody: { messages: [] }, + provider: "ccs-neg", + targetFormat: "openai", + effectiveModel: "gpt-x", + mode: "balanced", + stats: { originalTokens: 50, compressedTokens: 80 }, + }); + const rows = await waitForRows("ccs-neg", 1); + assert.equal(rows[0].saved, 0); +}); diff --git a/tests/unit/chatcore-compression-usage-receipt.test.ts b/tests/unit/chatcore-compression-usage-receipt.test.ts new file mode 100644 index 0000000000..fd3cb9711a --- /dev/null +++ b/tests/unit/chatcore-compression-usage-receipt.test.ts @@ -0,0 +1,78 @@ +// tests/unit/chatcore-compression-usage-receipt.test.ts +// Characterization of attachCompressionUsageReceiptAfterAnalytics — the fire-and-forget compression +// usage-receipt attachment extracted from handleChatCore (chatCore god-file decomposition, #3501). +// Uses a real temp DB: inserts a compression_analytics row, attaches a receipt after pendingWrite, +// and asserts the aggregated realUsage. Also locks the best-effort error swallowing. +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-compression-receipt-test-")); +process.env.DATA_DIR = testDataDir; + +const coreDb = await import("../../src/lib/db/core.ts"); +const { insertCompressionAnalyticsRow, getCompressionAnalyticsSummary } = await import( + "../../src/lib/db/compressionAnalytics.ts" +); +const { attachCompressionUsageReceiptAfterAnalytics } = await import( + "../../open-sse/handlers/chatCore/compressionUsageReceipt.ts" +); + +const tick = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +before(async () => { + await coreDb.ensureDbInitialized(); +}); + +after(() => { + coreDb.resetDbInstance(); + fs.rmSync(testDataDir, { recursive: true, force: true }); +}); + +test("attaches the usage receipt only after pendingWrite resolves", async () => { + insertCompressionAnalyticsRow({ + timestamp: new Date().toISOString(), + mode: "test", + original_tokens: 100, + compressed_tokens: 60, + tokens_saved: 40, + request_id: "req-1", + }); + + let pendingResolved = false; + const pendingWrite = new Promise((res) => + setTimeout(() => { + pendingResolved = true; + res(); + }, 20) + ); + + attachCompressionUsageReceiptAfterAnalytics( + { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + "provider", + { pendingWrite, skillRequestId: "req-1" } + ); + + await tick(80); + assert.equal(pendingResolved, true, "pendingWrite should have resolved first"); + + const summary = getCompressionAnalyticsSummary(); + assert.equal(summary.realUsage.requestsWithReceipts, 1); + assert.equal(summary.realUsage.promptTokens, 10); + assert.equal(summary.realUsage.completionTokens, 5); +}); + +test("swallows the no-matching-row case without throwing or recording a receipt", async () => { + assert.doesNotThrow(() => + attachCompressionUsageReceiptAfterAnalytics( + { prompt_tokens: 1, total_tokens: 1 }, + "provider", + { pendingWrite: null, skillRequestId: "does-not-exist" } + ) + ); + await tick(40); + const summary = getCompressionAnalyticsSummary(); + assert.equal(summary.realUsage.requestsWithReceipts, 1); +}); diff --git a/tests/unit/chatcore-context-editing-telemetry.test.ts b/tests/unit/chatcore-context-editing-telemetry.test.ts new file mode 100644 index 0000000000..bfb6cad524 --- /dev/null +++ b/tests/unit/chatcore-context-editing-telemetry.test.ts @@ -0,0 +1,106 @@ +// Characterization of recordContextEditingTelemetryHook — the Claude-only context-editing +// telemetry hook extracted from handleChatCore's non-streaming success path (chatCore god-file +// decomposition, #3501). The work is a fire-and-forget IIFE; uses a real temp DB and polls the +// captured log. Locks: the enabled+claude guard, the no-telemetry no-op, and that a valid +// applied_edits payload records and logs the cleared-token receipt. +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-ctxedit-test-")); +process.env.DATA_DIR = testDataDir; + +const coreDb = await import("../../src/lib/db/core.ts"); +const { recordContextEditingTelemetryHook } = await import( + "../../open-sse/handlers/chatCore/contextEditingTelemetry.ts" +); + +function makeLog() { + const debug: string[] = []; + return { + log: { debug: (tag: string, msg: string) => debug.push(`${tag} ${msg}`) }, + debug, + }; +} + +const telemetryBody = { + context_management: { + applied_edits: [{ cleared_input_tokens: 120, cleared_tool_uses: 3 }], + }, +}; + +async function waitFor(pred: () => boolean, timeoutMs = 3000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline && !pred()) { + await new Promise((r) => setTimeout(r, 25)); + } +} + +before(async () => { + await coreDb.ensureDbInitialized(); +}); + +after(() => { + coreDb.resetDbInstance(); + try { + fs.rmSync(testDataDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +}); + +test("disabled context-editing is a no-op", async () => { + const { log, debug } = makeLog(); + recordContextEditingTelemetryHook({ + contextEditingEnabled: false, + provider: "claude", + responseBody: telemetryBody, + skillRequestId: "req-1", + log, + }); + await new Promise((r) => setTimeout(r, 100)); + assert.equal(debug.length, 0); +}); + +test("non-claude provider is a no-op", async () => { + const { log, debug } = makeLog(); + recordContextEditingTelemetryHook({ + contextEditingEnabled: true, + provider: "openai", + responseBody: telemetryBody, + skillRequestId: "req-2", + log, + }); + await new Promise((r) => setTimeout(r, 100)); + assert.equal(debug.length, 0); +}); + +test("valid applied_edits records and logs the cleared-token receipt", async () => { + const { log, debug } = makeLog(); + recordContextEditingTelemetryHook({ + contextEditingEnabled: true, + provider: "claude", + responseBody: telemetryBody, + skillRequestId: "req-3", + log, + }); + await waitFor(() => debug.length > 0); + assert.ok(debug.length >= 1, "expected a CONTEXT_EDITING debug line"); + assert.match(debug[0], /CONTEXT_EDITING/); + assert.match(debug[0], /cleared 120 input tokens \/ 3 tool uses \(1 edits\)/); +}); + +test("response without applied_edits is a silent no-op (no throw)", async () => { + const { log, debug } = makeLog(); + recordContextEditingTelemetryHook({ + contextEditingEnabled: true, + provider: "claude", + responseBody: { choices: [] }, + skillRequestId: "req-4", + log, + }); + await new Promise((r) => setTimeout(r, 100)); + assert.equal(debug.length, 0); +}); diff --git a/tests/unit/chatcore-execution-credentials.test.ts b/tests/unit/chatcore-execution-credentials.test.ts new file mode 100644 index 0000000000..8eec6ee344 --- /dev/null +++ b/tests/unit/chatcore-execution-credentials.test.ts @@ -0,0 +1,98 @@ +// tests/unit/chatcore-execution-credentials.test.ts +// Characterization of resolveExecutionCredentials — the per-execution credentials builder extracted +// from handleChatCore (chatCore god-file decomposition, #3501). Locks: the native-Codex passthrough +// endpoint override, the Azure AI / OCI apiType=responses forcing (+ responses-upstream marker) only +// under the OpenAI Responses target format, respecting an explicit apiType, and the Claude Code +// session-id threading. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { resolveExecutionCredentials } from "../../open-sse/handlers/chatCore/executionCredentials.ts"; + +const RESPONSES = "openai-responses"; + +const base = { + credentials: { providerSpecificData: { foo: "bar" } } as Record, + nativeCodexPassthrough: false, + endpointPath: "/v1/responses", + targetFormat: "openai", + provider: "openai", + ccSessionId: null, +}; + +test("non-passthrough leaves credentials without a requestEndpointPath", () => { + const out = resolveExecutionCredentials({ ...base }) as Record; + assert.equal("requestEndpointPath" in out, false); + assert.deepEqual(out.providerSpecificData, { foo: "bar" }); +}); + +test("native Codex passthrough injects requestEndpointPath", () => { + const out = resolveExecutionCredentials({ + ...base, + nativeCodexPassthrough: true, + }) as Record; + assert.equal(out.requestEndpointPath, "/v1/responses"); +}); + +test("azure-ai + responses target forces apiType=responses and the upstream marker", () => { + const out = resolveExecutionCredentials({ + ...base, + provider: "azure-ai", + targetFormat: RESPONSES, + }) as Record; + const psd = out.providerSpecificData as Record; + assert.equal(psd.apiType, "responses"); + assert.equal(psd._omnirouteForceResponsesUpstream, true); +}); + +test("a non-responses apiType is forced to responses under the responses target", () => { + const out = resolveExecutionCredentials({ + ...base, + provider: "oci", + targetFormat: RESPONSES, + credentials: { providerSpecificData: { apiType: "chat" } }, + }) as Record; + const psd = out.providerSpecificData as Record; + assert.equal(psd.apiType, "responses"); + assert.equal(psd._omnirouteForceResponsesUpstream, true); +}); + +test("an explicit apiType=responses is preserved (guard short-circuits the reassignment)", () => { + const out = resolveExecutionCredentials({ + ...base, + provider: "oci", + targetFormat: RESPONSES, + credentials: { providerSpecificData: { apiType: "responses" } }, + }) as Record; + const psd = out.providerSpecificData as Record; + assert.equal(psd.apiType, "responses"); + assert.equal(psd._omnirouteForceResponsesUpstream, true); +}); + +test("non azure/oci providers never get apiType forcing", () => { + const out = resolveExecutionCredentials({ + ...base, + provider: "openai", + targetFormat: RESPONSES, + }) as Record; + const psd = out.providerSpecificData as Record; + assert.equal(psd.apiType, undefined); + assert.equal(psd._omnirouteForceResponsesUpstream, undefined); +}); + +test("ccSessionId is threaded into providerSpecificData when present", () => { + const out = resolveExecutionCredentials({ + ...base, + ccSessionId: "sess-123", + }) as Record; + const psd = out.providerSpecificData as Record; + assert.equal(psd.ccSessionId, "sess-123"); + assert.equal(psd.foo, "bar"); +}); + +test("missing providerSpecificData defaults to an empty object", () => { + const out = resolveExecutionCredentials({ + ...base, + credentials: { connectionId: "c1" }, + }) as Record; + assert.deepEqual(out.providerSpecificData, {}); +}); diff --git a/tests/unit/chatcore-executor-client-headers.test.ts b/tests/unit/chatcore-executor-client-headers.test.ts new file mode 100644 index 0000000000..92ff2a8c5b --- /dev/null +++ b/tests/unit/chatcore-executor-client-headers.test.ts @@ -0,0 +1,42 @@ +// tests/unit/chatcore-executor-client-headers.test.ts +// Characterization of buildExecutorClientHeaders — the executor client-header normalizer extracted +// from handleChatCore (chatCore god-file decomposition, #3501). Locks: Headers and plain-object +// normalization, non-string value skipping, User-Agent backfill (both casings, only when absent), +// and the null-when-empty return. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildExecutorClientHeaders } from "../../open-sse/handlers/chatCore/executorClientHeaders.ts"; + +test("returns null for empty / nullish inputs", () => { + assert.equal(buildExecutorClientHeaders(null), null); + assert.equal(buildExecutorClientHeaders(undefined), null); + assert.equal(buildExecutorClientHeaders({}), null); +}); + +test("normalizes a Headers instance", () => { + const h = new Headers({ "X-Test": "1", "content-type": "application/json" }); + const out = buildExecutorClientHeaders(h); + assert.equal(out?.["content-type"], "application/json"); + assert.equal(out?.["x-test"], "1"); +}); + +test("normalizes a plain object and skips non-string values", () => { + const out = buildExecutorClientHeaders({ a: "1", b: 2, c: null } as Record); + assert.deepEqual(out, { a: "1" }); +}); + +test("backfills the User-Agent in both casings when absent", () => { + const out = buildExecutorClientHeaders({ a: "1" }, " MyAgent/1.0 "); + assert.equal(out?.["user-agent"], "MyAgent/1.0"); + assert.equal(out?.["User-Agent"], "MyAgent/1.0"); +}); + +test("does not overwrite an existing user-agent header", () => { + const out = buildExecutorClientHeaders({ "user-agent": "Existing/9" }, "MyAgent/1.0"); + assert.equal(out?.["user-agent"], "Existing/9"); + assert.equal(out?.["User-Agent"], undefined); +}); + +test("a trimmed-empty user agent does not create headers on its own", () => { + assert.equal(buildExecutorClientHeaders({}, " "), null); +}); diff --git a/tests/unit/chatcore-executor-proxy.test.ts b/tests/unit/chatcore-executor-proxy.test.ts new file mode 100644 index 0000000000..98e5700565 --- /dev/null +++ b/tests/unit/chatcore-executor-proxy.test.ts @@ -0,0 +1,80 @@ +// tests/unit/chatcore-executor-proxy.test.ts +// Characterization of resolveExecutorWithProxy — the upstream-proxy executor resolver extracted from +// handleChatCore (chatCore god-file decomposition, #3501). Exercises the REAL config path through a +// temp DB: disabled/native → the provider's own executor; cliproxyapi → the passthrough executor; +// fallback → a distinct wrapper that owns its own execute(). The wrapper's retry behaviour is not +// invoked here (it would hit the network); the existing cliproxyapi-fallback-wiring.test.ts covers +// the surrounding wiring. +import { test, before, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-executor-proxy-test-")); +process.env.DATA_DIR = testDataDir; + +// Dynamic imports AFTER DATA_DIR is set so core.ts picks up the temp path. +const coreDb = await import("../../src/lib/db/core.ts"); +const upstreamProxyDb = await import("../../src/lib/db/upstreamProxy.ts"); +const { resolveExecutorWithProxy } = await import( + "../../open-sse/handlers/chatCore/executorProxy.ts" +); +const { getExecutor } = await import("../../open-sse/executors/index.ts"); +const { clearUpstreamProxyConfigCache } = await import( + "../../open-sse/handlers/chatCore/comboContextCache.ts" +); + +before(async () => { + await coreDb.ensureDbInitialized(); +}); + +beforeEach(() => { + clearUpstreamProxyConfigCache(); +}); + +after(() => { + coreDb.resetDbInstance(); + fs.rmSync(testDataDir, { recursive: true, force: true }); +}); + +test("no config (disabled by default) returns the provider's own executor", async () => { + clearUpstreamProxyConfigCache("openai"); + const exec = await resolveExecutorWithProxy("openai"); + assert.equal(exec, getExecutor("openai")); +}); + +test("mode 'native' returns the provider's own executor", async () => { + await upstreamProxyDb.upsertUpstreamProxyConfig({ + providerId: "openai", + mode: "native", + enabled: true, + }); + clearUpstreamProxyConfigCache("openai"); + const exec = await resolveExecutorWithProxy("openai"); + assert.equal(exec, getExecutor("openai")); +}); + +test("mode 'cliproxyapi' returns the CLIProxyAPI passthrough executor", async () => { + await upstreamProxyDb.upsertUpstreamProxyConfig({ + providerId: "anthropic", + mode: "cliproxyapi", + enabled: true, + }); + clearUpstreamProxyConfigCache("anthropic"); + const exec = await resolveExecutorWithProxy("anthropic"); + assert.equal(exec, getExecutor("cliproxyapi")); +}); + +test("mode 'fallback' returns a distinct wrapper owning its own execute()", async () => { + await upstreamProxyDb.upsertUpstreamProxyConfig({ + providerId: "openai", + mode: "fallback", + enabled: true, + }); + clearUpstreamProxyConfigCache("openai"); + const exec = await resolveExecutorWithProxy("openai"); + assert.notEqual(exec, getExecutor("openai")); + assert.notEqual(exec, getExecutor("cliproxyapi")); + assert.equal(typeof exec.execute, "function"); +}); diff --git a/tests/unit/chatcore-gamification-event.test.ts b/tests/unit/chatcore-gamification-event.test.ts new file mode 100644 index 0000000000..edc5afb70c --- /dev/null +++ b/tests/unit/chatcore-gamification-event.test.ts @@ -0,0 +1,74 @@ +// Characterization of emitRequestGamificationEvent — the per-request gamification hook extracted +// from handleChatCore's non-streaming AND streaming success paths (chatCore god-file +// decomposition, #3501). The inline block was duplicated verbatim; this locks the shared helper. +// Uses a real temp DB and polls xp_audit_log (the emit is fire-and-forget). Locks: the missing +// api-key guard (no-op) and that a valid call awards the "request" XP audit row. +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-gamification-test-")); +process.env.DATA_DIR = testDataDir; + +const coreDb = await import("../../src/lib/db/core.ts"); +const { emitRequestGamificationEvent } = await import( + "../../open-sse/handlers/chatCore/gamificationEvent.ts" +); + +function countAuditRows(apiKeyId: string): number { + const row = coreDb + .getDbInstance() + .prepare("SELECT COUNT(*) AS n FROM xp_audit_log WHERE api_key_id = ?") + .get(apiKeyId) as { n: number } | undefined; + return row?.n ?? 0; +} + +async function waitForAuditRows(apiKeyId: string, min: number, timeoutMs = 3000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (countAuditRows(apiKeyId) >= min) break; + await new Promise((r) => setTimeout(r, 25)); + } + return countAuditRows(apiKeyId); +} + +before(async () => { + await coreDb.ensureDbInitialized(); +}); + +after(() => { + coreDb.resetDbInstance(); + try { + fs.rmSync(testDataDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +}); + +test("missing apiKeyId is a no-op and never throws", async () => { + await assert.doesNotReject( + emitRequestGamificationEvent({ apiKeyId: null, model: "m", provider: "p" }) + ); + await assert.doesNotReject( + emitRequestGamificationEvent({ apiKeyId: undefined, model: "m", provider: "p" }) + ); + await assert.doesNotReject( + emitRequestGamificationEvent({ apiKeyId: "", model: "m", provider: "p" }) + ); +}); + +test("valid apiKeyId awards a 'request' XP audit row (fire-and-forget)", async () => { + const apiKeyId = "gamify-key-1"; + assert.equal(countAuditRows(apiKeyId), 0); + await emitRequestGamificationEvent({ apiKeyId, model: "gpt-x", provider: "openai" }); + const n = await waitForAuditRows(apiKeyId, 1); + assert.ok(n >= 1, "expected at least one xp_audit_log row for the request action"); +}); + +test("never throws even when the emit path runs fully", async () => { + await assert.doesNotReject( + emitRequestGamificationEvent({ apiKeyId: "gamify-key-2", model: null, provider: null }) + ); +}); diff --git a/tests/unit/chatcore-non-streaming-response-body.test.ts b/tests/unit/chatcore-non-streaming-response-body.test.ts new file mode 100644 index 0000000000..41d52a2e37 --- /dev/null +++ b/tests/unit/chatcore-non-streaming-response-body.test.ts @@ -0,0 +1,33 @@ +// tests/unit/chatcore-non-streaming-response-body.test.ts +// Characterization of readNonStreamingResponseBody — the non-streaming body reader extracted from +// handleChatCore (chatCore god-file decomposition, #3501). Locks: the response.text() fallback path +// (non-stream, or non-SSE content type) and the SSE-drain path that concatenates chunks until the +// stream closes. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readNonStreamingResponseBody } from "../../open-sse/handlers/chatCore/nonStreamingResponseBody.ts"; + +test("falls back to response.text() when upstream is not streaming", async () => { + const out = await readNonStreamingResponseBody(new Response("hello"), "application/json", false); + assert.equal(out, "hello"); +}); + +test("falls back to response.text() for a non-SSE content type even when streaming", async () => { + const out = await readNonStreamingResponseBody(new Response("plain"), "application/json", true); + assert.equal(out, "plain"); +}); + +test("drains an SSE stream chunk-by-chunk and concatenates until close", async () => { + const enc = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(enc.encode("data: {\"a\":1}\n\n")); + controller.enqueue(enc.encode("data: {\"b\":2}\n\n")); + controller.close(); + }, + }); + const response = new Response(body, { headers: { "Content-Type": "text/event-stream" } }); + const out = await readNonStreamingResponseBody(response, "text/event-stream", true); + assert.ok(out.includes('"a":1')); + assert.ok(out.includes('"b":2')); +}); diff --git a/tests/unit/chatcore-non-streaming-response-parse.test.ts b/tests/unit/chatcore-non-streaming-response-parse.test.ts new file mode 100644 index 0000000000..21b761fa04 --- /dev/null +++ b/tests/unit/chatcore-non-streaming-response-parse.test.ts @@ -0,0 +1,157 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { parseNonStreamingResponseBody } from "@omniroute/open-sse/handlers/chatCore/nonStreamingResponseParse.ts"; + +// Minimal Response stub: only the surface parseNonStreamingResponseBody touches +// (headers.get + text()). upstreamStream is passed false so readNonStreamingResponseBody +// always takes the buffered response.text() path. +function makeResponse(body: string, contentType: string): Response { + return { + headers: { + get: (name: string) => + name.toLowerCase() === "content-type" ? contentType : null, + }, + text: async () => body, + body: null, + } as unknown as Response; +} + +const baseOpts = { + upstreamStream: false, + providerHeaders: null, + finalBody: null, + targetFormat: "openai", + model: "gpt-4o-mini", +}; + +test("valid JSON body → ok with parsed object and targetFormat", async () => { + const payload = { id: "x", choices: [{ message: { content: "hi" } }] }; + const res = await parseNonStreamingResponseBody({ + ...baseOpts, + providerResponse: makeResponse(JSON.stringify(payload), "application/json"), + }); + assert.equal(res.kind, "ok"); + if (res.kind !== "ok") return; + assert.deepEqual(res.responseBody, payload); + assert.equal(res.responsePayloadFormat, "openai"); + assert.equal(res.looksLikeSSE, false); +}); + +test("empty body (non-SSE) → ok with empty object", async () => { + const res = await parseNonStreamingResponseBody({ + ...baseOpts, + providerResponse: makeResponse("", "application/json"), + }); + assert.equal(res.kind, "ok"); + if (res.kind !== "ok") return; + assert.deepEqual(res.responseBody, {}); + assert.equal(res.looksLikeSSE, false); +}); + +test("invalid JSON → invalid_json with short message + detailed error", async () => { + const res = await parseNonStreamingResponseBody({ + ...baseOpts, + providerResponse: makeResponse("{not json", "application/json"), + }); + assert.equal(res.kind, "invalid_json"); + if (res.kind !== "invalid_json") return; + assert.equal(res.message, "Invalid JSON response from provider"); + assert.match(res.detailedError, /^Invalid JSON response from provider \(error: /); + assert.match(res.detailedError, /\{not json/); + assert.equal(res.looksLikeSSE, false); +}); + +test("valid SSE payload (by content-type) → ok with SSE-derived format", async () => { + const sse = + 'data: {"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{"content":"hello"},"index":0,"finish_reason":null}]}\n\n' + + 'data: {"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{},"index":0,"finish_reason":"stop"}]}\n\n' + + "data: [DONE]\n\n"; + const res = await parseNonStreamingResponseBody({ + ...baseOpts, + providerResponse: makeResponse(sse, "text/event-stream"), + }); + assert.equal(res.kind, "ok"); + if (res.kind !== "ok") return; + assert.equal(res.looksLikeSSE, true); + assert.ok(res.responseBody && typeof res.responseBody === "object"); + assert.equal(typeof res.responsePayloadFormat, "string"); +}); + +test("SSE detected by body heuristic even with non-stream content-type", async () => { + const sse = + 'data: {"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{"content":"hi"},"index":0,"finish_reason":"stop"}]}\n\n' + + "data: [DONE]\n\n"; + const res = await parseNonStreamingResponseBody({ + ...baseOpts, + providerResponse: makeResponse(sse, "text/plain"), + }); + assert.equal(res.looksLikeSSE, true); +}); + +test("error-only SSE (no choices) → invalid_sse surfacing the upstream error (#3324)", async () => { + const sse = 'data: {"error":{"message":"Devin CLI not found in PATH"}}\n\n'; + const res = await parseNonStreamingResponseBody({ + ...baseOpts, + providerResponse: makeResponse(sse, "text/event-stream"), + }); + assert.equal(res.kind, "invalid_sse"); + if (res.kind !== "invalid_sse") return; + assert.equal(res.message, "Devin CLI not found in PATH"); + assert.equal(res.looksLikeSSE, true); +}); + +test("unparseable SSE with no embedded error → generic invalid_sse message", async () => { + const sse = "data: not-json-at-all\n\n"; + const res = await parseNonStreamingResponseBody({ + ...baseOpts, + providerResponse: makeResponse(sse, "text/event-stream"), + }); + assert.equal(res.kind, "invalid_sse"); + if (res.kind !== "invalid_sse") return; + assert.equal(res.message, "Invalid SSE response for non-streaming request"); +}); + +test("normalizedProviderPayload is present on every branch", async () => { + const ok = await parseNonStreamingResponseBody({ + ...baseOpts, + providerResponse: makeResponse('{"a":1}', "application/json"), + }); + assert.ok("normalizedProviderPayload" in ok); + const bad = await parseNonStreamingResponseBody({ + ...baseOpts, + providerResponse: makeResponse("{bad", "application/json"), + }); + assert.ok("normalizedProviderPayload" in bad); +}); + +test("buffering log fires debug when stream was expected, warn otherwise", async () => { + const sse = + 'data: {"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{"content":"hi"},"index":0,"finish_reason":"stop"}]}\n\n' + + "data: [DONE]\n\n"; + const debugCalls: string[] = []; + const warnCalls: string[] = []; + const log = { + debug: (_tag: string, msg: string) => debugCalls.push(msg), + warn: (_tag: string, msg: string) => warnCalls.push(msg), + }; + + // upstreamStream=true → expected → debug path + await parseNonStreamingResponseBody({ + ...baseOpts, + upstreamStream: true, + providerResponse: makeResponse(sse, "text/event-stream"), + log, + }); + assert.equal(debugCalls.length, 1); + assert.equal(warnCalls.length, 0); + + // upstreamStream=false + no accept/stream hints → unexpected → warn path + await parseNonStreamingResponseBody({ + ...baseOpts, + upstreamStream: false, + providerResponse: makeResponse(sse, "text/event-stream"), + log, + }); + assert.equal(warnCalls.length, 1); +}); diff --git a/tests/unit/chatcore-non-streaming-usage-stats.test.ts b/tests/unit/chatcore-non-streaming-usage-stats.test.ts new file mode 100644 index 0000000000..0065a83a75 --- /dev/null +++ b/tests/unit/chatcore-non-streaming-usage-stats.test.ts @@ -0,0 +1,129 @@ +// Characterization of recordNonStreamingUsageStats — the per-request usage-stats persistence +// extracted from handleChatCore's non-streaming success path (chatCore god-file decomposition, +// #3501). Uses a real temp DB and polls usage_history (saveRequestUsage is async + +// fire-and-forget). Locks: the non-object usage guard (no-op), the field mapping +// (provider/model/connection/api-key/tokens/serviceTier), and the trace-log line format. +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-nonstream-usage-test-")); +process.env.DATA_DIR = testDataDir; + +const coreDb = await import("../../src/lib/db/core.ts"); +const { getUsageHistory } = await import("../../src/lib/usage/usageHistory.ts"); +const { recordNonStreamingUsageStats } = await import( + "../../open-sse/handlers/chatCore/nonStreamingUsageStats.ts" +); + +function baseCtx(overrides: Record = {}) { + return { + traceEnabled: false, + provider: "openai", + connectionId: "conn-12345678abc", + model: "gpt-x", + startTime: Date.now() - 50, + apiKeyInfo: { id: "key-1", name: "Key One" }, + effectiveServiceTier: "standard", + isCombo: false, + comboStrategy: null, + ...overrides, + } as Parameters[1]; +} + +async function rowsFor(provider: string): Promise>> { + return (await getUsageHistory({ provider })) as Array>; +} + +async function waitForRows(provider: string, min: number, timeoutMs = 3000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const rows = await rowsFor(provider); + if (rows.length >= min) return rows; + await new Promise((r) => setTimeout(r, 25)); + } + return rowsFor(provider); +} + +before(async () => { + await coreDb.ensureDbInitialized(); +}); + +after(() => { + coreDb.resetDbInstance(); + try { + fs.rmSync(testDataDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +}); + +test("non-object usage is a no-op (null, undefined, number, string)", async () => { + const before = (await rowsFor("guard-prov")).length; + for (const bad of [null, undefined, 42, "usage", true]) { + recordNonStreamingUsageStats(bad, baseCtx({ provider: "guard-prov" })); + } + // give any (erroneous) async write a chance to land, then assert nothing was persisted + await new Promise((r) => setTimeout(r, 100)); + const after = (await rowsFor("guard-prov")).length; + assert.equal(after, before); +}); + +test("valid usage persists a row with the mapped fields", async () => { + recordNonStreamingUsageStats( + { prompt_tokens: 11, completion_tokens: 7 }, + baseCtx({ provider: "map-prov", model: "gpt-map", connectionId: "conn-abc", isCombo: false }) + ); + const rows = await waitForRows("map-prov", 1); + assert.equal(rows.length, 1); + const row = rows[0] as { + provider: string; + model: string; + connectionId: string; + apiKeyId: string; + apiKeyName: string; + success: boolean; + status: string; + tokens: { input: number; output: number }; + }; + assert.equal(row.provider, "map-prov"); + assert.equal(row.model, "gpt-map"); + assert.equal(row.connectionId, "conn-abc"); + assert.equal(row.apiKeyId, "key-1"); + assert.equal(row.apiKeyName, "Key One"); + assert.equal(row.success, true); + assert.equal(row.status, "200"); + assert.equal(row.tokens.input, 11); + assert.equal(row.tokens.output, 7); +}); + +test("falls back to 'unknown' provider/model when absent", async () => { + recordNonStreamingUsageStats( + { prompt_tokens: 1, completion_tokens: 1 }, + baseCtx({ provider: null, model: null, apiKeyInfo: null }) + ); + const rows = await waitForRows("unknown", 1); + const mine = rows.find((r) => (r as { model?: string }).model === "unknown"); + assert.ok(mine, "expected a row with provider/model 'unknown'"); +}); + +test("trace log emits a [USAGE] line with the upper-cased provider when traceEnabled", () => { + const original = console.log; + const captured: string[] = []; + console.log = (...args: unknown[]) => { + captured.push(args.map(String).join(" ")); + }; + try { + recordNonStreamingUsageStats( + { prompt_tokens: 3, completion_tokens: 2 }, + baseCtx({ provider: "trace-prov", traceEnabled: true }) + ); + } finally { + console.log = original; + } + const usageLine = captured.find((l) => l.includes("[USAGE]")); + assert.ok(usageLine, "expected a [USAGE] trace line"); + assert.match(usageLine as string, /TRACE-PROV/); +}); diff --git a/tests/unit/chatcore-plugin-onresponse.test.ts b/tests/unit/chatcore-plugin-onresponse.test.ts new file mode 100644 index 0000000000..a7b733dd91 --- /dev/null +++ b/tests/unit/chatcore-plugin-onresponse.test.ts @@ -0,0 +1,73 @@ +// Characterization of runPluginOnResponseHook — the plugin onResponse hook extracted from +// handleChatCore's streaming finalization (chatCore god-file decomposition, #3501). Hooks are +// in-memory (no DB). Locks: a registered onResponse hook receives the request context + status-200 +// response, and the helper is fire-and-forget / fail-open (no registered hooks → no-op). +import { test, after } from "node:test"; +import assert from "node:assert/strict"; + +const { registerHook, unregisterHook } = await import("../../src/lib/plugins/hooks.ts"); +const { runPluginOnResponseHook } = await import( + "../../open-sse/handlers/chatCore/pluginOnResponse.ts" +); + +async function waitFor(pred: () => boolean, timeoutMs = 2000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline && !pred()) { + await new Promise((r) => setTimeout(r, 10)); + } +} + +after(() => { + unregisterHook("onResponse", "test-onresponse-plugin"); +}); + +test("no registered hooks → resolves without throwing (no-op)", async () => { + await assert.doesNotReject( + runPluginOnResponseHook({ + requestId: "req-noop", + body: { messages: [] }, + model: "gpt-x", + provider: "openai", + apiKeyInfo: null, + }) + ); +}); + +test("registered onResponse hook receives the request context and status-200 response", async () => { + let captured: Record | undefined; + registerHook("onResponse", "test-onresponse-plugin", async (ctx: Record) => { + captured = ctx; + return {}; + }); + + await runPluginOnResponseHook({ + requestId: "req-42", + body: { messages: [{ role: "user", content: "hi" }] }, + model: "gpt-4o", + provider: "openai", + apiKeyInfo: { id: "key-1" }, + }); + + await waitFor(() => captured !== undefined); + assert.ok(captured, "expected the onResponse hook to be invoked"); + assert.equal(captured!.requestId, "req-42"); + assert.equal(captured!.model, "gpt-4o"); + assert.equal(captured!.provider, "openai"); + assert.deepEqual(captured!.response, { status: 200 }); +}); + +test("a throwing hook never rejects the caller (fail-open)", async () => { + registerHook("onResponse", "test-onresponse-plugin", async () => { + throw new Error("boom"); + }); + await assert.doesNotReject( + runPluginOnResponseHook({ + requestId: "req-throw", + body: {}, + model: "m", + provider: "p", + apiKeyInfo: null, + }) + ); + await new Promise((r) => setTimeout(r, 30)); +}); diff --git a/tests/unit/chatcore-quota-share-consumption.test.ts b/tests/unit/chatcore-quota-share-consumption.test.ts new file mode 100644 index 0000000000..7bf26fc207 --- /dev/null +++ b/tests/unit/chatcore-quota-share-consumption.test.ts @@ -0,0 +1,83 @@ +// Characterization of scheduleQuotaShareConsumption — the non-streaming shared-quota POST-hook +// extracted from handleChatCore (chatCore god-file decomposition, #3501). Fire-and-forget / +// fail-open. Locks: the guard (missing api-key id OR connection id → no-op) and that a valid call +// never throws (the underlying scheduleRecordConsumption is setImmediate + fail-open). +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-quota-share-test-")); +process.env.DATA_DIR = testDataDir; + +const coreDb = await import("../../src/lib/db/core.ts"); +const { scheduleQuotaShareConsumption } = await import( + "../../open-sse/handlers/chatCore/quotaShareConsumption.ts" +); + +const validUsage = { prompt_tokens: 10, completion_tokens: 5 }; + +before(async () => { + await coreDb.ensureDbInitialized(); +}); + +after(() => { + coreDb.resetDbInstance(); + try { + fs.rmSync(testDataDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +}); + +test("missing apiKeyId is a no-op (never throws)", async () => { + await assert.doesNotReject( + scheduleQuotaShareConsumption({ + apiKeyId: null, + connectionId: "conn-1", + provider: "openai", + usage: validUsage, + estimatedCost: 0.01, + }) + ); +}); + +test("missing connectionId is a no-op (never throws)", async () => { + await assert.doesNotReject( + scheduleQuotaShareConsumption({ + apiKeyId: "key-1", + connectionId: null, + provider: "openai", + usage: validUsage, + estimatedCost: 0.01, + }) + ); +}); + +test("valid input schedules consumption and never throws (fire-and-forget)", async () => { + await assert.doesNotReject( + scheduleQuotaShareConsumption({ + apiKeyId: "key-1", + connectionId: "conn-1", + provider: "openai", + usage: validUsage, + estimatedCost: 0.0123, + }) + ); + // give the scheduled setImmediate consumption a tick to run (fail-open, no assertion on DB + // state — pool config is out of scope; this proves the hook does not throw end-to-end) + await new Promise((r) => setTimeout(r, 50)); +}); + +test("absent provider falls back without throwing", async () => { + await assert.doesNotReject( + scheduleQuotaShareConsumption({ + apiKeyId: "key-2", + connectionId: "conn-2", + provider: null, + usage: null, + estimatedCost: 0, + }) + ); +}); diff --git a/tests/unit/chatcore-skills-format.test.ts b/tests/unit/chatcore-skills-format.test.ts new file mode 100644 index 0000000000..63f5f6d555 --- /dev/null +++ b/tests/unit/chatcore-skills-format.test.ts @@ -0,0 +1,32 @@ +// tests/unit/chatcore-skills-format.test.ts +// Characterization of getSkillsProviderForFormat / getSkillsModelIdForFormat — the skills-format +// mappers extracted from handleChatCore (chatCore god-file decomposition, #3501). Locks the +// claude→anthropic/claude, gemini→google/gemini and default→openai/openai mappings. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + getSkillsProviderForFormat, + getSkillsModelIdForFormat, +} from "../../open-sse/handlers/chatCore/skillsFormat.ts"; + +test("claude format maps to anthropic provider and claude model id", () => { + assert.equal(getSkillsProviderForFormat("claude"), "anthropic"); + assert.equal(getSkillsModelIdForFormat("claude"), "claude"); +}); + +test("gemini format maps to google provider and gemini model id", () => { + assert.equal(getSkillsProviderForFormat("gemini"), "google"); + assert.equal(getSkillsModelIdForFormat("gemini"), "gemini"); +}); + +test("openai format maps to openai provider and openai model id", () => { + assert.equal(getSkillsProviderForFormat("openai"), "openai"); + assert.equal(getSkillsModelIdForFormat("openai"), "openai"); +}); + +test("unknown / responses formats fall back to openai for both mappers", () => { + assert.equal(getSkillsProviderForFormat("openai-responses"), "openai"); + assert.equal(getSkillsModelIdForFormat("openai-responses"), "openai"); + assert.equal(getSkillsProviderForFormat("gemini-cli"), "openai"); + assert.equal(getSkillsModelIdForFormat("totally-unknown"), "openai"); +}); diff --git a/tests/unit/chatcore-stage-trace.test.ts b/tests/unit/chatcore-stage-trace.test.ts new file mode 100644 index 0000000000..4753af7563 --- /dev/null +++ b/tests/unit/chatcore-stage-trace.test.ts @@ -0,0 +1,50 @@ +// tests/unit/chatcore-stage-trace.test.ts +// Characterization of stageTrace — the per-request [STAGE_TRACE] checkpoint logger extracted from +// handleChatCore (chatCore god-file decomposition, #3501). Locks: the disabled no-op, the +// `${traceId} ${label} t=${elapsed}ms` format, serialized extra, and the [unserializable] fallback. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { stageTrace } from "../../open-sse/handlers/chatCore/stageTrace.ts"; + +function makeLog() { + const calls: Array<[string, string]> = []; + return { calls, log: { info: (tag: string, msg: string) => calls.push([tag, msg]) } }; +} + +test("is a no-op when tracing is disabled", () => { + const { calls, log } = makeLog(); + stageTrace("post_translation", undefined, { traceEnabled: false, startTime: 0, traceId: "abc", log }); + assert.equal(calls.length, 0); +}); + +test("emits a STAGE_TRACE line with trace id, label and elapsed ms", () => { + const { calls, log } = makeLog(); + stageTrace("post_executor", undefined, { + traceEnabled: true, + startTime: Date.now() - 5, + traceId: "abc123", + log, + }); + assert.equal(calls.length, 1); + assert.equal(calls[0][0], "STAGE_TRACE"); + assert.match(calls[0][1], /^abc123 post_executor t=\d+ms$/); +}); + +test("appends serialized extra context", () => { + const { calls, log } = makeLog(); + stageTrace("pre_executor", { attempt: 2 }, { + traceEnabled: true, + startTime: Date.now(), + traceId: "id", + log, + }); + assert.match(calls[0][1], /\{"attempt":2\}$/); +}); + +test("falls back to [unserializable] for circular extra", () => { + const { calls, log } = makeLog(); + const circular: Record = {}; + circular.self = circular; + stageTrace("x", circular, { traceEnabled: true, startTime: Date.now(), traceId: "id", log }); + assert.match(calls[0][1], /\[unserializable\]$/); +}); diff --git a/tests/unit/chatcore-stream-error-result.test.ts b/tests/unit/chatcore-stream-error-result.test.ts new file mode 100644 index 0000000000..352877046a --- /dev/null +++ b/tests/unit/chatcore-stream-error-result.test.ts @@ -0,0 +1,52 @@ +// tests/unit/chatcore-stream-error-result.test.ts +// Characterization of isSemaphoreCapacityError / createStreamingErrorResult / +// getUpstreamErrorIdentifier — streaming error-result helpers extracted from handleChatCore +// (chatCore god-file decomposition, #3501). Locks the semaphore code matching, the SSE error +// envelope shape (status, headers, `data: {...}\n\ndata: [DONE]\n\n` body, optional code/type), and +// the string-code extraction. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + isSemaphoreCapacityError, + createStreamingErrorResult, + getUpstreamErrorIdentifier, +} from "../../open-sse/handlers/chatCore/streamErrorResult.ts"; + +test("isSemaphoreCapacityError matches the two semaphore codes only", () => { + assert.equal(isSemaphoreCapacityError({ code: "SEMAPHORE_TIMEOUT" }), true); + assert.equal(isSemaphoreCapacityError({ code: "SEMAPHORE_QUEUE_FULL" }), true); + assert.equal(isSemaphoreCapacityError({ code: "OTHER" }), false); + assert.equal(isSemaphoreCapacityError(null), false); + assert.equal(isSemaphoreCapacityError("SEMAPHORE_TIMEOUT"), false); +}); + +test("createStreamingErrorResult builds an SSE error envelope with [DONE] terminator", async () => { + const result = createStreamingErrorResult(503, "boom"); + assert.equal(result.success, false); + assert.equal(result.status, 503); + assert.equal(result.error, "boom"); + assert.equal(result.response.status, 503); + assert.equal(result.response.headers.get("Content-Type"), "text/event-stream"); + assert.equal(result.response.headers.get("X-Accel-Buffering"), "no"); + const body = await result.response.text(); + assert.ok(body.startsWith("data: ")); + assert.ok(body.endsWith("data: [DONE]\n\n")); + const json = JSON.parse(body.slice("data: ".length, body.indexOf("\n\n"))); + assert.equal(json.error.message, "boom"); +}); + +test("createStreamingErrorResult attaches optional code and type", async () => { + const result = createStreamingErrorResult(429, "slow down", "rate_limited", "rate_limit_error"); + const body = await result.response.text(); + const json = JSON.parse(body.slice("data: ".length, body.indexOf("\n\n"))); + assert.equal(json.error.code, "rate_limited"); + assert.equal(json.error.type, "rate_limit_error"); +}); + +test("getUpstreamErrorIdentifier returns a non-empty string code or undefined", () => { + assert.equal(getUpstreamErrorIdentifier({ code: "ECONNRESET" }), "ECONNRESET"); + assert.equal(getUpstreamErrorIdentifier({ code: "" }), undefined); + assert.equal(getUpstreamErrorIdentifier({ code: 123 }), undefined); + assert.equal(getUpstreamErrorIdentifier(null), undefined); + assert.equal(getUpstreamErrorIdentifier("ECONNRESET"), undefined); +}); diff --git a/tests/unit/chatcore-stream-finalize.test.ts b/tests/unit/chatcore-stream-finalize.test.ts new file mode 100644 index 0000000000..4b7ede3c22 --- /dev/null +++ b/tests/unit/chatcore-stream-finalize.test.ts @@ -0,0 +1,47 @@ +// tests/unit/chatcore-stream-finalize.test.ts +// Characterization of wrapReadableStreamWithFinalize — the stream finalize wrapper extracted from +// handleChatCore (chatCore god-file decomposition, #3501). Locks: finalize runs exactly once on +// full drain, on cancel, and is not double-invoked. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { wrapReadableStreamWithFinalize } from "../../open-sse/handlers/chatCore/streamFinalize.ts"; + +function streamOf(chunks: unknown[]): ReadableStream { + let i = 0; + return new ReadableStream({ + pull(controller) { + if (i < chunks.length) { + controller.enqueue(chunks[i++]); + } else { + controller.close(); + } + }, + }); +} + +test("finalize runs exactly once after the stream is fully drained", async () => { + let calls = 0; + const wrapped = wrapReadableStreamWithFinalize(streamOf(["a", "b"]), () => { + calls++; + }); + const reader = wrapped.getReader(); + const seen: unknown[] = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + seen.push(value); + } + assert.deepEqual(seen, ["a", "b"]); + assert.equal(calls, 1); +}); + +test("finalize runs exactly once on cancel", async () => { + let calls = 0; + const wrapped = wrapReadableStreamWithFinalize(streamOf(["a", "b", "c"]), () => { + calls++; + }); + const reader = wrapped.getReader(); + await reader.read(); + await reader.cancel("done early"); + assert.equal(calls, 1); +}); diff --git a/tests/unit/chatcore-streaming-cost.test.ts b/tests/unit/chatcore-streaming-cost.test.ts new file mode 100644 index 0000000000..1e12846525 --- /dev/null +++ b/tests/unit/chatcore-streaming-cost.test.ts @@ -0,0 +1,99 @@ +// Characterization of recordStreamingCost — the streaming per-request cost recording extracted +// from handleChatCore's onStreamComplete (chatCore god-file decomposition, #3501). Sync +// fire-and-forget; calculateCost and recordCost are injected, so both are observable without a DB. +// Locks: the guard (missing api-key OR usage → no-op), recordCost with the resolved cost, and the +// estimatedCost<=0 skip. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const { recordStreamingCost } = await import( + "../../open-sse/handlers/chatCore/streamingCost.ts" +); + +function spies(costValue: number) { + const recorded: Array<{ apiKeyId: string; cost: number }> = []; + const costArgs: Array<{ provider: string; model: string }> = []; + return { + recorded, + costArgs, + calculateCost: async (provider: string, model: string) => { + costArgs.push({ provider, model }); + return costValue; + }, + recordCost: (apiKeyId: string, cost: number) => { + recorded.push({ apiKeyId, cost }); + }, + }; +} + +async function waitFor(pred: () => boolean, timeoutMs = 2000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline && !pred()) { + await new Promise((r) => setTimeout(r, 10)); + } +} + +test("missing apiKeyId → no-op (calculateCost never called)", async () => { + const s = spies(0.5); + recordStreamingCost({ + apiKeyId: null, + provider: "openai", + model: "gpt-x", + streamUsage: { prompt_tokens: 10 }, + serviceTier: "standard", + calculateCost: s.calculateCost, + recordCost: s.recordCost, + }); + await new Promise((r) => setTimeout(r, 50)); + assert.equal(s.costArgs.length, 0); + assert.equal(s.recorded.length, 0); +}); + +test("missing streamUsage → no-op", async () => { + const s = spies(0.5); + recordStreamingCost({ + apiKeyId: "key-1", + provider: "openai", + model: "gpt-x", + streamUsage: null, + calculateCost: s.calculateCost, + recordCost: s.recordCost, + }); + await new Promise((r) => setTimeout(r, 50)); + assert.equal(s.costArgs.length, 0); + assert.equal(s.recorded.length, 0); +}); + +test("valid input records the resolved cost against the api key", async () => { + const s = spies(0.0073); + recordStreamingCost({ + apiKeyId: "key-1", + provider: "deepseek", + model: "deepseek-chat", + streamUsage: { prompt_tokens: 100, completion_tokens: 50 }, + serviceTier: "standard", + calculateCost: s.calculateCost, + recordCost: s.recordCost, + }); + await waitFor(() => s.recorded.length > 0); + assert.equal(s.costArgs[0].provider, "deepseek"); + assert.equal(s.recorded.length, 1); + assert.equal(s.recorded[0].apiKeyId, "key-1"); + assert.equal(s.recorded[0].cost, 0.0073); +}); + +test("estimatedCost <= 0 does not record", async () => { + const s = spies(0); + recordStreamingCost({ + apiKeyId: "key-1", + provider: "openai", + model: "gpt-x", + streamUsage: { prompt_tokens: 1 }, + calculateCost: s.calculateCost, + recordCost: s.recordCost, + }); + await waitFor(() => s.costArgs.length > 0); + await new Promise((r) => setTimeout(r, 30)); + assert.equal(s.costArgs.length, 1); + assert.equal(s.recorded.length, 0); +}); diff --git a/tests/unit/chatcore-streaming-quota-share.test.ts b/tests/unit/chatcore-streaming-quota-share.test.ts new file mode 100644 index 0000000000..6acc9fde7b --- /dev/null +++ b/tests/unit/chatcore-streaming-quota-share.test.ts @@ -0,0 +1,96 @@ +// Characterization of scheduleStreamingQuotaShareConsumption — the streaming shared-quota POST-hook +// extracted from handleChatCore's onStreamComplete (chatCore god-file decomposition, #3501). Sync +// fire-and-forget / fail-open. The injected calculateCost is used as the observable. Locks: the +// guard (missing ids OR streamStatus != 200 → no recording) and that a valid 200 stream resolves +// the cost via calculateCost. +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-stream-quota-test-")); +process.env.DATA_DIR = testDataDir; + +const coreDb = await import("../../src/lib/db/core.ts"); +const { scheduleStreamingQuotaShareConsumption } = await import( + "../../open-sse/handlers/chatCore/streamingQuotaShare.ts" +); + +function makeCostSpy() { + const calls: Array<{ provider: string; model: string }> = []; + const calculateCost = async (provider: string, model: string) => { + calls.push({ provider, model }); + return 0.0042; + }; + return { calculateCost, calls }; +} + +async function waitFor(pred: () => boolean, timeoutMs = 2000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline && !pred()) { + await new Promise((r) => setTimeout(r, 10)); + } +} + +before(async () => { + await coreDb.ensureDbInitialized(); +}); + +after(() => { + coreDb.resetDbInstance(); + try { + fs.rmSync(testDataDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +}); + +test("streamStatus != 200 records nothing (calculateCost never called)", async () => { + const { calculateCost, calls } = makeCostSpy(); + scheduleStreamingQuotaShareConsumption({ + apiKeyId: "key-1", + connectionId: "conn-1", + provider: "openai", + model: "gpt-x", + streamUsage: { prompt_tokens: 10, completion_tokens: 5 }, + streamStatus: 500, + serviceTier: "standard", + calculateCost, + }); + await new Promise((r) => setTimeout(r, 80)); + assert.equal(calls.length, 0); +}); + +test("missing connectionId records nothing", async () => { + const { calculateCost, calls } = makeCostSpy(); + scheduleStreamingQuotaShareConsumption({ + apiKeyId: "key-1", + connectionId: null, + provider: "openai", + model: "gpt-x", + streamUsage: { prompt_tokens: 10 }, + streamStatus: 200, + calculateCost, + }); + await new Promise((r) => setTimeout(r, 80)); + assert.equal(calls.length, 0); +}); + +test("valid 200 stream resolves cost via injected calculateCost", async () => { + const { calculateCost, calls } = makeCostSpy(); + scheduleStreamingQuotaShareConsumption({ + apiKeyId: "key-1", + connectionId: "conn-1", + provider: "deepseek", + model: "deepseek-chat", + streamUsage: { prompt_tokens: 100, completion_tokens: 50 }, + streamStatus: 200, + serviceTier: "standard", + calculateCost, + }); + await waitFor(() => calls.length > 0); + assert.equal(calls.length, 1); + assert.equal(calls[0].provider, "deepseek"); + assert.equal(calls[0].model, "deepseek-chat"); +}); diff --git a/tests/unit/chatcore-streaming-usage-stats.test.ts b/tests/unit/chatcore-streaming-usage-stats.test.ts new file mode 100644 index 0000000000..e5db383053 --- /dev/null +++ b/tests/unit/chatcore-streaming-usage-stats.test.ts @@ -0,0 +1,104 @@ +// Characterization of recordStreamingUsageStats — the per-request usage-stats persistence extracted +// from handleChatCore's onStreamComplete (chatCore god-file decomposition, #3501). Uses a real temp +// DB and polls usage_history (saveRequestUsage is async + fire-and-forget). Locks: the non-object +// usage guard, the streaming field mapping (status/success/ttft/errorCode), and a non-200 stream. +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-stream-usage-test-")); +process.env.DATA_DIR = testDataDir; + +const coreDb = await import("../../src/lib/db/core.ts"); +const { getUsageHistory } = await import("../../src/lib/usage/usageHistory.ts"); +const { recordStreamingUsageStats } = await import( + "../../open-sse/handlers/chatCore/streamingUsageStats.ts" +); + +function baseCtx(overrides: Record = {}) { + return { + provider: "openai", + model: "gpt-x", + streamStatus: 200, + startTime: Date.now() - 50, + ttft: 12, + streamErrorCode: null, + connectionId: "conn-1", + apiKeyInfo: { id: "key-1", name: "Key One" }, + effectiveServiceTier: "standard", + isCombo: false, + comboStrategy: null, + ...overrides, + } as Parameters[1]; +} + +async function rowsFor(provider: string): Promise>> { + return (await getUsageHistory({ provider })) as Array>; +} + +async function waitForRows(provider: string, min: number, timeoutMs = 3000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const rows = await rowsFor(provider); + if (rows.length >= min) return rows; + await new Promise((r) => setTimeout(r, 25)); + } + return rowsFor(provider); +} + +before(async () => { + await coreDb.ensureDbInitialized(); +}); + +after(() => { + coreDb.resetDbInstance(); + try { + fs.rmSync(testDataDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +}); + +test("non-object usage is a no-op", async () => { + const before = (await rowsFor("guard-stream")).length; + for (const bad of [null, undefined, 7, "x"]) { + recordStreamingUsageStats(bad, baseCtx({ provider: "guard-stream" })); + } + await new Promise((r) => setTimeout(r, 100)); + assert.equal((await rowsFor("guard-stream")).length, before); +}); + +test("200 stream persists a success row with ttft and status 200", async () => { + recordStreamingUsageStats( + { prompt_tokens: 20, completion_tokens: 9 }, + baseCtx({ provider: "smap-prov", model: "gpt-stream", streamStatus: 200 }) + ); + const rows = await waitForRows("smap-prov", 1); + const row = rows[0] as { + model: string; + success: boolean; + status: string; + timeToFirstTokenMs: number; + tokens: { input: number; output: number }; + }; + assert.equal(row.model, "gpt-stream"); + assert.equal(row.success, true); + assert.equal(row.status, "200"); + assert.equal(row.timeToFirstTokenMs, 12); + assert.equal(row.tokens.input, 20); + assert.equal(row.tokens.output, 9); +}); + +test("non-200 stream persists a failure row (success=false, status string)", async () => { + recordStreamingUsageStats( + { prompt_tokens: 1, completion_tokens: 0 }, + baseCtx({ provider: "sfail-prov", streamStatus: 503, streamErrorCode: "upstream_5xx" }) + ); + const rows = await waitForRows("sfail-prov", 1); + const row = rows[0] as { success: boolean; status: string; errorCode: string }; + assert.equal(row.success, false); + assert.equal(row.status, "503"); + assert.equal(row.errorCode, "upstream_5xx"); +}); diff --git a/tests/unit/chatcore-upstream-body.test.ts b/tests/unit/chatcore-upstream-body.test.ts new file mode 100644 index 0000000000..463b34faed --- /dev/null +++ b/tests/unit/chatcore-upstream-body.test.ts @@ -0,0 +1,102 @@ +// tests/unit/chatcore-upstream-body.test.ts +// Characterization of prepareUpstreamBody — the first internal sub-slice of executeProviderRequest +// (chatCore god-file decomposition, #3501). Uses a fresh temp DB (no payload rules / no detected +// tool limits → defaults). Locks: target-model pinning, the Qwen OAuth user backfill (and its +// guards), and the prompt_cache_key gating (excluded providers + non-OPENAI format never inject). +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-upstream-body-test-")); +process.env.DATA_DIR = testDataDir; + +const coreDb = await import("../../src/lib/db/core.ts"); +const { prepareUpstreamBody } = await import("../../open-sse/handlers/chatCore/upstreamBody.ts"); + +before(async () => { + await coreDb.ensureDbInitialized(); +}); + +after(() => { + coreDb.resetDbInstance(); + fs.rmSync(testDataDir, { recursive: true, force: true }); +}); + +test("pins the target model when it differs from the translated body model", async () => { + const out = await prepareUpstreamBody({ + translatedBody: { model: "model-a", messages: [] }, + modelToCall: "model-b", + provider: "some-provider", + targetFormat: "claude", + credentials: null, + }); + assert.equal(out.model, "model-b"); +}); + +test("leaves the model untouched when it already matches", async () => { + const out = await prepareUpstreamBody({ + translatedBody: { model: "model-a", messages: [] }, + modelToCall: "model-a", + provider: "some-provider", + targetFormat: "claude", + credentials: null, + }); + assert.equal(out.model, "model-a"); +}); + +test("backfills the Qwen OAuth user when missing", async () => { + const out = await prepareUpstreamBody({ + translatedBody: { model: "qwen-max", messages: [] }, + modelToCall: "qwen-max", + provider: "qwen", + targetFormat: "claude", + credentials: { accessToken: "tok-123" }, + }); + assert.equal(out.user, "omniroute-qwen-oauth"); +}); + +test("does not backfill the Qwen user when an apiKey is present (API-key mode)", async () => { + const out = await prepareUpstreamBody({ + translatedBody: { model: "qwen-max", messages: [] }, + modelToCall: "qwen-max", + provider: "qwen", + targetFormat: "claude", + credentials: { apiKey: "k", accessToken: "tok-123" }, + }); + assert.equal(out.user, undefined); +}); + +test("does not backfill the Qwen user when one is already set", async () => { + const out = await prepareUpstreamBody({ + translatedBody: { model: "qwen-max", messages: [], user: "real-user" }, + modelToCall: "qwen-max", + provider: "qwen", + targetFormat: "claude", + credentials: { accessToken: "tok-123" }, + }); + assert.equal(out.user, "real-user"); +}); + +test("never injects prompt_cache_key for an excluded provider (codex)", async () => { + const out = await prepareUpstreamBody({ + translatedBody: { model: "gpt-5-codex", messages: [{ role: "user", content: "hi" }] }, + modelToCall: "gpt-5-codex", + provider: "codex", + targetFormat: "openai", + credentials: null, + }); + assert.equal(out.prompt_cache_key, undefined); +}); + +test("never injects prompt_cache_key when the target format is not OpenAI", async () => { + const out = await prepareUpstreamBody({ + translatedBody: { model: "claude-x", messages: [{ role: "user", content: "hi" }] }, + modelToCall: "claude-x", + provider: "claude", + targetFormat: "claude", + credentials: null, + }); + assert.equal(out.prompt_cache_key, undefined); +}); diff --git a/tests/unit/circuit-breaker-failure-kind.test.ts b/tests/unit/circuit-breaker-failure-kind.test.ts index d9498f1a0a..45d2afd85c 100644 --- a/tests/unit/circuit-breaker-failure-kind.test.ts +++ b/tests/unit/circuit-breaker-failure-kind.test.ts @@ -219,7 +219,11 @@ test("invalid cooldownByKind values (NaN, Infinity, negative) fall back to reset cb.reset(); cb._onFailure(kind); const t = cb._timeUntilReset(); - assert.ok(t > 29_000 && t <= 30_000, `expected resetTimeout fallback for ${kind}, got ${t}`); + // _timeUntilReset() = resetTimeout - elapsed-since-failure, so the lower bound + // must tolerate real wall-clock drift on slow/loaded CI runners (this loop took + // ~1.6s once → t=28401, flaking the old `> 29_000`). Any t well above the invalid + // cooldown values (NaN/Infinity/negative) and <= resetTimeout proves the fallback. + assert.ok(t > 25_000 && t <= 30_000, `expected resetTimeout fallback for ${kind}, got ${t}`); } cb.reset(); }); diff --git a/tests/unit/combo-config.test.ts b/tests/unit/combo-config.test.ts index 1e9a04b9c3..0fc4e17264 100644 --- a/tests/unit/combo-config.test.ts +++ b/tests/unit/combo-config.test.ts @@ -213,9 +213,12 @@ test("combo config schema accepts explicit zero-latency opt-in controls", () => assert.equal(parsed.config.predictiveTtftMs, 1800); }); -test("combo config schema rejects enabled zero-latency subfeatures without opt-in", () => { +test("combo config schema auto-promotes the zero-latency gate for legacy configs without opt-in", () => { + // Pre-3.8.33 combos carry zero-latency subfeatures without the + // zeroLatencyOptimizationsEnabled gate. The schema now auto-promotes the gate + // (instead of 400-ing on the first GUI edit) so they round-trip. See #4774/#4382. const result = createComboSchema.safeParse({ - name: "zero-latency-noop", + name: "zero-latency-legacy", models: ["openai/gpt-4o-mini", "anthropic/claude-3-haiku"], config: { hedging: true, @@ -224,11 +227,45 @@ test("combo config schema rejects enabled zero-latency subfeatures without opt-i }, }); - assert.equal(result.success, false); - assert.deepEqual( - result.error.issues.map((issue) => issue.path.join(".")), - ["config.hedging", "config.predictiveTtftMs", "config.fallbackCompressionMode"] - ); + assert.equal(result.success, true); + assert.equal(result.data.config.zeroLatencyOptimizationsEnabled, true); + // The enabled subfeatures are preserved verbatim. + assert.equal(result.data.config.hedging, true); + assert.equal(result.data.config.fallbackCompressionMode, "lite"); + assert.equal(result.data.config.predictiveTtftMs, 1800); +}); + +test("combo config schema leaves the zero-latency gate untouched when no subfeature is enabled", () => { + // A plain config with no zero-latency subfeature must NOT be auto-promoted — + // the gate stays at its default (false) so we don't silently flip optimizations on. + const result = createComboSchema.safeParse({ + name: "no-zero-latency", + models: ["openai/gpt-4o-mini", "anthropic/claude-3-haiku"], + config: { + fallbackCompressionMode: "off", + }, + }); + + assert.equal(result.success, true); + assert.notEqual(result.data.config.zeroLatencyOptimizationsEnabled, true); +}); + +test("combo config schema no longer rejects v3.8.31-era removed config keys (#4382 round-trip)", () => { + // Keys dropped after v3.8.31 still live in stored JSON. The schema switched + // .strict() (which 400'd) → .passthrough(); the route + migration 103 scrub them. + const result = createComboSchema.safeParse({ + name: "legacy-removed-keys", + models: ["openai/gpt-4o-mini", "anthropic/claude-3-haiku"], + config: { + queueDepth: 4, + fallbackDelayMs: 200, + maxComboDepth: 3, + shadowRouting: { enabled: false }, + resetAwareEnabled: true, + }, + }); + + assert.equal(result.success, true); }); test("combo config schema allows zero-latency tuning fields when subfeatures stay disabled", () => { 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); +}); diff --git a/tests/unit/compression/db.test.ts b/tests/unit/compression/db.test.ts index 9da322eb97..9f4731da42 100644 --- a/tests/unit/compression/db.test.ts +++ b/tests/unit/compression/db.test.ts @@ -139,4 +139,19 @@ describe("updateCompressionSettings", () => { assert.equal(settings.ultra?.modelPath, "/tmp/model.onnx"); assert.equal(settings.ultra?.maxTokensPerMessage, 512); }); + + it("round-trips ultraEngine + ultraSlmPrewarm (Phase 4 B), defaulting off", async () => { + const before = await getCompressionSettings(); + assert.equal(before.ultraEngine, "heuristic"); + assert.equal(before.ultraSlmPrewarm, false); + + await updateCompressionSettings({ + ultraEngine: "slm", + ultraSlmPrewarm: true, + } as any); + + const after = await getCompressionSettings(); + assert.equal(after.ultraEngine, "slm"); + assert.equal(after.ultraSlmPrewarm, true); + }); }); diff --git a/tests/unit/compression/eval-aggregate.test.ts b/tests/unit/compression/eval-aggregate.test.ts new file mode 100644 index 0000000000..0b6cd90da1 --- /dev/null +++ b/tests/unit/compression/eval-aggregate.test.ts @@ -0,0 +1,45 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { aggregateRecords } from "../../../open-sse/services/compression/eval/aggregate.ts"; +import type { EvalRecord, RunStamps } from "../../../open-sse/services/compression/eval/types.ts"; + +const stamps: RunStamps = { answerModel: "m", judgeModel: "j", corpusHash: "abc", sampleSize: "all" }; + +const records: EvalRecord[] = [ + { id: "p1", kind: "prose", fidelity: "same", goldFull: true, goldCompressed: true, + savings: { tokensBefore: 100, tokensAfter: 50, ratio: 0.5 }, errored: false }, + { id: "p2", kind: "prose", fidelity: "materially-differs", goldFull: true, goldCompressed: false, + savings: { tokensBefore: 200, tokensAfter: 80, ratio: 0.4 }, errored: false }, + { id: "c1", kind: "code", fidelity: "same", goldFull: null, goldCompressed: null, + savings: { tokensBefore: 60, tokensAfter: 30, ratio: 0.5 }, errored: false }, + { id: "e1", kind: "logs", fidelity: "unparseable", goldFull: null, goldCompressed: null, + savings: { tokensBefore: 0, tokensAfter: 0, ratio: 1 }, errored: true }, +]; + +describe("eval aggregate", () => { + it("excludes errored records from aggregates but counts them", () => { + const report = aggregateRecords(records, stamps, { partial: false, totalCostUsd: 0 }); + assert.equal(report.overall.casesScored, 3); + assert.equal(report.overall.casesErrored, 1); + }); + + it("computes fidelity-preserved % overall (same / scored)", () => { + const report = aggregateRecords(records, stamps, { partial: false, totalCostUsd: 0 }); + // 2 of 3 scored are "same" + assert.equal(report.overall.fidelityPreservedPct, 66.7); + }); + + it("computes gold-accuracy delta (compressed-correct minus full-correct) over gold cases", () => { + const report = aggregateRecords(records, stamps, { partial: false, totalCostUsd: 0 }); + // prose gold cases: full 2/2 correct, compressed 1/2 correct => delta -50% + const prose = report.perKind.find((k) => k.kind === "prose")!; + assert.equal(prose.goldAccuracyDeltaPct, -50); + }); + + it("carries the stamps and the partial flag through", () => { + const report = aggregateRecords(records, stamps, { partial: true, totalCostUsd: 1.23 }); + assert.equal(report.stamps.corpusHash, "abc"); + assert.equal(report.partial, true); + assert.equal(report.totalCostUsd, 1.23); + }); +}); diff --git a/tests/unit/compression/eval-corpus.test.ts b/tests/unit/compression/eval-corpus.test.ts new file mode 100644 index 0000000000..999dc65c1d --- /dev/null +++ b/tests/unit/compression/eval-corpus.test.ts @@ -0,0 +1,39 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { loadCorpus, hashCorpus } from "../../../open-sse/services/compression/eval/corpus.ts"; +import type { EvalCase } from "../../../open-sse/services/compression/eval/types.ts"; + +const cases: EvalCase[] = [ + { id: "a", kind: "prose", context: "hello world", question: "what is it?" }, + { id: "b", kind: "code", context: "function f(){return 1}", question: "what does f return?", gold: "1" }, +]; + +describe("eval corpus loader", () => { + it("loads valid cases unchanged", () => { + const loaded = loadCorpus(cases); + assert.equal(loaded.length, 2); + assert.equal(loaded[1].gold, "1"); + }); + + it("rejects a case missing required fields", () => { + assert.throws(() => loadCorpus([{ id: "x", kind: "prose", context: "", question: "q" } as EvalCase])); + }); + + it("rejects a captured case with an obvious PII marker (email)", () => { + assert.throws(() => + loadCorpus([{ id: "p", kind: "logs", context: "user alice@example.com failed", question: "who?", captured: true }]) + ); + }); + + it("allows a curated seed case even if it contains an email-like token (curated is vetted)", () => { + const loaded = loadCorpus([{ id: "s", kind: "logs", context: "noreply@x.test sent", question: "who?", captured: false }]); + assert.equal(loaded.length, 1); + }); + + it("hashCorpus is stable and order-independent over case ids", () => { + const h1 = hashCorpus(cases); + const h2 = hashCorpus([cases[1], cases[0]]); + assert.equal(h1, h2); + assert.match(h1, /^[0-9a-f]{64}$/); + }); +}); diff --git a/tests/unit/compression/eval-cost-meter.test.ts b/tests/unit/compression/eval-cost-meter.test.ts new file mode 100644 index 0000000000..1d7fce6c34 --- /dev/null +++ b/tests/unit/compression/eval-cost-meter.test.ts @@ -0,0 +1,34 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { createCostMeter } from "../../../open-sse/services/compression/eval/costMeter.ts"; + +describe("eval cost meter", () => { + it("accumulates spend and reports it", () => { + const m = createCostMeter(1.0); + m.add(0.2); + m.add(0.3); + assert.equal(Math.round(m.spent * 100) / 100, 0.5); + assert.equal(m.exceeded, false); + }); + + it("wouldExceed is true when the next charge crosses the cap", () => { + const m = createCostMeter(1.0); + m.add(0.8); + assert.equal(m.wouldExceed(0.3), true); + assert.equal(m.wouldExceed(0.1), false); + }); + + it("marks exceeded once spend crosses the cap", () => { + const m = createCostMeter(1.0); + m.add(0.9); + m.add(0.2); + assert.equal(m.exceeded, true); + }); + + it("a cap of 0 or undefined means unbounded (never exceeds)", () => { + const m = createCostMeter(0); + m.add(1000); + assert.equal(m.wouldExceed(1000), false); + assert.equal(m.exceeded, false); + }); +}); diff --git a/tests/unit/compression/eval-grader.test.ts b/tests/unit/compression/eval-grader.test.ts new file mode 100644 index 0000000000..05cf0cf35f --- /dev/null +++ b/tests/unit/compression/eval-grader.test.ts @@ -0,0 +1,22 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { buildGradePrompt, parseGradeVerdict } from "../../../open-sse/services/compression/eval/grader.ts"; + +describe("gold grader", () => { + it("buildGradePrompt embeds the answer and the gold", () => { + const msgs = buildGradePrompt("returns three", "3"); + const joined = msgs.map((m) => m.content).join("\n"); + assert.match(joined, /returns three/); + assert.match(joined, /3/); + assert.match(joined.toUpperCase(), /CORRECT|INCORRECT/); + }); + + it("parseGradeVerdict reads CORRECT / INCORRECT", () => { + assert.equal(parseGradeVerdict("VERDICT: CORRECT").correct, true); + assert.equal(parseGradeVerdict("verdict: incorrect — wrong number").correct, false); + }); + + it("parseGradeVerdict defaults to incorrect on unparseable output (never credits a wrong answer)", () => { + assert.equal(parseGradeVerdict("hmm, hard to say").correct, false); + }); +}); diff --git a/tests/unit/compression/eval-judge.test.ts b/tests/unit/compression/eval-judge.test.ts new file mode 100644 index 0000000000..bfc41d06d7 --- /dev/null +++ b/tests/unit/compression/eval-judge.test.ts @@ -0,0 +1,26 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { buildJudgePrompt, parseJudgeVerdict } from "../../../open-sse/services/compression/eval/judge.ts"; + +describe("fidelity judge", () => { + it("buildJudgePrompt embeds both answers and asks for a SAME/DIFFERENT verdict", () => { + const msgs = buildJudgePrompt("the cat sat", "a cat was sitting"); + const joined = msgs.map((m) => m.content).join("\n"); + assert.match(joined, /the cat sat/); + assert.match(joined, /a cat was sitting/); + assert.match(joined.toUpperCase(), /SAME|MATERIALLY/); + }); + + it("parseJudgeVerdict maps a SAME verdict", () => { + assert.equal(parseJudgeVerdict("Verdict: SAME"), "same"); + }); + + it("parseJudgeVerdict maps a MATERIALLY_DIFFERS verdict (case/format tolerant)", () => { + assert.equal(parseJudgeVerdict("VERDICT: materially_differs\nreason: omitted the error"), "materially-differs"); + assert.equal(parseJudgeVerdict("differs materially"), "materially-differs"); + }); + + it("parseJudgeVerdict returns 'unparseable' for noise", () => { + assert.equal(parseJudgeVerdict("I am not sure, could you clarify?"), "unparseable"); + }); +}); diff --git a/tests/unit/compression/eval-report.test.ts b/tests/unit/compression/eval-report.test.ts new file mode 100644 index 0000000000..65099a2f1b --- /dev/null +++ b/tests/unit/compression/eval-report.test.ts @@ -0,0 +1,36 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { formatReport } from "../../../open-sse/services/compression/eval/report.ts"; +import type { EvalReport } from "../../../open-sse/services/compression/eval/types.ts"; + +const report: EvalReport = { + stamps: { answerModel: "gpt-x", judgeModel: "judge-y", corpusHash: "deadbeef", sampleSize: 5 }, + partial: true, + totalCostUsd: 0.42, + overall: { casesScored: 4, casesErrored: 1, fidelityPreservedPct: 75, goldAccuracyDeltaPct: -25, meanRatio: 0.5 }, + perKind: [{ kind: "prose", casesScored: 2, fidelityPreservedPct: 50, goldAccuracyDeltaPct: -50, meanRatio: 0.45 }], +}; + +describe("eval report writer", () => { + it("stamps the answer model, judge model and corpus hash in the header", () => { + const md = formatReport(report); + assert.match(md, /gpt-x/); + assert.match(md, /judge-y/); + assert.match(md, /deadbeef/); + }); + + it("flags a partial run prominently (never silent)", () => { + assert.match(formatReport(report), /PARTIAL/i); + }); + + it("renders a per-kind table row and the overall fidelity %", () => { + const md = formatReport(report); + assert.match(md, /prose/); + assert.match(md, /75/); + }); + + it("a complete run is not labelled partial", () => { + const md = formatReport({ ...report, partial: false }); + assert.doesNotMatch(md, /PARTIAL/i); + }); +}); diff --git a/tests/unit/compression/eval-runner.test.ts b/tests/unit/compression/eval-runner.test.ts new file mode 100644 index 0000000000..4d8b58a623 --- /dev/null +++ b/tests/unit/compression/eval-runner.test.ts @@ -0,0 +1,94 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { runEval } from "../../../open-sse/services/compression/eval/runner.ts"; +import { CONTROL_PAIR } from "../../../open-sse/services/compression/eval/judge.ts"; +import type { EvalCase, ModelClient } from "../../../open-sse/services/compression/eval/types.ts"; + +const corpus: EvalCase[] = [ + { id: "p1", kind: "prose", context: "the sky is blue", question: "what color is the sky?", gold: "blue" }, + { id: "c1", kind: "code", context: "function f(){return 7}", question: "what does f return?", gold: "7" }, +]; + +/** Stub that answers questions, judges fidelity, grades gold — keyed off prompt content. */ +function smartStub(opts: { answerModel: string; judgeModel: string }): ModelClient { + return { + async complete(model, messages) { + const sys = messages.find((m) => m.role === "system")?.content ?? ""; + const user = messages.find((m) => m.role === "user")?.content ?? ""; + // Self-test control pair (judge): rank degraded vs good correctly. + if (model === opts.judgeModel && user.includes(CONTROL_PAIR.degraded)) { + return { text: "VERDICT: MATERIALLY_DIFFERS", usdCost: 0.01 }; + } + if (model === opts.judgeModel && /judge|materially/i.test(sys)) { + return { text: "VERDICT: SAME", usdCost: 0.01 }; + } + // Gold grader: always CORRECT for this stub. + if (/grader|correct\|incorrect/i.test(sys)) return { text: "VERDICT: CORRECT", usdCost: 0.01 }; + // Answer generation. + return { text: "blue and seven", usdCost: 0.01 }; + }, + }; +} + +const baseOpts = { + config: { enabled: true, defaultMode: "lite", enginesExplicit: false } as any, + comboId: null, + combos: {}, + answerModel: "answer-model", + judgeModel: "judge-model", + provider: "test", + costCapUsd: 0, + sample: undefined as number | undefined, +}; + +describe("eval runner", () => { + it("aborts when the judge fails self-test (no scores emitted)", async () => { + const broken: ModelClient = { async complete() { return { text: "VERDICT: SAME" }; } }; + const r = await runEval({ ...baseOpts, corpus, client: broken }); + assert.equal(r.aborted, true); + assert.match(r.abortReason ?? "", /self-test/i); + assert.equal(r.report, null); + }); + + it("produces an aggregated report with a passing judge", async () => { + const r = await runEval({ ...baseOpts, corpus, client: smartStub({ answerModel: "answer-model", judgeModel: "judge-model" }) }); + assert.equal(r.aborted, false); + assert.ok(r.report); + assert.equal(r.report!.overall.casesScored, 2); + assert.equal(r.report!.stamps.answerModel, "answer-model"); + assert.match(r.report!.stamps.corpusHash, /^[0-9a-f]{64}$/); + }); + + it("an errored case (model throws) is excluded but counted", async () => { + let n = 0; + const flaky: ModelClient = { + async complete(model, messages) { + const user = messages.find((m) => m.role === "user")?.content ?? ""; + if (model === "judge-model" && user.includes(CONTROL_PAIR.degraded)) return { text: "VERDICT: MATERIALLY_DIFFERS" }; + if (model === "judge-model") return { text: "VERDICT: SAME" }; + n += 1; + if (n === 2) throw new Error("upstream 500"); // fail the 2nd answer call + return { text: "ok", usdCost: 0.01 }; + }, + }; + const r = await runEval({ ...baseOpts, corpus, client: flaky }); + assert.equal(r.aborted, false); + assert.equal(r.report!.overall.casesErrored, 1); + }); + + it("the cost cap stops the run and flags partial", async () => { + const r = await runEval({ + ...baseOpts, + costCapUsd: 0.025, // self-test (2 calls) + a little; cap trips during the case loop + corpus, + client: smartStub({ answerModel: "answer-model", judgeModel: "judge-model" }), + }); + assert.equal(r.aborted, false); + assert.equal(r.report!.partial, true); + }); + + it("--sample N limits the cases scored", async () => { + const r = await runEval({ ...baseOpts, sample: 1, corpus, client: smartStub({ answerModel: "answer-model", judgeModel: "judge-model" }) }); + assert.equal(r.report!.overall.casesScored + r.report!.overall.casesErrored, 1); + }); +}); diff --git a/tests/unit/compression/eval-savings.test.ts b/tests/unit/compression/eval-savings.test.ts new file mode 100644 index 0000000000..94dc91e3ad --- /dev/null +++ b/tests/unit/compression/eval-savings.test.ts @@ -0,0 +1,27 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { computeSavings } from "../../../open-sse/services/compression/eval/savings.ts"; + +describe("eval savings", () => { + it("computes tokensBefore/after + ratio from the bodies", () => { + const full = { messages: [{ role: "user", content: "a".repeat(400) }] }; + const compressed = { messages: [{ role: "user", content: "a".repeat(100) }] }; + const s = computeSavings(full, compressed); + assert.ok(s.tokensBefore > s.tokensAfter); + assert.ok(s.ratio < 1 && s.ratio > 0); + assert.equal(s.costDelta, undefined); + }); + + it("computes a positive costDelta when a per-1k input price is supplied", () => { + const full = { messages: [{ role: "user", content: "a".repeat(4000) }] }; + const compressed = { messages: [{ role: "user", content: "a".repeat(1000) }] }; + const s = computeSavings(full, compressed, 0.003); // $0.003 / 1k input tokens + assert.ok((s.costDelta ?? 0) > 0); + }); + + it("ratio is 1 (no savings) when bodies are identical", () => { + const body = { messages: [{ role: "user", content: "x" }] }; + const s = computeSavings(body, body); + assert.equal(s.ratio, 1); + }); +}); diff --git a/tests/unit/compression/eval-seed-corpus.test.ts b/tests/unit/compression/eval-seed-corpus.test.ts new file mode 100644 index 0000000000..511e181a42 --- /dev/null +++ b/tests/unit/compression/eval-seed-corpus.test.ts @@ -0,0 +1,24 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { SEED_CORPUS } from "../../../open-sse/services/compression/eval/seedCorpus.ts"; +import { loadCorpus } from "../../../open-sse/services/compression/eval/corpus.ts"; +import type { ContentKind } from "../../../open-sse/services/compression/eval/types.ts"; + +describe("eval seed corpus", () => { + it("loads cleanly through loadCorpus (no malformed/PII cases)", () => { + assert.doesNotThrow(() => loadCorpus(SEED_CORPUS)); + }); + + it("covers every content kind", () => { + const kinds = new Set(SEED_CORPUS.map((c) => c.kind)); + for (const k of ["tool-output-json", "logs", "code", "prose", "multi-turn"] as ContentKind[]) { + assert.ok(kinds.has(k), `missing kind ${k}`); + } + }); + + it("has unique ids and at least one gold-bearing case", () => { + const ids = SEED_CORPUS.map((c) => c.id); + assert.equal(new Set(ids).size, ids.length); + assert.ok(SEED_CORPUS.some((c) => typeof c.gold === "string")); + }); +}); diff --git a/tests/unit/compression/eval-selftest.test.ts b/tests/unit/compression/eval-selftest.test.ts new file mode 100644 index 0000000000..acb4b6ea2a --- /dev/null +++ b/tests/unit/compression/eval-selftest.test.ts @@ -0,0 +1,33 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { runSelfTest, CONTROL_PAIR } from "../../../open-sse/services/compression/eval/judge.ts"; +import type { ModelClient } from "../../../open-sse/services/compression/eval/types.ts"; + +/** A judge that correctly ranks the control pair (degraded => MATERIALLY_DIFFERS, good => SAME). */ +function correctJudge(): ModelClient { + return { + async complete(_model, messages) { + const u = messages.find((m) => m.role === "user")?.content ?? ""; + const degraded = u.includes(CONTROL_PAIR.degraded); + return { text: degraded ? "VERDICT: MATERIALLY_DIFFERS" : "VERDICT: SAME" }; + }, + }; +} + +/** A broken judge that always says SAME — must FAIL self-test. */ +function brokenJudge(): ModelClient { + return { async complete() { return { text: "VERDICT: SAME" }; } }; +} + +describe("judge self-test gate (D-D3)", () => { + it("a correct judge passes self-test", async () => { + const r = await runSelfTest(correctJudge(), "judge-model"); + assert.equal(r.passed, true); + }); + + it("a broken judge (always SAME) fails self-test", async () => { + const r = await runSelfTest(brokenJudge(), "judge-model"); + assert.equal(r.passed, false); + assert.match(r.detail, /degraded|control/i); + }); +}); diff --git a/tests/unit/compression/llmlingua-ultra-entry.test.ts b/tests/unit/compression/llmlingua-ultra-entry.test.ts new file mode 100644 index 0000000000..308aafd6cf --- /dev/null +++ b/tests/unit/compression/llmlingua-ultra-entry.test.ts @@ -0,0 +1,174 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { + slmAvailable, + __resetUltraEntryForTests, +} from "../../../open-sse/services/compression/engines/llmlingua/ultraEntry.ts"; +import { __resetLlmlinguaWorkerForTests } from "../../../open-sse/services/compression/engines/llmlingua/worker.ts"; + +const require = createRequire(import.meta.url); + +function depsResolve(): boolean { + try { + require.resolve("@atjsh/llmlingua-2"); + return true; + } catch { + return false; + } +} + +after(() => { + __resetUltraEntryForTests(); + __resetLlmlinguaWorkerForTests(); +}); + +test("slmAvailable() is false and fast when optional deps are absent", () => { + if (depsResolve()) { + console.log("skip: optional deps present — absent-probe test N/A"); + return; + } + const start = Date.now(); + const available = slmAvailable(); + const elapsed = Date.now() - start; + assert.equal(available, false); + assert.ok(elapsed < 1000, `expected <1000ms, got ${elapsed}ms`); +}); + +test("slmAvailable() result is cached (second call also fast)", () => { + if (depsResolve()) return; + const start = Date.now(); + slmAvailable(); + slmAvailable(); + assert.ok(Date.now() - start < 1000); +}); + +import { runLlmlinguaUltra } from "../../../open-sse/services/compression/engines/llmlingua/ultraEntry.ts"; + +test("runLlmlinguaUltra throws when the backend fail-opens (no gain)", async () => { + if (depsResolve()) { + console.log("skip: optional deps present — no-op path N/A"); + return; + } + // Deps absent → workerBackend returns the original text unchanged (no-op) → throw. + await assert.rejects( + () => runLlmlinguaUltra("hello world this is some prose to compress"), + /no gain/ + ); +}); + +import { + prewarmLlmlinguaUltra, + __setUltraSlmTestHooks, +} from "../../../open-sse/services/compression/engines/llmlingua/ultraEntry.ts"; + +test("prewarmLlmlinguaUltra fires exactly one warm call when available", async () => { + let calls = 0; + __setUltraSlmTestHooks({ + available: true, + run: async (text) => { + calls++; + return text.slice(0, 1); + }, + }); + try { + const attempted = await prewarmLlmlinguaUltra(); + assert.equal(attempted, true); + assert.equal(calls, 1); + } finally { + __resetUltraEntryForTests(); + } +}); + +test("prewarmLlmlinguaUltra swallows a warm-call failure", async () => { + __setUltraSlmTestHooks({ + available: true, + run: async () => { + throw new Error("load failed"); + }, + }); + try { + const attempted = await prewarmLlmlinguaUltra(); // must NOT throw + assert.equal(attempted, true); + } finally { + __resetUltraEntryForTests(); + } +}); + +test("prewarmLlmlinguaUltra is a no-op when unavailable", async () => { + __setUltraSlmTestHooks({ available: false }); + try { + const attempted = await prewarmLlmlinguaUltra(); + assert.equal(attempted, false); + } finally { + __resetUltraEntryForTests(); + } +}); + +// ─── Task 7 — gated VPS live validation (Hard Rule #18) ────────────────────── +// These tests run the REAL ONNX model and are SKIPPED unless RUN_LLMLINGUA_INT=1 +// AND the optional deps are present (only true on the VPS). Under the normal +// runner they print a skip line and pass as no-ops. +// +// VPS command (run ON the VPS, optional deps present, real ONNX model downloaded +// on the first call): +// +// RUN_LLMLINGUA_INT=1 node --import tsx --import ./open-sse/utils/setupPolyfill.ts \ +// --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit \ +// tests/unit/compression/llmlingua-ultra-entry.test.ts +// +// Expected: "GATED real ultra-SLM compression" PASSES with a real shrink + +// ultraTier:"slm"; "GATED forced-unavailable ultra falls back to heuristic" +// PASSES with ultraTier:"heuristic". +import { ultraCompress } from "../../../open-sse/services/compression/ultra.ts"; + +test("GATED real ultra-SLM compression (RUN_LLMLINGUA_INT=1)", async () => { + if (process.env.RUN_LLMLINGUA_INT !== "1") { + console.log("skip: RUN_LLMLINGUA_INT!=1"); + return; + } + if (!depsResolve()) { + console.log("skip: deps absent"); + return; + } + const LONG_PROSE = + "The quick brown fox jumps over the lazy dog while the sun sets slowly behind the distant hills. ".repeat( + 120 + ); + const r = await ultraCompress([{ role: "user", content: LONG_PROSE }], { + enabled: true, + compressionRate: 0.5, + minScoreThreshold: 0.3, + slmFallbackToAggressive: false, + maxTokensPerMessage: 0, + ultraEngine: "slm", + }); + const out = r.messages[0].content as string; + assert.equal(typeof out, "string"); + assert.ok(out.length < LONG_PROSE.length, "expected a real SLM shrink"); + assert.equal(r.stats.ultraTier, "slm"); +}); + +test("GATED forced-unavailable ultra falls back to heuristic", async () => { + if (process.env.RUN_LLMLINGUA_INT !== "1") { + console.log("skip: RUN_LLMLINGUA_INT!=1"); + return; + } + __setUltraSlmTestHooks({ available: false }); + try { + const r = await ultraCompress( + [{ role: "user", content: "the quick brown fox jumps over the lazy dog ".repeat(40) }], + { + enabled: true, + compressionRate: 0.5, + minScoreThreshold: 0.3, + slmFallbackToAggressive: false, + maxTokensPerMessage: 0, + ultraEngine: "slm", + } + ); + assert.equal(r.stats.ultraTier, "heuristic"); + } finally { + __resetUltraEntryForTests(); + } +}); diff --git a/tests/unit/compression/output-styles-apply.test.ts b/tests/unit/compression/output-styles-apply.test.ts new file mode 100644 index 0000000000..d9b4c47aa9 --- /dev/null +++ b/tests/unit/compression/output-styles-apply.test.ts @@ -0,0 +1,155 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + applyOutputStyles, + OUTPUT_STYLE_MARKER, + type OutputStyleSelectionEntry, +} from "../../../open-sse/services/compression/outputStyles/apply.ts"; + +const sel = ( + ...entries: Array<[string, "lite" | "full" | "ultra"]> +): OutputStyleSelectionEntry[] => entries.map(([id, level]) => ({ id, level })); + +test("injects a system instruction with the unified marker", () => { + const r = applyOutputStyles( + { messages: [{ role: "user", content: "Summarize this API response." }] }, + sel(["terse-prose", "full"]) + ); + assert.equal(r.applied, true); + assert.equal(r.body.messages?.[0]?.role, "system"); + assert.match(String(r.body.messages?.[0]?.content), new RegExp(escapeRe(OUTPUT_STYLE_MARKER))); + assert.match(String(r.body.messages?.[0]?.content), /Respond terse/); + assert.deepEqual(r.appliedStyles, [{ id: "terse-prose", level: "full" }]); +}); + +test("combines two styles in catalog order with a single shared boundary", () => { + const r = applyOutputStyles( + { messages: [{ role: "user", content: "Refactor this module." }] }, + sel(["less-code", "full"], ["terse-prose", "full"]) // requested out of order + ); + const text = String(r.body.messages?.[0]?.content); + // catalog order is terse-prose before less-code + const proseAt = text.indexOf("Respond terse"); + const codeAt = text.indexOf("lazy senior dev"); + assert.ok(proseAt >= 0 && codeAt >= 0 && proseAt < codeAt, "catalog order"); + // SHARED_BOUNDARIES appears exactly once (appended once, not per style) + const boundaryCount = (text.match(/Resume terse style after\./g) ?? []).length; + assert.equal(boundaryCount, 1); + assert.deepEqual( + r.appliedStyles?.map((s) => s.id), + ["terse-prose", "less-code"] + ); +}); + +test("appends to an existing system prompt", () => { + const r = applyOutputStyles( + { + messages: [ + { role: "system", content: "Follow tenant policy." }, + { role: "user", content: "Summarize logs." }, + ], + }, + sel(["terse-prose", "lite"]) + ); + assert.match(String(r.body.messages?.[0]?.content), /Follow tenant policy/); + assert.match(String(r.body.messages?.[0]?.content), /Drop filler/); +}); + +test("idempotent: re-applying is a no-op", () => { + const body = { messages: [{ role: "user", content: "Summarize logs." }] }; + const once = applyOutputStyles(body, sel(["terse-prose", "full"])).body; + const twice = applyOutputStyles(once, sel(["terse-prose", "full"])); + assert.equal(twice.applied, false); + assert.equal(twice.skippedReason, "already_applied"); + const markerCount = (String(twice.body.messages?.[0]?.content).match( + new RegExp(escapeRe(OUTPUT_STYLE_MARKER), "g") + ) ?? []).length; + assert.equal(markerCount, 1); +}); + +test("content bypass is all-or-nothing across every selected style", () => { + const r = applyOutputStyles( + { messages: [{ role: "user", content: "Explain this security vulnerability in detail." }] }, + sel(["terse-prose", "full"], ["less-code", "full"]) + ); + assert.equal(r.applied, false); + assert.equal(r.skippedReason, "security_warning"); + assert.equal(r.body.messages?.[0]?.role, "user"); // untouched +}); + +test("no styles selected → body untouched", () => { + const body = { messages: [{ role: "user", content: "Tell me a joke." }] }; + const r = applyOutputStyles(body, []); + assert.equal(r.applied, false); + assert.equal(r.skippedReason, "no_styles"); + assert.equal(r.body.messages?.[0]?.content, "Tell me a joke."); +}); + +test("unknown style id is skipped, never throws", () => { + const r = applyOutputStyles( + { messages: [{ role: "user", content: "hi" }] }, + sel(["__nope__", "full"], ["terse-prose", "full"]) + ); + assert.equal(r.applied, true); + assert.deepEqual(r.appliedStyles?.map((s) => s.id), ["terse-prose"]); +}); + +test("locale gate: terse-cjk only honored under zh", () => { + const enOnly = applyOutputStyles( + { messages: [{ role: "user", content: "hi" }] }, + sel(["terse-cjk", "full"]), + "en" + ); + assert.equal(enOnly.applied, false); + assert.equal(enOnly.skippedReason, "no_styles"); + + const zh = applyOutputStyles( + { messages: [{ role: "user", content: "hi" }] }, + sel(["terse-cjk", "full"]), + "zh" + ); + assert.equal(zh.applied, true); + assert.match(String(zh.body.messages?.[0]?.content), /文言/); +}); + +test("determinism: same (selection, language) yields byte-identical injected text", () => { + const make = () => + applyOutputStyles( + { messages: [{ role: "user", content: "do a thing" }] }, + sel(["terse-prose", "full"], ["less-code", "lite"]) + ).body.messages?.[0]?.content; + assert.equal(make(), make()); +}); + +test("Responses input (no messages) uses instructions field", () => { + const r = applyOutputStyles( + { input: [{ type: "message", role: "user", content: "Summarize logs." }] }, + sel(["terse-prose", "full"]) + ); + assert.equal(r.applied, true); + assert.match(String(r.body.instructions), new RegExp(escapeRe(OUTPUT_STYLE_MARKER))); + assert.ok(!("messages" in r.body)); +}); + +test("terse-prose localizes per language (back-compat with the legacy caveman packs)", () => { + // Regression guard: the legacy caveman output mode localized to en/pt-BR/ja/id; the + // migrated terse-prose style must inject the SAME localized text, not fall back to English. + const ptBR = applyOutputStyles( + { messages: [{ role: "user", content: "Resuma os logs." }] }, + sel(["terse-prose", "lite"]), + "pt-BR" + ); + assert.match(String(ptBR.body.messages?.[0]?.content), /Responda conciso/); + assert.doesNotMatch(String(ptBR.body.messages?.[0]?.content), /Respond concise/); + + const en = applyOutputStyles( + { messages: [{ role: "user", content: "Summarize logs." }] }, + sel(["terse-prose", "lite"]), + "en" + ); + assert.match(String(en.body.messages?.[0]?.content), /Respond concise/); +}); + +function escapeRe(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/tests/unit/compression/output-styles-backcompat.test.ts b/tests/unit/compression/output-styles-backcompat.test.ts new file mode 100644 index 0000000000..195e581522 --- /dev/null +++ b/tests/unit/compression/output-styles-backcompat.test.ts @@ -0,0 +1,49 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { resolveOutputStyleSelection } from "../../../open-sse/services/compression/outputStyles/backCompat.ts"; +import { applyOutputStyles } from "../../../open-sse/services/compression/outputStyles/apply.ts"; +import { applyCavemanOutputMode } from "../../../open-sse/services/compression/outputMode.ts"; + +test("explicit outputStyles win when present", () => { + const sel = resolveOutputStyleSelection({ + outputStyles: [{ id: "less-code", level: "ultra" }], + cavemanOutputMode: { enabled: true, intensity: "lite", autoClarity: true }, + }); + assert.deepEqual(sel, [{ id: "less-code", level: "ultra" }]); +}); + +test("legacy cavemanOutputMode maps to terse-prose at the same intensity", () => { + const sel = resolveOutputStyleSelection({ + cavemanOutputMode: { enabled: true, intensity: "full", autoClarity: true }, + }); + assert.deepEqual(sel, [{ id: "terse-prose", level: "full" }]); +}); + +test("disabled legacy mode and no styles → empty selection", () => { + assert.deepEqual( + resolveOutputStyleSelection({ + cavemanOutputMode: { enabled: false, intensity: "full", autoClarity: true }, + }), + [] + ); + assert.deepEqual(resolveOutputStyleSelection({}), []); +}); + +test("golden: legacy config injects the same prose instruction as the old injector", () => { + const body = { messages: [{ role: "user", content: "Summarize this API response." }] }; + const legacy = applyCavemanOutputMode(structuredClone(body), { + enabled: true, + intensity: "full", + autoClarity: true, + }); + const sel = resolveOutputStyleSelection({ + cavemanOutputMode: { enabled: true, intensity: "full", autoClarity: true }, + }); + const next = applyOutputStyles(structuredClone(body), sel); + + const legacyInstr = String(legacy.body.messages?.[0]?.content); + const nextInstr = String(next.body.messages?.[0]?.content); + // The prose instruction text (minus the marker line) must be byte-identical. + const strip = (s: string) => s.split("\n").slice(1).join("\n"); + assert.equal(strip(nextInstr), strip(legacyInstr)); +}); diff --git a/tests/unit/compression/output-styles-catalog.test.ts b/tests/unit/compression/output-styles-catalog.test.ts new file mode 100644 index 0000000000..2cbefb83cf --- /dev/null +++ b/tests/unit/compression/output-styles-catalog.test.ts @@ -0,0 +1,51 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + OUTPUT_STYLE_CATALOG, + OUTPUT_STYLE_IDS, + outputStyleMeta, + type OutputStyle, +} from "../../../open-sse/services/compression/outputStyles/catalog.ts"; + +test("catalog seeds terse-prose, less-code, terse-cjk with all three levels", () => { + for (const id of ["terse-prose", "less-code", "terse-cjk"]) { + const meta = outputStyleMeta(id); + assert.ok(meta, `${id} present`); + assert.equal(typeof meta.label, "string"); + for (const level of ["lite", "full", "ultra"] as const) { + assert.equal(typeof meta.levels[level], "string"); + assert.ok(meta.levels[level].length > 0, `${id}.${level} non-empty`); + } + } +}); + +test("OUTPUT_STYLE_IDS lists every catalog id in catalog (declaration) order", () => { + assert.deepEqual(OUTPUT_STYLE_IDS, Object.keys(OUTPUT_STYLE_CATALOG)); +}); + +test("terse-cjk carries a locale gate of zh", () => { + assert.equal(outputStyleMeta("terse-cjk").locale, "zh"); + assert.equal(outputStyleMeta("terse-prose").locale, undefined); +}); + +test("extensibility: one entry added to the catalog is enumerated with no other change", () => { + const probe: OutputStyle = { + id: "__probe__", + label: "Probe", + levels: { lite: "L", full: "F", ultra: "U" }, + }; + const extended = { ...OUTPUT_STYLE_CATALOG, [probe.id]: probe }; + const ids = Object.keys(extended); + assert.ok(ids.includes("__probe__")); + // Adding a style adds exactly one id; no plumbing edited. + assert.equal(ids.length, OUTPUT_STYLE_IDS.length + 1); +}); + +test("every level instruction is deterministic (no Date/Math.random tokens)", () => { + for (const id of OUTPUT_STYLE_IDS) { + const meta = outputStyleMeta(id); + for (const level of ["lite", "full", "ultra"] as const) { + assert.doesNotMatch(meta.levels[level], /Date\.now|Math\.random|\$\{/); + } + } +}); diff --git a/tests/unit/compression/output-styles-config.test.ts b/tests/unit/compression/output-styles-config.test.ts new file mode 100644 index 0000000000..d361e9483e --- /dev/null +++ b/tests/unit/compression/output-styles-config.test.ts @@ -0,0 +1,29 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { compressionSettingsUpdateSchema } from "../../../src/shared/validation/compressionConfigSchemas.ts"; +import type { CompressionConfig } from "../../../open-sse/services/compression/types.ts"; + +test("schema accepts a valid outputStyles selection", () => { + const parsed = compressionSettingsUpdateSchema.parse({ + outputStyles: [ + { id: "terse-prose", level: "full" }, + { id: "less-code", level: "lite" }, + ], + }); + assert.equal(parsed.outputStyles?.length, 2); +}); + +test("schema rejects an invalid level", () => { + assert.throws(() => + compressionSettingsUpdateSchema.parse({ + outputStyles: [{ id: "terse-prose", level: "extreme" }], + }) + ); +}); + +test("CompressionConfig type carries outputStyles", () => { + const cfg: Pick = { + outputStyles: [{ id: "terse-prose", level: "full" }], + }; + assert.equal(cfg.outputStyles?.[0]?.id, "terse-prose"); +}); diff --git a/tests/unit/compression/output-styles-wiring.test.ts b/tests/unit/compression/output-styles-wiring.test.ts new file mode 100644 index 0000000000..ef3d83086b --- /dev/null +++ b/tests/unit/compression/output-styles-wiring.test.ts @@ -0,0 +1,49 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildOutputStyleTelemetry } from "../../../open-sse/services/compression/outputStyles/telemetry.ts"; + +test("builds a telemetry record from an applied result", () => { + const rec = buildOutputStyleTelemetry({ + requestId: "req-1", + model: "gpt-4o", + provider: "openai", + source: "active-profile", + tokensBefore: 1000, + tokensAfter: 1000, + applied: true, + appliedStyles: [{ id: "terse-prose", level: "full" }], + }); + assert.equal(rec.requestId, "req-1"); + assert.equal(rec.ratio, 1); + assert.deepEqual(rec.outputStyles, [{ id: "terse-prose", level: "full" }]); + assert.equal(rec.outputStyleBypass, undefined); +}); + +test("records the bypass reason and omits styles when bypassed", () => { + const rec = buildOutputStyleTelemetry({ + requestId: "req-2", + model: "m", + provider: "p", + source: "default", + tokensBefore: 500, + tokensAfter: 500, + applied: false, + skippedReason: "security_warning", + }); + assert.equal(rec.outputStyleBypass, "security_warning"); + assert.equal(rec.outputStyles, undefined); +}); + +test("does not treat a benign skip (disabled/no_styles) as a bypass", () => { + const rec = buildOutputStyleTelemetry({ + requestId: "req-3", + model: "m", + provider: "p", + source: "off", + tokensBefore: 0, + tokensAfter: 0, + applied: false, + skippedReason: "no_styles", + }); + assert.equal(rec.outputStyleBypass, undefined); +}); diff --git a/tests/unit/compression/ultra-code-preservation.test.ts b/tests/unit/compression/ultra-code-preservation.test.ts index fb4ff71891..990867dc36 100644 --- a/tests/unit/compression/ultra-code-preservation.test.ts +++ b/tests/unit/compression/ultra-code-preservation.test.ts @@ -9,7 +9,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { ultraCompress } from "@omniroute/open-sse/services/compression/ultra.ts"; -test("ultraCompress preserves fenced code, inline code, and URLs byte-identical", () => { +test("ultraCompress preserves fenced code, inline code, and URLs byte-identical", async () => { const code = "```ts\nexport function add(a, b) {\n return a + b;\n}\n```"; const inline = "`add(x, y)`"; const url = "https://example.com/api/v1/auth?id=42"; @@ -20,7 +20,7 @@ test("ultraCompress preserves fenced code, inline code, and URLs byte-identical" // Realistic layout: fenced code block sits on its own line (markdown convention). const text = `${filler}\n\n${code}\n\nThen call ${inline} and see ${url} for details.\n\n${filler}`; - const { messages } = ultraCompress([{ role: "user", content: text }], { + const { messages } = await ultraCompress([{ role: "user", content: text }], { maxTokensPerMessage: 0, }); const out = typeof messages[0].content === "string" ? messages[0].content : ""; diff --git a/tests/unit/compression/ultra-slm-tier.test.ts b/tests/unit/compression/ultra-slm-tier.test.ts index 8db4c37769..918ac6a2f2 100644 --- a/tests/unit/compression/ultra-slm-tier.test.ts +++ b/tests/unit/compression/ultra-slm-tier.test.ts @@ -10,7 +10,7 @@ * The llmlingua backend is injectable (setLlmlinguaBackend), so the tier is testable * without the real ONNX model. */ -import { describe, it, after, afterEach } from "node:test"; +import { describe, it, after, afterEach, test } from "node:test"; import assert from "node:assert/strict"; import { applyCompressionAsync } from "../../../open-sse/services/compression/index.ts"; @@ -101,3 +101,252 @@ describe("ultra SLM tier — modelPath routes through llmlingua", () => { assert.ok(!techniques(result.stats).includes("ultra-slm")); }); }); + +// ─── Phase 4 (B): SLM-tier resolver, probe, telemetry, pre-warm ────────────── +// (Appended to the pre-existing legacy `modelPath` suite above, which must stay green.) + +import { DEFAULT_COMPRESSION_CONFIG } from "../../../open-sse/services/compression/types.ts"; + +test("DEFAULT_COMPRESSION_CONFIG defaults ultraEngine to 'heuristic'", () => { + assert.equal(DEFAULT_COMPRESSION_CONFIG.ultraEngine, "heuristic"); +}); + +test("DEFAULT_COMPRESSION_CONFIG defaults ultraSlmPrewarm to false", () => { + assert.equal(DEFAULT_COMPRESSION_CONFIG.ultraSlmPrewarm, false); +}); + +import type { CompressionStats } from "../../../open-sse/services/compression/types.ts"; + +test("CompressionStats accepts an optional ultraTier signal", () => { + const s = { + originalTokens: 10, + compressedTokens: 5, + savingsPercent: 50, + techniquesUsed: ["ultra-heuristic-pruning"], + mode: "ultra" as const, + timestamp: 1, + ultraTier: "heuristic" as const, + } satisfies CompressionStats; + assert.equal(s.ultraTier, "heuristic"); +}); + +import { + ultraCompress, + ultraCompressHeuristic, +} from "../../../open-sse/services/compression/ultra.ts"; + +test("ultraCompressHeuristic is a synchronous pure heuristic (no SLM)", () => { + const cfg = { + enabled: true, + compressionRate: 0.5, + minScoreThreshold: 0.3, + slmFallbackToAggressive: false, + maxTokensPerMessage: 0, + }; + const r = ultraCompressHeuristic( + [{ role: "user", content: "the quick brown fox jumps over the lazy dog" }], + cfg + ); + assert.equal(r.stats.mode, "ultra"); + assert.equal(r.stats.ultraTier, "heuristic"); + assert.ok(r.stats.techniquesUsed.includes("ultra-heuristic-pruning")); +}); + +test("ultraCompress with default config (no ultraEngine) uses heuristic tier", async () => { + const r = await ultraCompress([{ role: "user", content: "the quick brown fox jumps" }], { + enabled: true, + compressionRate: 0.5, + minScoreThreshold: 0.3, + slmFallbackToAggressive: false, + maxTokensPerMessage: 0, + }); + assert.equal(r.stats.ultraTier, "heuristic"); +}); + +import { + __setUltraSlmTestHooks, + __resetUltraEntryForTests, +} from "../../../open-sse/services/compression/engines/llmlingua/ultraEntry.ts"; + +test("ultraEngine:'slm' with available stub backend records ultraTier:'slm'", async () => { + __setUltraSlmTestHooks({ + available: true, + run: async (text) => text.slice(0, Math.ceil(text.length / 2)), + }); + try { + const r = await ultraCompress( + [{ role: "user", content: "the quick brown fox jumps over the lazy dog repeatedly today" }], + { + enabled: true, + compressionRate: 0.5, + minScoreThreshold: 0.3, + slmFallbackToAggressive: false, + maxTokensPerMessage: 0, + ultraEngine: "slm", + } + ); + assert.equal(r.stats.ultraTier, "slm"); + assert.ok(r.stats.techniquesUsed.includes("ultra-slm")); + assert.ok(r.stats.compressedTokens <= r.stats.originalTokens); + } finally { + __resetUltraEntryForTests(); + } +}); + +test("ultraEngine:'slm' but backend throws → ultraTier:'heuristic-fallback'", async () => { + __setUltraSlmTestHooks({ + available: true, + run: async () => { + throw new Error("worker timeout"); + }, + }); + try { + const r = await ultraCompress( + [{ role: "user", content: "the quick brown fox jumps over the lazy dog repeatedly today" }], + { + enabled: true, + compressionRate: 0.5, + minScoreThreshold: 0.3, + slmFallbackToAggressive: false, + maxTokensPerMessage: 0, + ultraEngine: "slm", + } + ); + assert.equal(r.stats.ultraTier, "heuristic-fallback"); + } finally { + __resetUltraEntryForTests(); + } +}); + +test("ultraEngine:'slm' but slmAvailable() false → heuristic tier (no SLM attempt)", async () => { + __setUltraSlmTestHooks({ + available: false, + run: async () => { + throw new Error("should not be called"); + }, + }); + try { + const r = await ultraCompress([{ role: "user", content: "the quick brown fox jumps" }], { + enabled: true, + compressionRate: 0.5, + minScoreThreshold: 0.3, + slmFallbackToAggressive: false, + maxTokensPerMessage: 0, + ultraEngine: "slm", + }); + assert.equal(r.stats.ultraTier, "heuristic"); + } finally { + __resetUltraEntryForTests(); + } +}); + +test("SLM tier preserves fenced code + URLs verbatim (structure wrapper)", async () => { + // Stub the SLM to lowercase prose — any leakage of code/URL into it would show. + __setUltraSlmTestHooks({ + available: true, + run: async (text) => (text.trim() ? text.toLowerCase() + " x" : text), + }); + try { + const code = "```js\nconst A = 1; // KEEP\n```"; + const url = "https://Example.com/Path"; + // The fenced block must open at line-start for `extractPreservedBlocks` to tombstone + // it (the same rule the heuristic Tier-A already relies on); both tiers share that + // wrapper, so this proves the SLM tier preserves structure identically. + const content = `Some PROSE here and ${url} trailing PROSE\n${code}\nmore PROSE after`; + const r = await ultraCompress([{ role: "user", content }], { + enabled: true, + compressionRate: 0.5, + minScoreThreshold: 0.3, + slmFallbackToAggressive: false, + maxTokensPerMessage: 0, + ultraEngine: "slm", + }); + const out = r.messages[0].content as string; + assert.ok(out.includes(code), "fenced code block must survive verbatim"); + assert.ok(out.includes(url), "URL must survive verbatim"); + } finally { + __resetUltraEntryForTests(); + } +}); + +import { ultraEngine } from "../../../open-sse/services/compression/engines/cavemanAdapter.ts"; + +test("stacked ultraEngine.apply stays synchronous and compresses via heuristic", () => { + const res = ultraEngine.apply( + { messages: [{ role: "user", content: "the quick brown fox jumps over the lazy dog" }] }, + { config: { ultra: { compressionRate: 0.5 } } as never } + ); + // Synchronous result object (not a Promise), with a real stats record. + assert.equal(typeof (res as { then?: unknown }).then, "undefined"); + assert.ok(res.stats); +}); + +test("applyCompressionAsync ultra + ultraEngine:'slm' (stub) yields ultraTier in stats", async () => { + __setUltraSlmTestHooks({ + available: true, + run: async (text) => text.slice(0, Math.ceil(text.length / 2)), + }); + try { + const reqBody = { + messages: [ + { role: "user", content: "the quick brown fox jumps over the lazy dog more than once" }, + ], + }; + const result = await applyCompressionAsync(reqBody, "ultra", { + config: { + enabled: true, + defaultMode: "ultra", + ultraEngine: "slm", + ultra: { + enabled: true, + compressionRate: 0.5, + minScoreThreshold: 0.3, + slmFallbackToAggressive: false, + maxTokensPerMessage: 0, + }, + } as never, + }); + assert.equal(result.stats?.ultraTier, "slm"); + } finally { + __resetUltraEntryForTests(); + } +}); + +import * as compression from "../../../open-sse/services/compression/index.ts"; + +test("compression index re-exports the ultra-SLM surface", () => { + assert.equal(typeof compression.ultraCompressHeuristic, "function"); + assert.equal(typeof compression.slmAvailable, "function"); + assert.equal(typeof compression.runLlmlinguaUltra, "function"); + assert.equal(typeof compression.prewarmLlmlinguaUltra, "function"); +}); + +import { shouldPrewarmUltraSlm } from "../../../open-sse/services/compression/ultra.ts"; + +test("shouldPrewarmUltraSlm: true only when slm + prewarm both on", () => { + assert.equal(shouldPrewarmUltraSlm({ ultraEngine: "slm", ultraSlmPrewarm: true }), true); + assert.equal(shouldPrewarmUltraSlm({ ultraEngine: "slm", ultraSlmPrewarm: false }), false); + assert.equal(shouldPrewarmUltraSlm({ ultraEngine: "heuristic", ultraSlmPrewarm: true }), false); + assert.equal(shouldPrewarmUltraSlm({}), false); +}); + +import { maybePrewarmUltraSlmOnConfig } from "../../../open-sse/services/compression/ultra.ts"; + +test("maybePrewarmUltraSlmOnConfig fires prewarm when slm+prewarm on (stub)", async () => { + let warmed = 0; + __setUltraSlmTestHooks({ + available: true, + run: async (t) => { + warmed++; + return t.slice(0, 1); + }, + }); + try { + await maybePrewarmUltraSlmOnConfig({ ultraEngine: "slm", ultraSlmPrewarm: true }); + assert.equal(warmed, 1); + await maybePrewarmUltraSlmOnConfig({ ultraEngine: "heuristic", ultraSlmPrewarm: true }); + assert.equal(warmed, 1); // unchanged — heuristic does not prewarm + } finally { + __resetUltraEntryForTests(); + } +}); diff --git a/tests/unit/dast-method-not-allowed.test.ts b/tests/unit/dast-method-not-allowed.test.ts index 4a7f16e0d1..55e04ed9e4 100644 --- a/tests/unit/dast-method-not-allowed.test.ts +++ b/tests/unit/dast-method-not-allowed.test.ts @@ -75,7 +75,7 @@ test("raw HTTP guard allows documented methods through", () => { }); test("OpenAPI documents high-risk route auth and setup responses", () => { - const spec = readFileSync("docs/reference/openapi.yaml", "utf8"); + const spec = readFileSync("docs/openapi.yaml", "utf8"); const apiKeyDetailStart = spec.indexOf(" /api/keys/{id}:"); const apiKeyDetailEnd = spec.indexOf("\n /api/combos:", apiKeyDetailStart); const apiKeyDetail = spec.slice(apiKeyDetailStart, apiKeyDetailEnd); diff --git a/tests/unit/db-import-max-size-4719.test.ts b/tests/unit/db-import-max-size-4719.test.ts new file mode 100644 index 0000000000..9f15f4a022 --- /dev/null +++ b/tests/unit/db-import-max-size-4719.test.ts @@ -0,0 +1,34 @@ +// #4719 — DB backup import failed for databases larger than the hard-coded 100 MB cap. +// Real databases bloat (a 156 MB file VACUUMs down to 5 MB) but still couldn't be +// re-imported. The cap is now operator-tunable via OMNIROUTE_DB_IMPORT_MAX_MB, with the +// historical 100 MB as default and a 4 GB ceiling for invalid/hostile values. +import test from "node:test"; +import assert from "node:assert/strict"; + +const { resolveMaxUploadSizeBytes } = await import( + "../../src/app/api/db-backups/import/route.ts" +); + +const MB = 1024 * 1024; + +test("defaults to 100 MB when env is unset (#4719)", () => { + assert.equal(resolveMaxUploadSizeBytes({}), 100 * MB); +}); + +test("honors OMNIROUTE_DB_IMPORT_MAX_MB (#4719)", () => { + assert.equal(resolveMaxUploadSizeBytes({ OMNIROUTE_DB_IMPORT_MAX_MB: "256" }), 256 * MB); +}); + +test("clamps absurd values to the 4 GB ceiling (#4719)", () => { + assert.equal(resolveMaxUploadSizeBytes({ OMNIROUTE_DB_IMPORT_MAX_MB: "999999" }), 4096 * MB); +}); + +test("falls back to default for invalid / out-of-range values (#4719)", () => { + assert.equal(resolveMaxUploadSizeBytes({ OMNIROUTE_DB_IMPORT_MAX_MB: "abc" }), 100 * MB); + assert.equal(resolveMaxUploadSizeBytes({ OMNIROUTE_DB_IMPORT_MAX_MB: "0" }), 100 * MB); + assert.equal(resolveMaxUploadSizeBytes({ OMNIROUTE_DB_IMPORT_MAX_MB: "-5" }), 100 * MB); +}); + +test("floors fractional MB values (#4719)", () => { + assert.equal(resolveMaxUploadSizeBytes({ OMNIROUTE_DB_IMPORT_MAX_MB: "150.9" }), 150 * MB); +}); diff --git a/tests/unit/db-tier-config.test.ts b/tests/unit/db-tier-config.test.ts index 684a619d45..86eb47017d 100644 --- a/tests/unit/db-tier-config.test.ts +++ b/tests/unit/db-tier-config.test.ts @@ -1,4 +1,4 @@ -import { describe, it, beforeEach } from "node:test"; +import { describe, it, beforeEach, mock } from "node:test"; import assert from "node:assert/strict"; import { @@ -43,14 +43,86 @@ describe("tierConfig DB module", () => { assert.ok(loaded, "should return config after overwrite"); }); - it("loadTierConfigFromDb handles corrupted JSON gracefully", async () => { - // Directly insert corrupted data + it("loadTierConfigFromDb handles corrupted JSON gracefully (#4517)", async () => { + // Reproduce the bug report: a hand-edited / partially written row contains + // non-JSON garbage. We expect null + a warning, NOT a thrown error. const { getDbInstance } = await import("../../src/lib/db/core.ts"); const db = getDbInstance(); db.prepare( "INSERT OR REPLACE INTO tier_config (key, value, updated_at) VALUES ('tier_config', ?, datetime('now'))" ).run("not-valid-json{{{"); - const result = loadTierConfigFromDb(); - assert.equal(result, null, "should return null for corrupted JSON"); + + // Spy on the logger to confirm we emit a warning that operators can spot. + const loggerModule = await import("@omniroute/open-sse/utils/logger.ts"); + const warnSpy = mock.method(loggerModule.defaultLogger, "warn", () => {}); + + try { + const result = loadTierConfigFromDb(); + assert.equal(result, null, "should return null for corrupted JSON"); + assert.ok( + warnSpy.mock.calls.length > 0, + "should emit at least one warning so operators can spot the corruption" + ); + // Sanity: loadTierConfig() still returns DEFAULT_TIER_CONFIG. + const fallback = loadTierConfig(); + assert.deepEqual(fallback.freeProviders, DEFAULT_TIER_CONFIG.freeProviders); + } finally { + warnSpy.mock.restore(); + } + }); + + it("loadTierConfigFromDb handles valid JSON that fails Zod (#4517)", async () => { + // JSON parses fine, but the shape doesn't match the schema (freeThreshold = -1 + // violates the min(0) constraint). + const { getDbInstance } = await import("../../src/lib/db/core.ts"); + const db = getDbInstance(); + const badShape = JSON.stringify({ + version: "1.0.0", + defaults: { freeThreshold: -1, cheapThreshold: 1.0 }, + providerOverrides: [], + modelOverrides: [], + freeProviders: [], + }); + db.prepare( + "INSERT OR REPLACE INTO tier_config (key, value, updated_at) VALUES ('tier_config', ?, datetime('now'))" + ).run(badShape); + + const loggerModule = await import("@omniroute/open-sse/utils/logger.ts"); + const warnSpy = mock.method(loggerModule.defaultLogger, "warn", () => {}); + + try { + const result = loadTierConfigFromDb(); + assert.equal(result, null, "should return null for Zod-failing config"); + assert.ok(warnSpy.mock.calls.length > 0, "should log a warning on Zod failure"); + } finally { + warnSpy.mock.restore(); + } + }); + + it("loadTierConfigFromDb truncates very long corrupted values in the warning preview (#4517)", async () => { + const { getDbInstance } = await import("../../src/lib/db/core.ts"); + const db = getDbInstance(); + // 1000 chars of garbage — the warning preview must truncate to avoid log floods. + const long = "{".repeat(1000); + db.prepare( + "INSERT OR REPLACE INTO tier_config (key, value, updated_at) VALUES ('tier_config', ?, datetime('now'))" + ).run(long); + + const loggerModule = await import("@omniroute/open-sse/utils/logger.ts"); + const warnSpy = mock.method(loggerModule.defaultLogger, "warn", () => {}); + + try { + const result = loadTierConfigFromDb(); + assert.equal(result, null); + assert.ok(warnSpy.mock.calls.length > 0); + const payload = warnSpy.mock.calls[0].arguments[0] as Record; + const preview = typeof payload.value === "string" ? payload.value : ""; + assert.ok( + preview.length <= 250, + `warning preview should be truncated, got length=${preview.length}` + ); + } finally { + warnSpy.mock.restore(); + } }); }); diff --git a/tests/unit/db/compressionRunTelemetry.test.ts b/tests/unit/db/compressionRunTelemetry.test.ts new file mode 100644 index 0000000000..a6bbe908c5 --- /dev/null +++ b/tests/unit/db/compressionRunTelemetry.test.ts @@ -0,0 +1,69 @@ +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const tmpDir = mkdtempSync(join(tmpdir(), "omniroute-rt-")); +process.env.DATA_DIR = tmpDir; + +const core = await import("../../../src/lib/db/core.ts"); +core.resetDbInstance(); +const { + insertCompressionRunTelemetryRow, + getCompressionRunTelemetrySummary, +} = await import("../../../src/lib/db/compressionRunTelemetry.ts"); +const { getDbInstance } = core; + +describe("compressionRunTelemetry", () => { + beforeEach(() => { + const db = getDbInstance(); + db.exec("DROP TABLE IF EXISTS compression_run_telemetry"); + }); + + it("persists a run record and summarizes savings + applied styles", () => { + insertCompressionRunTelemetryRow({ + requestId: "req-1", + model: "gpt-4o", + provider: "openai", + source: "active-profile", + tokensBefore: 1000, + tokensAfter: 700, + ratio: 0.7, + outputStyles: [{ id: "terse-prose", level: "full" }], + outputTokens: 320, + }); + insertCompressionRunTelemetryRow({ + requestId: "req-2", + model: "gpt-4o", + provider: "openai", + source: "default", + tokensBefore: 500, + tokensAfter: 500, + ratio: 1, + outputStyleBypass: "security_warning", + }); + + const summary = getCompressionRunTelemetrySummary(); + assert.equal(summary.totalRuns, 2); + assert.equal(summary.totalTokensSaved, 300); // (1000-700) + (500-500) + assert.equal(summary.runsWithStyles, 1); + assert.equal(summary.bypassCount, 1); + assert.deepEqual(summary.appliedStyleCounts, { "terse-prose": 1 }); + }); + + it("never throws on a malformed row; outputStyles is optional", () => { + assert.doesNotThrow(() => + insertCompressionRunTelemetryRow({ + requestId: "req-3", + model: "m", + provider: "p", + source: "off", + tokensBefore: 0, + tokensAfter: 0, + ratio: 0, + }) + ); + assert.equal(getCompressionRunTelemetrySummary().totalRuns, 1); + }); +}); diff --git a/tests/unit/db/vacuum-scheduler.test.ts b/tests/unit/db/vacuum-scheduler.test.ts index 2376924b61..0f979c2495 100644 --- a/tests/unit/db/vacuum-scheduler.test.ts +++ b/tests/unit/db/vacuum-scheduler.test.ts @@ -4,12 +4,10 @@ * Covers: * 1. Module exports the expected public API. * 2. getState() returns the documented shape before any init/run. - * 3. init() is idempotent (safe to call from instrumentation-node.ts). - * 4. stop() is safe before init() and idempotent. + * 3. init() honors Storage's scheduledVacuum/vacuumHour settings. + * 4. refresh() applies Storage setting changes without a restart. * 5. runNow() succeeds on a healthy DB, persists lastRunAt, clears isRunning. - * 6. runNow() called twice concurrently yields exactly one success and one - * "already_running". - * 7. lastRunAt survives a simulated restart (__resetForTests + init reloads the + * 6. lastRunAt survives a simulated restart (__resetForTests + init reloads the * persisted state from key_value). * * Rebuild note (PR #4480): the original PR test was authored against the Vitest @@ -29,19 +27,28 @@ import path from "node:path"; const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-vacuum-scheduler-")); const originalDataDir = process.env.DATA_DIR; -const originalEnabled = process.env.OMNIROUTE_VACUUM_ENABLED; process.env.DATA_DIR = TEST_DATA_DIR; -// Keep the scheduler from arming a real interval timer during unit tests. -process.env.OMNIROUTE_VACUUM_ENABLED = "0"; const core = await import("../../../src/lib/db/core.ts"); core.resetDbInstance(); const scheduler = await import("../../../src/lib/db/vacuumScheduler.ts"); +function setOptimizationSettings(values: { scheduledVacuum?: string; vacuumHour?: number }) { + const db = core.getDbInstance(); + const insert = db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)" + ); + for (const [key, value] of Object.entries(values)) { + insert.run("databaseSettings", `optimization.${key}`, JSON.stringify(value)); + } +} + test.beforeEach(() => { scheduler.__resetForTests(); + const db = core.getDbInstance(); + db.prepare("DELETE FROM key_value WHERE namespace IN ('scheduler', 'databaseSettings')").run(); }); test.after(() => { @@ -50,8 +57,6 @@ test.after(() => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); if (originalDataDir === undefined) delete process.env.DATA_DIR; else process.env.DATA_DIR = originalDataDir; - if (originalEnabled === undefined) delete process.env.OMNIROUTE_VACUUM_ENABLED; - else process.env.OMNIROUTE_VACUUM_ENABLED = originalEnabled; }); test("module loads and exports the expected public API", () => { @@ -59,6 +64,8 @@ test("module loads and exports the expected public API", () => { assert.equal(typeof scheduler.stop, "function"); assert.equal(typeof scheduler.runNow, "function"); assert.equal(typeof scheduler.getState, "function"); + assert.equal(typeof scheduler.refresh, "function"); + assert.equal(typeof scheduler.resolveNextRunAt, "function"); }); test("getState() returns the documented shape before any init/run", () => { @@ -72,13 +79,69 @@ test("getState() returns the documented shape before any init/run", () => { assert.equal(state.nextRunAt, null); }); +test("resolveNextRunAt respects Storage frequency and vacuumHour", () => { + const nowBeforeHour = new Date(2026, 0, 1, 1, 30, 0, 0).getTime(); + const todayAtTwo = new Date(2026, 0, 1, 2, 0, 0, 0).getTime(); + assert.equal( + scheduler.resolveNextRunAt({ scheduledVacuum: "daily", vacuumHour: 2 }, null, nowBeforeHour), + todayAtTwo + ); + + const nowAfterHour = new Date(2026, 0, 1, 3, 0, 0, 0).getTime(); + const tomorrowAtTwo = new Date(2026, 0, 2, 2, 0, 0, 0).getTime(); + assert.equal( + scheduler.resolveNextRunAt({ scheduledVacuum: "daily", vacuumHour: 2 }, null, nowAfterHour), + tomorrowAtTwo + ); + + const lastRun = new Date(2026, 0, 1, 3, 0, 0, 0).getTime(); + const nextWeekAtTwo = new Date(2026, 0, 8, 2, 0, 0, 0).getTime(); + assert.equal( + scheduler.resolveNextRunAt({ scheduledVacuum: "weekly", vacuumHour: 2 }, lastRun, lastRun), + nextWeekAtTwo + ); + + assert.equal( + scheduler.resolveNextRunAt({ scheduledVacuum: "never", vacuumHour: 2 }, null, nowBeforeHour), + null + ); +}); + +test("init() honors Storage scheduledVacuum=never", () => { + setOptimizationSettings({ scheduledVacuum: "never", vacuumHour: 4 }); + const state = scheduler.init(); + assert.equal(state.enabled, false); + assert.equal(state.intervalMs, 0); + assert.equal(state.nextRunAt, null); +}); + +test("init() honors Storage schedule settings", () => { + setOptimizationSettings({ scheduledVacuum: "weekly", vacuumHour: 4 }); + const state = scheduler.init(); + assert.equal(state.enabled, true); + assert.equal(state.intervalMs, 7 * 24 * 60 * 60 * 1000); + assert.notEqual(state.nextRunAt, null); +}); + +test("refresh() applies Storage setting changes without restart", () => { + setOptimizationSettings({ scheduledVacuum: "daily", vacuumHour: 1 }); + assert.equal(scheduler.init().enabled, true); + + setOptimizationSettings({ scheduledVacuum: "never" }); + const state = scheduler.refresh(); + assert.equal(state.enabled, false); + assert.equal(state.nextRunAt, null); +}); + test("init() is idempotent — calling it twice does not throw", () => { + setOptimizationSettings({ scheduledVacuum: "never" }); assert.doesNotThrow(() => scheduler.init()); assert.doesNotThrow(() => scheduler.init()); scheduler.stop(); }); test("stop() is safe to call before init() and is idempotent", () => { + setOptimizationSettings({ scheduledVacuum: "never" }); assert.doesNotThrow(() => scheduler.stop()); scheduler.init(); assert.doesNotThrow(() => scheduler.stop()); @@ -86,6 +149,7 @@ test("stop() is safe to call before init() and is idempotent", () => { }); test("runNow() succeeds on a healthy DB and persists lastRunAt", async () => { + setOptimizationSettings({ scheduledVacuum: "never" }); scheduler.init(); try { const result = await scheduler.runNow(); @@ -107,6 +171,7 @@ test("runNow() can be called repeatedly; each run succeeds and refreshes lastRun // run — the isRunning guard (which returns "already_running") cannot be // triggered by overlapping awaits in-process. The realistic contract is that // sequential runs each succeed and update lastRunAt. + setOptimizationSettings({ scheduledVacuum: "never" }); scheduler.init(); try { const first = await scheduler.runNow(); @@ -121,6 +186,7 @@ test("runNow() can be called repeatedly; each run succeeds and refreshes lastRun }); test("lastRunAt survives a simulated restart (state reloaded from key_value)", async () => { + setOptimizationSettings({ scheduledVacuum: "never" }); scheduler.init(); await scheduler.runNow(); const beforeRestart = scheduler.getState().lastRunAt; diff --git a/tests/unit/deepseek-web-tool-result-prompt-4712.test.ts b/tests/unit/deepseek-web-tool-result-prompt-4712.test.ts new file mode 100644 index 0000000000..38136f270d --- /dev/null +++ b/tests/unit/deepseek-web-tool-result-prompt-4712.test.ts @@ -0,0 +1,66 @@ +// #4712 — deepseek-web drops role:"tool" messages. The web API takes a single `prompt` +// string, so tool results must be folded into the transcript. The agentic path +// (buildToolConversationPrompt) already does this, but messagesToPrompt — used whenever +// the follow-up request no longer carries a `tools[]` array (hasTools=false) — silently +// discarded role:"tool" messages, so the model never saw the tool output and either +// re-called the tool endlessly or answered "I don't have that information". +import test from "node:test"; +import assert from "node:assert/strict"; + +const { messagesToPrompt } = await import("../../open-sse/executors/deepseek-web.ts"); + +const CONVO = [ + { role: "system", content: "You are helpful." }, + { role: "user", content: "what is the weather in Tokyo?" }, + { + role: "assistant", + content: "", + tool_calls: [ + { id: "call_1", function: { name: "get_weather", arguments: '{"city":"Tokyo"}' } }, + ], + }, + { + role: "tool", + tool_call_id: "call_1", + name: "get_weather", + content: '{"temp":22,"conditions":"Sunny"}', + }, + { role: "user", content: "should I bring an umbrella?" }, +]; + +test("messagesToPrompt includes role:tool results in the rolling-window transcript (#4712)", () => { + const prompt = messagesToPrompt(CONVO, 50); + // The tool output must reach the model — this is the regression guard. + assert.match(prompt, /22/); + assert.match(prompt, /Sunny/); + // It should be labelled as a tool result, not silently merged into a user turn. + assert.match(prompt, /Tool result/i); + // Tool name is preserved for context when available. + assert.match(prompt, /get_weather/); +}); + +test("messagesToPrompt tags the tool name from tool_call_id when no explicit name (#4712)", () => { + const convo = [ + { role: "user", content: "q" }, + { + role: "assistant", + content: "", + tool_calls: [{ id: "c9", function: { name: "lookup", arguments: "{}" } }], + }, + { role: "tool", tool_call_id: "c9", content: "RESULT_PAYLOAD" }, + ]; + const prompt = messagesToPrompt(convo, 50); + assert.match(prompt, /RESULT_PAYLOAD/); + assert.match(prompt, /lookup/); +}); + +test("messagesToPrompt still drops empty tool results without crashing (#4712)", () => { + const convo = [ + { role: "user", content: "hello" }, + { role: "tool", tool_call_id: "x", content: "" }, + { role: "user", content: "world" }, + ]; + const prompt = messagesToPrompt(convo, 50); + assert.match(prompt, /hello/); + assert.match(prompt, /world/); +}); diff --git a/tests/unit/home-page-client-hook-imports-4759.test.ts b/tests/unit/home-page-client-hook-imports-4759.test.ts new file mode 100644 index 0000000000..af836e21dc --- /dev/null +++ b/tests/unit/home-page-client-hook-imports-4759.test.ts @@ -0,0 +1,55 @@ +// #4759 / #4745 — the /home dashboard crashed in production builds with +// "ReferenceError: useLiveRequests is not defined" because HomePageClient.tsx called the +// hook but never imported it (and the binding was unused). typecheck:core does not cover +// the Next dashboard .tsx files and ESLint's no-undef is off (TS owns that), so nothing +// but `next build` caught it. +// +// #4596 — that same top-level useLiveRequests() call opened the live-dashboard WebSocket +// unconditionally, even when Provider Topology was hidden. The live feed is owned by the +// settings-gated (useLiveRequests({ enabled })), so +// HomePageClient must not open its own unconditional socket. +// +// Fix: remove the dead, unconditional useLiveRequests() call from HomePageClient. These +// static guards lock both regressions down. +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const src = readFileSync( + fileURLToPath( + new URL("../../src/app/(dashboard)/dashboard/HomePageClient.tsx", import.meta.url) + ), + "utf8" +); + +function collect(re: RegExp, group = 1): Set { + return new Set([...src.matchAll(re)].map((m) => m[group])); +} + +const called = collect(/\b(use[A-Z]\w*)\s*\(/g); +const importedNames = new Set( + [...src.matchAll(/import\s+(?:type\s+)?\{([^}]*)\}/g)].flatMap((m) => + m[1].split(",").map((s) => s.trim().split(/\s+as\s+/).pop()!.trim()) + ) +); +const declared = collect(/(?:function|const|let|var)\s+(use[A-Z]\w*)/g); + +test("every hook called in HomePageClient.tsx is imported or declared (#4759/#4745)", () => { + const missing = [...called].filter((h) => !importedNames.has(h) && !declared.has(h)); + assert.deepEqual( + missing, + [], + `hooks used without import (ReferenceError in production build): ${missing.join(", ")}` + ); +}); + +test("HomePageClient does not open its own unconditional live socket (#4596)", () => { + // The live feed belongs to the settings-gated . A bare + // useLiveRequests( call at the page level would open the WebSocket even when topology + // is hidden — exactly the regression #4596 reports (and the dead binding behind #4759). + assert.ok( + !/\buseLiveRequests\s*\(/.test(src), + "HomePageClient.tsx must not call useLiveRequests directly; delegate it to HomeProviderTopologySection" + ); +}); diff --git a/tests/unit/onboarding-step-titles-i18n-4698.test.ts b/tests/unit/onboarding-step-titles-i18n-4698.test.ts new file mode 100644 index 0000000000..2d81d2c89d --- /dev/null +++ b/tests/unit/onboarding-step-titles-i18n-4698.test.ts @@ -0,0 +1,34 @@ +// #4698 — the onboarding wizard rendered each step title via t(stepId), but the "tiers" +// step had no matching onboarding.tiers key, so next-intl threw +// "MISSING_MESSAGE: onboarding.tiers (en)" and the wizard crashed. Guard that every step +// id used as a title resolves to a string in the source (en) catalog. +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +// Mirrors STEP_IDS in src/app/(dashboard)/dashboard/onboarding/page.tsx and the +// title lookup `t(id === "done" ? "ready" : id)`. +const STEP_IDS = ["welcome", "tiers", "security", "provider", "test", "done"]; +const titleKeyFor = (id: string) => (id === "done" ? "ready" : id); + +const enPath = fileURLToPath( + new URL("../../src/i18n/messages/en.json", import.meta.url) +); +const en = JSON.parse(readFileSync(enPath, "utf8")); + +test("every onboarding step has a string title key in en.json (#4698)", () => { + const onboarding = en.onboarding ?? {}; + for (const id of STEP_IDS) { + const key = titleKeyFor(id); + assert.equal( + typeof onboarding[key], + "string", + `onboarding.${key} (for step "${id}") must be a string title` + ); + } +}); + +test("onboarding.tiers specifically exists (regression guard #4698)", () => { + assert.equal(typeof en.onboarding?.tiers, "string"); +}); diff --git a/tests/unit/openapi-coverage.test.ts b/tests/unit/openapi-coverage.test.ts index be5c8425f4..d69f65b52e 100644 --- a/tests/unit/openapi-coverage.test.ts +++ b/tests/unit/openapi-coverage.test.ts @@ -6,7 +6,7 @@ import * as yaml from "js-yaml"; const ROOT = process.cwd(); const API_ROOT = path.join(ROOT, "src", "app", "api"); -const OPENAPI_PATH = path.join(ROOT, "docs", "reference", "openapi.yaml"); +const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml"); function collectRoutePaths(dir: string): string[] { const entries = fs.readdirSync(dir, { withFileTypes: true }); diff --git a/tests/unit/openapi-security-tiers.test.ts b/tests/unit/openapi-security-tiers.test.ts index beab4d4815..35c5d31520 100644 --- a/tests/unit/openapi-security-tiers.test.ts +++ b/tests/unit/openapi-security-tiers.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import * as yaml from "js-yaml"; const ROOT = process.cwd(); -const OPENAPI_PATH = path.join(ROOT, "docs", "reference", "openapi.yaml"); +const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml"); const { LOCAL_ONLY_API_PREFIXES, ALWAYS_PROTECTED_API_PATHS } = await import("../../src/server/authz/routeGuard.ts"); diff --git a/tests/unit/openapi-ws-endpoint.test.ts b/tests/unit/openapi-ws-endpoint.test.ts index 46a917bade..7077e2585e 100644 --- a/tests/unit/openapi-ws-endpoint.test.ts +++ b/tests/unit/openapi-ws-endpoint.test.ts @@ -11,7 +11,7 @@ import * as yaml from "js-yaml"; // in the spec, so it was invisible in the endpoints reference. const ROOT = fileURLToPath(new URL("../../", import.meta.url)); -const spec = yaml.load(readFileSync(ROOT + "docs/reference/openapi.yaml", "utf8")) as { +const spec = yaml.load(readFileSync(ROOT + "docs/openapi.yaml", "utf8")) as { paths: Record }>>; }; diff --git a/tests/unit/provider-node-dedup-4746.test.ts b/tests/unit/provider-node-dedup-4746.test.ts new file mode 100644 index 0000000000..18d8d0daee --- /dev/null +++ b/tests/unit/provider-node-dedup-4746.test.ts @@ -0,0 +1,35 @@ +// #4746 — the compatible-provider "add" modals appended provider nodes with +// `setProviderNodes((prev) => [...prev, node])`, so the same provider id could land in the +// array more than once (refresh-then-add, double-click, retry, StrictMode double-invocation), +// producing duplicate cards and invalidating the compatibleProviderGroups memo on no-op adds. +// upsertProviderNodeById dedups by id and keeps array identity stable for no-op adds. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { upsertProviderNodeById } from "../../src/app/(dashboard)/dashboard/providers/providerPageUtils.ts"; + +test("appends a node with a new id (#4746)", () => { + const prev = [{ id: "a", name: "A" }]; + const next = upsertProviderNodeById(prev, { id: "b", name: "B" }); + assert.deepEqual(next.map((n) => n.id), ["a", "b"]); +}); + +test("does not append a duplicate id — same identical payload returns prev unchanged (#4746)", () => { + const prev = [{ id: "a", name: "A" }]; + const next = upsertProviderNodeById(prev, { id: "a", name: "A" }); + assert.equal(next.length, 1); + assert.equal(next, prev, "no-op add must keep the same array reference (memo stability)"); +}); + +test("replaces an existing id when the payload changed (#4746)", () => { + const prev = [{ id: "a", name: "A" }, { id: "b", name: "B" }]; + const next = upsertProviderNodeById(prev, { id: "a", name: "A2" }); + assert.equal(next.length, 2); + assert.equal(next.find((n) => n.id === "a")?.name, "A2"); + assert.notEqual(next, prev); +}); + +test("appends when id is missing/null (cannot dedup) (#4746)", () => { + const prev = [{ id: "a" }]; + const next = upsertProviderNodeById(prev, { id: null } as { id: string | null }); + assert.equal(next.length, 2); +}); diff --git a/tests/unit/ui/compression-settings-page.test.tsx b/tests/unit/ui/compression-settings-page.test.tsx index 277b386747..6e4319f51b 100644 --- a/tests/unit/ui/compression-settings-page.test.tsx +++ b/tests/unit/ui/compression-settings-page.test.tsx @@ -6,6 +6,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; // ── Mock next-intl (CompressionSettingsTab calls useTranslations) ────────── vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key, + useLocale: () => "en", })); // ── Helpers ─────────────────────────────────────────────────────────────── @@ -55,6 +56,21 @@ afterEach(async () => { function setupFetchMock() { vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => { const url = input.toString(); + // D0 tile reads this; it must resolve to a valid Summary BEFORE the generic + // /api/settings/compression match (the run-telemetry URL contains that prefix). + if (url.includes("/run-telemetry")) { + return new Response( + JSON.stringify({ + totalRuns: 0, + totalTokensSaved: 0, + runsWithStyles: 0, + bypassCount: 0, + totalOutputTokens: 0, + appliedStyleCounts: {}, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } if (url.includes("/api/settings/compression")) { return new Response( JSON.stringify({ @@ -101,6 +117,8 @@ describe("CompressionSettingsPage", () => { expect(container).toBeTruthy(); expect(container.children.length).toBeGreaterThan(0); + // D0: the read-only telemetry tile is mounted alongside the panel. + expect(container.querySelector('[data-testid="compression-styles-tile"]')).toBeTruthy(); }); it("does not crash when fetch calls fail (fail-soft)", async () => { diff --git a/tests/unit/ui/compressionPanel.test.tsx b/tests/unit/ui/compressionPanel.test.tsx index ea56423056..ba0a761241 100644 --- a/tests/unit/ui/compressionPanel.test.tsx +++ b/tests/unit/ui/compressionPanel.test.tsx @@ -12,6 +12,7 @@ import { // labels/descriptions, engine ids, data-testid hooks, and the PUT request body. vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key, + useLocale: () => "en", })); // ── Harness ───────────────────────────────────────────────────────────────── diff --git a/tests/unit/ui/compressionStylesPanel.test.tsx b/tests/unit/ui/compressionStylesPanel.test.tsx new file mode 100644 index 0000000000..0c4f5904b1 --- /dev/null +++ b/tests/unit/ui/compressionStylesPanel.test.tsx @@ -0,0 +1,158 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + OUTPUT_STYLE_IDS, + outputStyleMeta, +} from "../../../open-sse/services/compression/outputStyles/catalog.ts"; + +// Locale is mutable per-test so we can exercise the locale gate (terse-cjk → zh only). +const intl = vi.hoisted(() => ({ locale: "en" })); +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => intl.locale, +})); + +const containers: HTMLElement[] = []; +const roots: Array<{ unmount: () => void }> = []; + +function mount(ui: React.ReactElement): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + containers.push(container); + const root = createRoot(container); + roots.push(root); + act(() => root.render(ui)); + return container; +} + +beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + intl.locale = "en"; +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await act(async () => { + while (roots.length > 0) roots.pop()?.unmount(); + }); + while (containers.length > 0) containers.pop()?.remove(); + document.body.innerHTML = ""; +}); + +async function flush() { + await act(async () => { + for (let i = 0; i < 10; i++) await Promise.resolve(); + }); +} + +function setupFetchMock() { + const puts: Array<{ url: string; body: Record }> = []; + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); + const initial = { + enabled: true, + autoTriggerTokens: 0, + preserveSystemPrompt: true, + engines: {}, + activeComboId: null, + outputStyles: [], + cavemanOutputMode: { enabled: false, intensity: "full", autoClarity: true }, + }; + vi.spyOn(globalThis, "fetch").mockImplementation( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = (init?.method ?? "GET").toUpperCase(); + if (url.includes("/api/settings/compression/mcp-accessibility")) return json({ enabled: true }); + if (url.includes("/api/settings/compression")) { + if (method === "PUT") { + const body = JSON.parse(String(init?.body ?? "{}")); + puts.push({ url, body }); + return json({ ...initial, ...body }); + } + return json(initial); + } + return json({}, 404); + } + ); + return { puts }; +} + +describe("CompressionPanel output styles", () => { + it("renders one row per catalog style", async () => { + setupFetchMock(); + intl.locale = "zh-CN"; // a locale that matches every gated style, so all rows render + const { default: CompressionPanel } = await import( + "../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel" + ); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + for (const id of OUTPUT_STYLE_IDS) { + const row = container.querySelector(`[data-testid="output-style-row-${id}"]`); + expect(row, `expected a row for style "${id}"`).toBeTruthy(); + expect(container.textContent).toContain(outputStyleMeta(id).label); + } + }); + + it("locale-gates terse-cjk: hidden under a non-zh locale", async () => { + setupFetchMock(); + intl.locale = "en"; + const { default: CompressionPanel } = await import( + "../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel" + ); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + // terse-cjk (locale "zh") must NOT be offered under "en"… + expect(container.querySelector(`[data-testid="output-style-row-terse-cjk"]`)).toBeFalsy(); + // …while the non-gated styles still render. + expect(container.querySelector(`[data-testid="output-style-row-terse-prose"]`)).toBeTruthy(); + expect(container.querySelector(`[data-testid="output-style-row-less-code"]`)).toBeTruthy(); + }); + + it("locale-gates terse-cjk: offered under a zh locale (zh-CN base matches)", async () => { + setupFetchMock(); + intl.locale = "zh-CN"; + const { default: CompressionPanel } = await import( + "../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel" + ); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + expect(container.querySelector(`[data-testid="output-style-row-terse-cjk"]`)).toBeTruthy(); + }); + + it("toggling a style PUTs an outputStyles selection", async () => { + const { puts } = setupFetchMock(); + const { default: CompressionPanel } = await import( + "../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel" + ); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + const toggle = container.querySelector( + `[data-testid="output-style-toggle-terse-prose"] button, [data-testid="output-style-toggle-terse-prose"] input` + ) as HTMLElement | null; + expect(toggle).toBeTruthy(); + await act(async () => { + toggle!.click(); + }); + await flush(); + const put = puts.find((p) => "outputStyles" in p.body); + expect(put, "a PUT carrying outputStyles").toBeTruthy(); + expect( + (put!.body.outputStyles as Array<{ id: string }>).some((s) => s.id === "terse-prose") + ).toBe(true); + }); +}); diff --git a/tests/unit/ui/compressionStylesTile.test.tsx b/tests/unit/ui/compressionStylesTile.test.tsx new file mode 100644 index 0000000000..d552c1e76d --- /dev/null +++ b/tests/unit/ui/compressionStylesTile.test.tsx @@ -0,0 +1,68 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key })); + +const containers: HTMLElement[] = []; +const roots: Array<{ unmount: () => void }> = []; + +function mount(ui: React.ReactElement): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + containers.push(container); + const root = createRoot(container); + roots.push(root); + act(() => root.render(ui)); + return container; +} + +beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await act(async () => { + while (roots.length > 0) roots.pop()?.unmount(); + }); + while (containers.length > 0) containers.pop()?.remove(); + document.body.innerHTML = ""; +}); + +async function flush() { + await act(async () => { + for (let i = 0; i < 10; i++) await Promise.resolve(); + }); +} + +describe("CompressionStylesTile", () => { + it("renders total savings and applied style ids from the summary endpoint", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + totalRuns: 4, + totalTokensSaved: 1234, + runsWithStyles: 3, + bypassCount: 1, + totalOutputTokens: 900, + appliedStyleCounts: { "terse-prose": 3, "less-code": 1 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + ); + const { default: CompressionStylesTile } = await import( + "../../../src/app/(dashboard)/dashboard/context/CompressionStylesTile" + ); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + expect(container.textContent).toContain("1234"); + expect(container.textContent).toContain("terse-prose"); + expect(container.textContent).toContain("less-code"); + }); +}); diff --git a/tests/unit/ui/compressionUltraTier.test.tsx b/tests/unit/ui/compressionUltraTier.test.tsx new file mode 100644 index 0000000000..67cadedabe --- /dev/null +++ b/tests/unit/ui/compressionUltraTier.test.tsx @@ -0,0 +1,165 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +// i18n does not resolve to a real locale in vitest/jsdom, so mock next-intl to echo +// the key. This test asserts ONLY on i18n-independent hooks (data-testid + values) +// and the captured PUT body. +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => "en", +})); + +const containers: HTMLElement[] = []; +const roots: Array<{ unmount: () => void }> = []; + +function mount(ui: React.ReactElement): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + containers.push(container); + const root = createRoot(container); + roots.push(root); + act(() => root.render(ui)); + return container; +} + +beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await act(async () => { + while (roots.length > 0) roots.pop()?.unmount(); + }); + for (let i = 0; i < 10; i++) await Promise.resolve(); + while (containers.length > 0) containers.pop()?.remove(); + document.body.innerHTML = ""; +}); + +async function flush() { + await act(async () => { + for (let i = 0; i < 10; i++) await Promise.resolve(); + }); +} + +interface CapturedPut { + url: string; + body: Record; +} + +function setupFetchMock(overrides?: Record): { puts: CapturedPut[] } { + const puts: CapturedPut[] = []; + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); + const initial = { + enabled: true, + autoTriggerTokens: 0, + preserveSystemPrompt: true, + engines: {}, + activeComboId: null, + outputStyles: [], + cavemanOutputMode: { enabled: false, intensity: "full", autoClarity: true }, + ultraEngine: "heuristic", + ultraSlmPrewarm: false, + ...overrides, + }; + vi.spyOn(globalThis, "fetch").mockImplementation( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = (init?.method ?? "GET").toUpperCase(); + if (url.includes("/api/settings/compression/mcp-accessibility")) return json({ enabled: true }); + if (url.includes("/api/settings/compression")) { + if (method === "PUT") { + const body = JSON.parse(String(init?.body ?? "{}")); + puts.push({ url, body }); + return json({ ...initial, ...body }); + } + return json(initial); + } + return json({}, 404); + } + ); + return { puts }; +} + +describe("CompressionPanel ultra SLM tier", () => { + it("renders the ultra-engine select defaulting to heuristic", async () => { + setupFetchMock(); + const { default: CompressionPanel } = await import( + "../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel" + ); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const select = container.querySelector( + `[data-testid="ultra-engine-select"]` + ) as HTMLSelectElement | null; + expect(select, "ultra-engine select must render").toBeTruthy(); + expect(select?.value).toBe("heuristic"); + // The pre-warm toggle is hidden while heuristic is selected. + expect( + container.querySelector(`[data-testid="ultra-slm-prewarm-toggle"]`) + ).toBeFalsy(); + }); + + it("selecting SLM PUTs ultraEngine:'slm' and reveals the pre-warm toggle", async () => { + const { puts } = setupFetchMock(); + const { default: CompressionPanel } = await import( + "../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel" + ); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const select = container.querySelector( + `[data-testid="ultra-engine-select"]` + ) as HTMLSelectElement; + expect(select).toBeTruthy(); + await act(async () => { + select.value = "slm"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + await flush(); + + const put = puts.find((p) => "ultraEngine" in p.body); + expect(put, "a PUT carrying ultraEngine").toBeTruthy(); + expect(put!.body.ultraEngine).toBe("slm"); + + // Pre-warm toggle now visible. + const toggle = container.querySelector(`[data-testid="ultra-slm-prewarm-toggle"]`); + expect(toggle, "pre-warm toggle must appear when slm is selected").toBeTruthy(); + }); + + it("toggling pre-warm PUTs ultraSlmPrewarm:true when SLM is active", async () => { + const { puts } = setupFetchMock({ ultraEngine: "slm", ultraSlmPrewarm: false }); + const { default: CompressionPanel } = await import( + "../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel" + ); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const toggle = container.querySelector( + `[data-testid="ultra-slm-prewarm-toggle"] button, [data-testid="ultra-slm-prewarm-toggle"] input` + ) as HTMLElement | null; + expect(toggle, "pre-warm toggle must exist when slm preselected").toBeTruthy(); + await act(async () => { + toggle!.click(); + }); + await flush(); + + const put = puts.find((p) => "ultraSlmPrewarm" in p.body); + expect(put, "a PUT carrying ultraSlmPrewarm").toBeTruthy(); + expect(put!.body.ultraSlmPrewarm).toBe(true); + }); +}); diff --git a/tests/unit/ui/home-page-client-dashboard-smoke-4615.test.tsx b/tests/unit/ui/home-page-client-dashboard-smoke-4615.test.tsx new file mode 100644 index 0000000000..bc9cad86d3 --- /dev/null +++ b/tests/unit/ui/home-page-client-dashboard-smoke-4615.test.tsx @@ -0,0 +1,161 @@ +// @vitest-environment jsdom +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +function makeTranslator() { + const t = (key: string) => key; + t.rich = (key: string) => key; + return t; +} + +vi.mock("next-intl", () => ({ + useTranslations: () => makeTranslator(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + refresh: vi.fn(), + }), +})); + +vi.mock("next/link", () => ({ + default: ({ href, children, ...props }: React.AnchorHTMLAttributes) => ( + + {children} + + ), +})); + +vi.mock("next/dynamic", () => ({ + default: () => + function DynamicStub() { + return
; + }, +})); + +vi.mock("@/shared/components", () => ({ + Card: ({ children }: { children: React.ReactNode }) =>
{children}
, + CardSkeleton: () =>
, + Button: ({ + children, + loading: _loading, + fullWidth: _fullWidth, + variant: _variant, + size: _size, + ...props + }: React.ButtonHTMLAttributes & { + loading?: boolean; + fullWidth?: boolean; + variant?: string; + size?: string; + }) => , + Modal: ({ children, isOpen }: { children: React.ReactNode; isOpen: boolean }) => + isOpen ?
{children}
: null, +})); + +vi.mock("@/shared/components/ProviderIcon", () => ({ + default: () => , +})); + +const notifyMock = { + success: vi.fn(), + error: vi.fn(), + addNotification: vi.fn(), +}; + +function useNotificationStoreMock() { + return notifyMock; +} +useNotificationStoreMock.getState = () => notifyMock; + +vi.mock("@/store/notificationStore", () => ({ + useNotificationStore: useNotificationStoreMock, +})); + +vi.mock("@/shared/hooks/useElectron", () => ({ + useIsElectron: () => false, + useOpenExternal: () => ({ openExternal: vi.fn() }), +})); + +vi.mock("@/shared/utils/clipboard", () => ({ + copyToClipboard: vi.fn(async () => undefined), +})); + +const { default: HomePageClient } = + await import("../../../src/app/(dashboard)/dashboard/HomePageClient"); + +function jsonResponse(body: unknown) { + return { + ok: true, + json: async () => body, + } as Response; +} + +let container: HTMLDivElement; +let root: ReturnType; + +beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/settings") { + return Promise.resolve( + jsonResponse({ + showQuickStartOnHome: true, + showProviderTopologyOnHome: false, + }) + ); + } + if (url === "/api/providers") { + return Promise.resolve(jsonResponse({ connections: [] })); + } + if (url === "/api/models") { + return Promise.resolve(jsonResponse({ models: [] })); + } + if (url === "/api/system/version") { + return Promise.resolve( + jsonResponse({ + current: "0.0.0-test", + latest: "0.0.0-test", + updateAvailable: false, + channel: "test", + autoUpdateSupported: false, + }) + ); + } + if (url === "/api/provider-nodes") { + return Promise.resolve(jsonResponse({ nodes: [] })); + } + return Promise.resolve(jsonResponse({})); + }) + ); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +it("renders the dashboard home client without throwing an internal server error", async () => { + await act(async () => { + root.render(); + }); + + expect(container.textContent).not.toContain("Internal Server Error"); + expect(container.textContent).toContain("quickStart"); +}); diff --git a/tests/unit/validate-release-green.test.ts b/tests/unit/validate-release-green.test.ts index 0712a6747c..f0f69f4ebb 100644 --- a/tests/unit/validate-release-green.test.ts +++ b/tests/unit/validate-release-green.test.ts @@ -72,3 +72,27 @@ test("computeVerdict: releaseGreen iff zero HARD failures (drift never blocks)", ]); assert.equal(allGreen.releaseGreen, true); }); + +test("computeVerdict: full-coverage classification — ratchets are drift, defects are hard", () => { + // Mirrors the expanded check set: the ratchets that historically surfaced in + // layers on the release PR (complexity/openapi/zizmor/…) are DRIFT → never block; + // the new real-defect gates (docs-all, integration) are HARD → block. + const results = [ + { id: "complexity", kind: "drift", ok: false }, + { id: "openapi-coverage", kind: "drift", ok: false }, + { id: "workflow-lint", kind: "drift", ok: false }, + { id: "dead-code", kind: "drift", ok: true }, + { id: "codeql-ratchet", kind: "drift", ok: true }, + { id: "docs-all", kind: "hard", ok: true }, + { id: "integration", kind: "hard", ok: true }, + ]; + const v = computeVerdict(results); + // Three ratchets drifted but NONE block — release is still green, all reported. + assert.equal(v.releaseGreen, true); + assert.equal(v.drift.length, 3); + + // A hard gate (integration assertion regression) flips it red. + const withHardFail = computeVerdict([...results, { id: "integration", kind: "hard", ok: false }]); + assert.equal(withHardFail.releaseGreen, false); + assert.equal(withHardFail.hardFailures.length, 1); +});