docs(wiki): auto-sync pages + cover counts with docs

github-actions[bot]
2026-06-21 12:34:19 +00:00
parent c569995ab0
commit 545470720e
7 changed files with 2907 additions and 0 deletions

@@ -0,0 +1,237 @@
> 🌍 [View in other languages](Languages)
# Unified Compression Config Panel — Design
**Status:** approved direction (2026-06-20), pending spec review
**Base branch:** `release/v3.8.31`
**Goal:** Make `/dashboard/context/settings` the single management panel for the master
on/off and each compression engine's on/off + level, deriving the default pipeline from
those toggles. Keep the Combos page solely for *chaining* (ordered named pipelines) +
selecting the globally-active profile, and the per-engine pages solely for *detailed*
config. Plan for (but phase) multiple named profiles with an active selector and a
per-request override header.
---
## 1. Background — current state (the problem)
Compression on/off + level is split across **two stores and several UIs**, with real
duplication:
- **`/api/settings/compression`** (DB `key_value` ns=`compression`, via `src/lib/db/compression.ts`)
holds: `enabled` (master), `defaultMode`, `autoTriggerTokens`, `cavemanConfig`,
`cavemanOutputMode`, `rtkConfig`, `aggressive`, `ultra`, language config, etc.
- **`/api/context/combos/default`** (the *default combo pipeline*) holds the per-engine
`enabled` + config for the **structural** engines (lite, headroom, session-dedup, ccr,
llmlingua, aggressive, ultra) as pipeline steps. The per-engine detail pages
(`EngineConfigPage.tsx`) read/write here via `setEngineInDefaultCombo`.
- **`compression_combos`** table holds *named* pipelines assigned to routing combos.
Concrete duplications (from the UI audit):
- Caveman on/off in **3** places (TokenSaverCard, CompressionSettingsTab, CavemanContextPageClient).
- Caveman intensity in **3**; RTK intensity in **2**; Caveman output mode in **2**.
- `lite/session-dedup/headroom/ccr/llmlingua` on/off **absent** from the central settings —
only on their per-engine page (which writes the *default combo*, not the central config).
So "is engine X on?" is answered inconsistently (central config for caveman/rtk; default
combo for the structural engines), and the **Combos page edits the same default pipeline**
that a central panel would — the conceptual overlap the redesign must remove.
Out of scope as a *config* surface: `dashboard/compression/studio` is the Compression
Studio (waterfall/cockpit analytics), not toggles — untouched.
Engines (the 10 stackable units): `lite, caveman, aggressive, ultra, rtk, headroom,
session-dedup, ccr, llmlingua` (registered `CompressionEngine`s) + `mcpAccessibility`
(separate path: compresses MCP tool-result outputs, not the chat pipeline). Each engine's
`stackPriority` defines automatic ordering (session-dedup 3, ccr 4, lite 5, rtk 10,
headroom 15, caveman 20, aggressive 30, llmlingua 35, ultra 40).
---
## 2. Design principles — the boundary
| Surface | Concept | Owns | Never does |
|---|---|---|---|
| **Panel** (`context/settings`) | "My **Default**: what is on + level" | master on/off; per-engine on/off + level | ordering/chaining; per-route assignment |
| **Combos** (`context/combos`, menu #2) | "Named **profiles**: chaining + which is active + per-route assignment" | create/edit ordered named pipelines; pick the globally-active profile; assign to routing combos | engine on/off (inherits the active profile's membership) |
| **Per-engine pages** (menu, after combos) | "Deep config of one engine" | filters, rules, language packs, thresholds | on/off; level (those live in the panel) |
**No duplication rule:** the **Panel owns the Default profile** (membership + level, order
auto-derived by `stackPriority`). A **Combo is a *named alternative* profile** (membership
+ level + *explicit order*). Different scopes (the one default vs named alternatives) — not
the same object edited twice. The editable "default combo" store is **removed**; the
default pipeline becomes **derived** from the panel.
---
## 3. Architecture
### 3.1 Resolution model (per request, most-specific wins)
```
x-omniroute-compression header (per-request override)
→ routing-combo override (comboOverrides[comboId]) (per-route)
→ active profile (activeComboId: "default" | <comboId>) (global)
→ Default = derived from panel engines map
→ off (master disabled, or zero engines on)
```
A single `resolveCompressionPlan(config, { comboId, header })` produces the effective
`{ mode, stackedPipeline }` fed to the existing `applyCompressionAsync`. It supersedes
today's `getEffectiveMode` (which only does combo-override → autoTrigger → defaultMode).
### 3.2 Data model (Model A — single source + derived pipeline)
**CompressionConfig gains an engines map** (the single source for the Default's on/off +
level), replacing the editable default-combo store:
```ts
interface EngineToggle {
enabled: boolean;
level?: string; // caveman/cavemanOutput: lite|full|ultra ; rtk: minimal|standard|aggressive ; others: undefined
}
interface CompressionConfig {
enabled: boolean; // master
engines: Record<CompressionEngineId, EngineToggle>; // NEW — the Default profile
activeComboId: string | null; // NEW — null/"default" = derived default; else a compression_combos id
autoTriggerTokens: number;
autoTriggerMode?: CompressionMode; // kept (auto-trigger still selects a profile/mode on large prompts)
preserveSystemPrompt: boolean;
comboOverrides: Record<string, CompressionMode>; // existing per-routing-combo override
// detailed per-engine config keeps living in its existing sub-objects
// (cavemanConfig, rtkConfig, aggressive, ultra, …) — edited by the per-engine pages.
}
```
**Deriving the Default pipeline** from `engines` (pure function `deriveDefaultPlan`):
- master `enabled === false``off`.
- 0 engines enabled → `off`.
- exactly 1 enabled and it is a single-mode engine (`lite|caveman|aggressive|ultra|rtk`) →
that mode (single path — reuses today's `applyCompression(mode)`).
- otherwise → `stacked` with the enabled engines in `stackPriority` order, each step's
`intensity` from its `level` (the global `stackedPipeline` already accepts all 9 engines
after the B-PIPELINE-DIVERGENCE fix).
The **stored `defaultMode` field** and the editable default combo
(`/api/context/combos/default`, `setEngineInDefaultCombo`) are **removed**; the default is
derived from `engines`. (The `CompressionMode` *type* persists — `comboOverrides`,
`autoTriggerMode`, and the resolver's output still use it; only the stored `defaultMode`
field is dropped.) A DB migration backfills `engines` from the current `defaultMode` +
default-combo steps + caveman/rtk config so existing installs keep their behavior.
**Named combos** (`compression_combos`, existing table): N ordered pipelines. `activeComboId`
selects which profile is globally active (`Default` or a named combo). Per-routing-combo
assignment stays in `comboOverrides`.
`mcpAccessibility` keeps its own store (`/api/settings/compression/mcp-accessibility`,
migration 056) — surfaced in the panel as a row that writes there, with a scope note.
### 3.3 The per-request header
`x-omniroute-compression: <value>` (mirrors the `x-omniroute-no-memory`/`no-cache` pattern,
PR #4290; parsed in the request pipeline alongside the other omniroute headers). Values:
- `off` → no compression for this request.
- `default` → the derived Default profile.
- `<combo-name|id>` → that named combo.
- `engine:<id>` → a single engine (if that engine is enabled in the Default), e.g. `engine:rtk`.
Invalid/unknown value → ignored (falls through to the normal resolution); never errors the
request. Header parsing + validation has a unit test asserting each form and the
fall-through.
---
## 4. Screens
### 4.1 Panel — `context/settings` (engine grid)
A single client component (replacing the scattered `CompressionSettingsTab` +
`CompressionTokenSaverCard` toggles):
- **Master** on/off at top.
- **Engine grid** (one row per engine, ordered by `stackPriority` so the row order mirrors
run order): `[engine name + short desc] [on/off toggle] [level selector if applicable]
[→ detail page]`. Level selector appears only for engines with levels
(caveman lite|full|ultra, rtk minimal|standard|aggressive; caveman output mode as its own row).
- **General** (auto-trigger tokens, preserve-system-prompt) below the grid.
- Reads/writes the `engines` map + master via `GET/PUT /api/settings/compression` (single
endpoint). The displayed default pipeline (derived) is shown read-only ("runs: rtk →
caveman → …") so the user sees the effect without editing order here.
### 4.2 Combos — `context/combos` (menu #2)
- List of named combos; create/edit an **ordered** pipeline (drag/reorder + per-step level)
— the only place for explicit chaining.
- **Active profile selector**: `Default (panel)` | `<combo>` → writes `activeComboId`.
- Assign a combo to a routing combo (existing `comboOverrides`).
- Reuses the existing `CompressionCombosPageClient` / `comboFlowModel`; the `CompressionHub`
"master mode selector" is removed (mode is now derived; the Hub becomes a read-only
overview or is folded into the panel).
### 4.3 Per-engine pages (menu, after combos)
`EngineConfigPage` + the caveman/rtk custom pages: **lose** the on/off + level controls
(moved to the panel) and keep only **detailed** config (filters, rules, language packs,
thresholds, preview). They **stop writing** `/api/context/combos/default`; detailed config
writes to its own sub-object in `/api/settings/compression` (or the existing
caveman/rtk facade routes, which already proxy to it).
### 4.4 Navigation
`COMPRESSION_CONTEXT_GROUP` (`src/shared/constants/sidebarVisibility.ts`) reordered:
**Settings (panel) → Combos → per-engine pages → Studio (analytics)**.
---
## 5. Consolidation / migration
- `CompressionTokenSaverCard` quick toggles → **absorbed into the panel; the card is removed**.
- Duplicate caveman/rtk on/off + intensity in `CompressionSettingsTab` → removed.
- `EngineConfigPage` → on/off+level removed; stops writing the default combo.
- DB migration: backfill `engines` map + `activeComboId="default"` from current state
(defaultMode + default-combo steps + caveman/rtk/ultra/aggressive enabled), so live
installs preserve behavior. The editable default-combo route (`PUT /api/context/combos/default`)
becomes a **read-only shim for one release** (returns the derived default; rejects writes
with a deprecation note), then is removed.
---
## 6. Phasing
Each phase is its own implementation plan + PR, independently shippable and TDD'd
(Hard Rule #18). `writing-plans` will author **Phase 1 first**; Phases 23 get their own
plans later.
- **Phase 1 (core consolidation):** the `engines` map + `deriveDefaultPlan` + migration;
the engine-grid panel; remove scattered/duplicate toggles; per-engine pages lose on/off;
menu reorder. Delivers the single-panel goal. *No behavior change for existing installs
(migration backfills).*
- **Phase 2 (profiles):** multiple named combos + the `activeComboId` active-profile selector.
- **Phase 3 (header):** the `x-omniroute-compression` per-request override.
Phases 23 reuse the Phase-1 resolution model (`resolveCompressionPlan`), which is built
header/active-aware from the start so later phases only wire UI + header parsing.
---
## 7. Testing
- **Unit:** `deriveDefaultPlan` (every engines-map shape → expected mode/pipeline);
migration backfill (old config → equivalent engines map); `resolveCompressionPlan`
precedence (header > comboOverride > active > default > off); header parsing (each form +
fall-through); panel reducer (toggle/level edits → config patch).
- **Component (vitest):** the panel renders all engines, toggles persist, derived pipeline
preview updates.
- **Integration:** a config with engines `{rtk,caveman}` on → `applyCompressionAsync` runs the
derived stacked pipeline; single engine on → single-mode path; equivalence with the old
defaultMode behavior for the same logical config.
- Both runners green (`test:unit` + `test:vitest`); typecheck:core clean; lint 0 errors.
---
## 8. Non-goals (YAGNI)
- No new compression engines in this work (the model just makes adding them trivial later).
- No change to the engines' internal algorithms (the recent fixes are separate).
- The Compression Studio (analytics) is not restructured.
- Phases 23 UI polish (combo templates, sharing) is out of scope.

@@ -0,0 +1,492 @@
> 🌍 [View in other languages](Languages)
# Unified Compression Config Panel — Phase 1 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make `/dashboard/context/settings` the single source for the master + per-engine on/off + level, with the default compression pipeline DERIVED from those toggles, removing the scattered/duplicate toggles — with zero behavior change for existing installs (a migration backfills).
**Architecture:** Add an `engines` map (+`activeComboId`) to `CompressionConfig` as the single source. A pure `deriveDefaultPlan(engines)` turns it into `{mode, stackedPipeline}`; a pure `resolveCompressionPlan(config, ctx)` applies precedence (header→combo-override→active-profile→derived-default→off) and feeds the existing `applyCompressionAsync`. A DB migration backfills the map. The engine-grid panel reads/writes the map via the single `/api/settings/compression` endpoint; per-engine pages lose on/off+level; the editable default-combo route becomes a read-only shim.
**Tech Stack:** TypeScript, Next.js 16 App Router, Zod, SQLite (better-sqlite3), Node test runner + Vitest (component), React.
**Base:** worktree `feat/compression-config-panel-v3831` off `release/v3.8.31`. Spec: `docs/compression/2026-06-20-unified-compression-config-panel-design.md`.
**Conventions:** unit tests run `node --import tsx/esm --test tests/unit/<f>.test.ts`; component tests run `npm run test:vitest`. Each task ends by running the FULL compression suite (`node --import tsx/esm --test tests/unit/compression/*.test.ts`) + `npm run typecheck:core` before commit. Never `--no-verify`.
---
## File Structure
**Create:**
- `open-sse/services/compression/engineCatalog.ts` — pure metadata: per-engine `{ id, label, stackPriority, levels?, isSingleMode }`. One source of truth for "which engines exist, which have levels, which can be a standalone mode".
- `open-sse/services/compression/deriveDefaultPlan.ts` — pure: `engines` map → `{ mode, stackedPipeline }`.
- `open-sse/services/compression/resolveCompressionPlan.ts` — pure: precedence resolver.
- `src/lib/db/migrations/102_compression_engines_map.sql` — backfill `engines` + `activeComboId`.
- `src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx` — the engine-grid panel.
- `tests/unit/compression/engine-catalog.test.ts`, `derive-default-plan.test.ts`, `resolve-compression-plan.test.ts`, `compression-engines-map-migration.test.ts`.
- `tests/unit/ui/compressionPanel.test.tsx` (vitest).
**Modify:**
- `open-sse/services/compression/types.ts` — add `EngineToggle`, `CompressionConfig.engines`, `activeComboId`; keep `CompressionMode` type; drop stored `defaultMode` usage (derive).
- `src/lib/db/compression.ts` — normalize/persist `engines` + `activeComboId`.
- `open-sse/services/compression/strategySelector.ts``selectCompressionStrategy`/`getEffectiveMode` delegate to `resolveCompressionPlan`.
- `open-sse/handlers/chatCore.ts` — call `resolveCompressionPlan`.
- `src/app/api/settings/compression/route.ts` — accept/return `engines` + `activeComboId`.
- `src/app/api/context/combos/default/route.ts` — read-only shim (reject writes).
- `src/app/(dashboard)/dashboard/context/settings/page.tsx` — render `CompressionPanel` (not the old tab).
- `src/shared/components/compression/EngineConfigPage.tsx` + caveman/rtk client pages — remove on/off+level; stop writing default combo.
- `src/shared/constants/sidebarVisibility.ts` — reorder `COMPRESSION_CONTEXT_GROUP`.
**Engine reference (`stackPriority`, levels, single-mode):**
`session-dedup`(3,—,no) `ccr`(4,—,no) `lite`(5,—,yes) `rtk`(10,minimal|standard|aggressive,yes) `headroom`(15,—,no) `caveman`(20,lite|full|ultra,yes) `aggressive`(30,—,yes) `llmlingua`(35,—,no) `ultra`(40,—,yes). Plus `cavemanOutput`(intensity lite|full|ultra, separate from input caveman) and `mcpAccessibility`(no level, separate store).
---
## Task 1: Engine catalog (single source of engine metadata)
**Files:**
- Create: `open-sse/services/compression/engineCatalog.ts`
- Test: `tests/unit/compression/engine-catalog.test.ts`
- [ ] **Step 1: Write the failing test**
```ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { ENGINE_CATALOG, engineMeta, ENGINE_IDS } from "@omniroute/open-sse/services/compression/engineCatalog.ts";
test("catalog lists every engine with stackPriority", () => {
for (const id of ["session-dedup","ccr","lite","rtk","headroom","caveman","aggressive","llmlingua","ultra"]) {
assert.ok(engineMeta(id), `${id} present`);
assert.equal(typeof engineMeta(id).stackPriority, "number");
}
});
test("levels + single-mode flags are correct", () => {
assert.deepEqual(engineMeta("rtk").levels, ["minimal","standard","aggressive"]);
assert.deepEqual(engineMeta("caveman").levels, ["lite","full","ultra"]);
assert.equal(engineMeta("headroom").levels, undefined);
assert.equal(engineMeta("caveman").isSingleMode, true);
assert.equal(engineMeta("headroom").isSingleMode, false);
});
test("ENGINE_IDS is ordered by stackPriority", () => {
const ps = ENGINE_IDS.map((id) => engineMeta(id).stackPriority);
assert.deepEqual(ps, [...ps].sort((a,b)=>a-b));
});
```
- [ ] **Step 2: Run → FAIL** (`node --import tsx/esm --test tests/unit/compression/engine-catalog.test.ts`) — module not found.
- [ ] **Step 3: Implement** `engineCatalog.ts`:
```ts
export interface EngineMeta {
id: string;
label: string;
stackPriority: number;
levels?: string[]; // intensity options; undefined = no level selector
isSingleMode: boolean; // can be the effective mode when it is the only engine on
description: string;
}
export const ENGINE_CATALOG: Record<string, EngineMeta> = {
"session-dedup": { id:"session-dedup", label:"Session Dedup", stackPriority:3, isSingleMode:false, description:"Cross-turn block deduplication." },
ccr: { id:"ccr", label:"CCR (Retrieval)", stackPriority:4, isSingleMode:false, description:"Content-addressed retrieval markers." },
lite: { id:"lite", label:"Lite", stackPriority:5, isSingleMode:true, description:"Whitespace/format cleanup." },
rtk: { id:"rtk", label:"RTK", stackPriority:10, levels:["minimal","standard","aggressive"], isSingleMode:true, description:"Command-output filtering." },
headroom: { id:"headroom", label:"Headroom", stackPriority:15, isSingleMode:false, description:"Tabular JSON compaction." },
caveman: { id:"caveman", label:"Caveman", stackPriority:20, levels:["lite","full","ultra"], isSingleMode:true, description:"Rule-based prose compression." },
aggressive: { id:"aggressive", label:"Aggressive", stackPriority:30, isSingleMode:true, description:"Summarize + age old turns." },
llmlingua: { id:"llmlingua", label:"LLMLingua (SLM)", stackPriority:35, isSingleMode:false, description:"Semantic pruning (ONNX)." },
ultra: { id:"ultra", label:"Ultra", stackPriority:40, isSingleMode:true, description:"Heuristic token pruning (+ optional SLM)." },
};
export const ENGINE_IDS: string[] = Object.values(ENGINE_CATALOG).sort((a,b)=>a.stackPriority-b.stackPriority).map((e)=>e.id);
export function engineMeta(id: string): EngineMeta { return ENGINE_CATALOG[id]; }
```
- [ ] **Step 4: Run → PASS.**
- [ ] **Step 5: Commit** `git add open-sse/services/compression/engineCatalog.ts tests/unit/compression/engine-catalog.test.ts && git commit -m "feat(compression): engine catalog metadata (levels, single-mode, order)"`
---
## Task 2: `EngineToggle` + `engines` map + `activeComboId` on the config type
**Files:**
- Modify: `open-sse/services/compression/types.ts` (the `CompressionConfig` interface + `DEFAULT_COMPRESSION_CONFIG`)
- Test: `tests/unit/compression/engine-catalog.test.ts` (extend) — assert the default config shape.
- [ ] **Step 1: Add the test** (append):
```ts
import { DEFAULT_COMPRESSION_CONFIG } from "@omniroute/open-sse/services/compression/types.ts";
test("default config has an engines map + activeComboId", () => {
assert.equal(typeof DEFAULT_COMPRESSION_CONFIG.engines, "object");
assert.equal(DEFAULT_COMPRESSION_CONFIG.activeComboId, null);
// default-off: every engine disabled by default (opt-in preserved)
for (const id of ENGINE_IDS) assert.equal(DEFAULT_COMPRESSION_CONFIG.engines[id]?.enabled, false);
});
```
- [ ] **Step 2: Run → FAIL.**
- [ ] **Step 3: Implement** in `types.ts`: add `export interface EngineToggle { enabled: boolean; level?: string }`; add to `CompressionConfig`: `engines: Record<string, EngineToggle>;` and `activeComboId: string | null;`. In `DEFAULT_COMPRESSION_CONFIG` add `engines: Object.fromEntries(ENGINE_IDS.map((id)=>[id,{enabled:false}]))` (import `ENGINE_IDS`) and `activeComboId: null`. Keep `defaultMode` field for now (removed in Task 9 once derive is wired) to avoid breaking compilation.
- [ ] **Step 4: Run → PASS + `npm run typecheck:core`.**
- [ ] **Step 5: Commit** `... -m "feat(compression): add engines map + activeComboId to CompressionConfig"`
---
## Task 3: `deriveDefaultPlan` (the heart — engines map → mode/pipeline)
**Files:**
- Create: `open-sse/services/compression/deriveDefaultPlan.ts`
- Test: `tests/unit/compression/derive-default-plan.test.ts`
- [ ] **Step 1: Write the failing test**
```ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { deriveDefaultPlan } from "@omniroute/open-sse/services/compression/deriveDefaultPlan.ts";
const on = (level) => ({ enabled: true, ...(level?{level}:{}) });
test("master off / empty / none-on => off", () => {
assert.deepEqual(deriveDefaultPlan({}, false), { mode:"off", stackedPipeline:[] });
assert.deepEqual(deriveDefaultPlan({}, true), { mode:"off", stackedPipeline:[] });
assert.deepEqual(deriveDefaultPlan({ rtk:{enabled:false} }, true), { mode:"off", stackedPipeline:[] });
});
test("exactly one single-mode engine => that mode", () => {
assert.deepEqual(deriveDefaultPlan({ caveman: on("full") }, true), { mode:"standard", stackedPipeline:[] });
assert.deepEqual(deriveDefaultPlan({ rtk: on("minimal") }, true), { mode:"rtk", stackedPipeline:[] });
assert.deepEqual(deriveDefaultPlan({ lite: on() }, true), { mode:"lite", stackedPipeline:[] });
});
test("one non-single-mode engine => stacked with that engine", () => {
const p = deriveDefaultPlan({ headroom: on() }, true);
assert.equal(p.mode, "stacked");
assert.deepEqual(p.stackedPipeline, [{ engine:"headroom" }]);
});
test("multiple engines => stacked in stackPriority order, levels as intensity", () => {
const p = deriveDefaultPlan({ caveman: on("full"), rtk: on("standard"), headroom: on() }, true);
assert.equal(p.mode, "stacked");
assert.deepEqual(p.stackedPipeline, [
{ engine:"rtk", intensity:"standard" }, // pri 10
{ engine:"headroom" }, // pri 15
{ engine:"caveman", intensity:"full" }, // pri 20
]);
});
```
- [ ] **Step 2: Run → FAIL.**
- [ ] **Step 3: Implement** `deriveDefaultPlan.ts`:
```ts
import { ENGINE_CATALOG, engineMeta } from "./engineCatalog.ts";
import type { EngineToggle } from "./types.ts";
const SINGLE_MODE_OF: Record<string,string> = { lite:"lite", caveman:"standard", aggressive:"aggressive", ultra:"ultra", rtk:"rtk" };
export interface DerivedPlan { mode: string; stackedPipeline: Array<{ engine: string; intensity?: string }>; }
export function deriveDefaultPlan(engines: Record<string, EngineToggle>, masterEnabled: boolean): DerivedPlan {
if (!masterEnabled) return { mode:"off", stackedPipeline:[] };
const onIds = Object.keys(ENGINE_CATALOG).filter((id) => engines[id]?.enabled === true);
if (onIds.length === 0) return { mode:"off", stackedPipeline:[] };
if (onIds.length === 1 && engineMeta(onIds[0]).isSingleMode) {
return { mode: SINGLE_MODE_OF[onIds[0]], stackedPipeline:[] };
}
const ordered = onIds.sort((a,b)=>engineMeta(a).stackPriority-engineMeta(b).stackPriority);
const stackedPipeline = ordered.map((id) => {
const level = engines[id]?.level;
return level ? { engine:id, intensity:level } : { engine:id };
});
return { mode:"stacked", stackedPipeline };
}
```
- [ ] **Step 4: Run → PASS.**
- [ ] **Step 5: Commit** `... -m "feat(compression): deriveDefaultPlan (engines map -> mode/pipeline)"`
---
## Task 4: Migration 102 — backfill `engines` + `activeComboId`
**Files:**
- Create: `src/lib/db/migrations/102_compression_engines_map.sql`
- Test: `tests/unit/compression/compression-engines-map-migration.test.ts`
- [ ] **Step 1: Write the failing test** (uses `resetDbInstance()` + closes handles in `test.after`, per repo rule):
```ts
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { getDbInstance, resetDbInstance } from "@/lib/db/core.ts";
import { getCompressionSettings } from "@/lib/db/compression.ts";
after(() => resetDbInstance());
test("migration backfills engines map from prior defaultMode + default combo", () => {
resetDbInstance();
const db = getDbInstance(); // runs migrations incl. 102
// simulate a pre-102 install: master on, defaultMode 'standard', caveman enabled
db.prepare("INSERT OR REPLACE INTO key_value(namespace,key,value) VALUES('compression','enabled','true')").run();
db.prepare("INSERT OR REPLACE INTO key_value(namespace,key,value) VALUES('compression','defaultMode','\"standard\"')").run();
db.prepare("INSERT OR REPLACE INTO key_value(namespace,key,value) VALUES('compression','cavemanConfig','{\"enabled\":true}')").run();
const cfg = getCompressionSettings();
assert.equal(cfg.engines.caveman.enabled, true);
assert.equal(cfg.activeComboId, null);
});
```
- [ ] **Step 2: Run → FAIL.**
- [ ] **Step 3: Implement** the SQL migration (idempotent; the row backfill of derived `engines` is done in `normalizeCompressionSettings` read-path — the SQL only seeds `activeComboId` default and a marker). Migration `102_compression_engines_map.sql`:
```sql
-- Phase 1 of the unified compression panel: the engines map + activeComboId become the
-- single source. The engines map is DERIVED on read (normalizeCompressionSettings) from the
-- legacy defaultMode + default-combo steps + caveman/rtk/ultra/aggressive config, so existing
-- installs keep their behavior. Here we only ensure activeComboId defaults to NULL ("default").
INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('compression', 'activeComboId', 'null');
```
(The read-path derivation in Task 5 is what makes `getCompressionSettings().engines` correct; the migration just guarantees `activeComboId` exists.)
- [ ] **Step 4: Run → PASS** (after Task 5's normalize is in — if running 4 before 5, expect the engines assertion to fail; do Task 5 then re-run). Commit after Task 5.
---
## Task 5: Persist + normalize `engines` / `activeComboId` (read-path derivation)
**Files:**
- Modify: `src/lib/db/compression.ts` (`getCompressionSettings`/`normalizeCompressionSettings`/`updateCompressionSettings`)
- Test: reuse Task 4's migration test + add a round-trip test in the same file.
- [ ] **Step 1: Add round-trip test**:
```ts
import { updateCompressionSettings } from "@/lib/db/compression.ts";
test("engines map persists round-trip + activeComboId", () => {
resetDbInstance(); getDbInstance();
updateCompressionSettings({ enabled:true, engines:{ rtk:{enabled:true,level:"standard"}, caveman:{enabled:true,level:"full"} }, activeComboId:null });
const cfg = getCompressionSettings();
assert.equal(cfg.engines.rtk.enabled, true);
assert.equal(cfg.engines.rtk.level, "standard");
assert.equal(cfg.engines.caveman.level, "full");
});
```
- [ ] **Step 2: Run → FAIL.**
- [ ] **Step 3: Implement** in `compression.ts`:
- In `normalizeCompressionSettings`: read stored `engines` if present; ELSE derive it from legacy fields — `engines[id].enabled` from: caveman/rtk/ultra/aggressive `*.enabled`; structural engines from the default-combo steps (read the default combo); single-modes from `defaultMode`. Levels from `cavemanConfig.intensity`/`rtkConfig.intensity`. Read `activeComboId` (default null).
- In `updateCompressionSettings`: accept `engines` (validate each value `{enabled:boolean, level?:string}`) + `activeComboId` and persist as `key_value` rows (`engines` as one JSON row).
- Add a Zod sub-schema `engineToggleSchema = z.object({ enabled: z.boolean(), level: z.string().optional() })` and `engines: z.record(engineToggleSchema).optional()`, `activeComboId: z.string().nullable().optional()`.
- [ ] **Step 4: Run → PASS** (Task 4 + Task 5 tests). `npm run typecheck:core`.
- [ ] **Step 5: Commit** `git add src/lib/db/migrations/102_compression_engines_map.sql src/lib/db/compression.ts tests/unit/compression/compression-engines-map-migration.test.ts && git commit -m "feat(compression): persist+backfill engines map and activeComboId (migration 102)"`
---
## Task 6: `resolveCompressionPlan` (precedence resolver)
**Files:**
- Create: `open-sse/services/compression/resolveCompressionPlan.ts`
- Test: `tests/unit/compression/resolve-compression-plan.test.ts`
- [ ] **Step 1: Write the failing test**
```ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { resolveCompressionPlan } from "@omniroute/open-sse/services/compression/resolveCompressionPlan.ts";
const base = { enabled:true, engines:{ caveman:{enabled:true,level:"full"} }, activeComboId:null, comboOverrides:{} };
test("derived default when no override/active/header", () => {
assert.deepEqual(resolveCompressionPlan(base, {}), { mode:"standard", stackedPipeline:[] });
});
test("routing-combo override wins over default", () => {
const cfg = { ...base, comboOverrides:{ cmb:"aggressive" } };
assert.equal(resolveCompressionPlan(cfg, { comboId:"cmb" }).mode, "aggressive");
});
test("active named combo wins over default (Phase 2 wiring uses combos table; here pass it in)", () => {
const cfg = { ...base, activeComboId:"c1" };
const combos = { c1: [{ engine:"rtk", intensity:"standard" }] };
const plan = resolveCompressionPlan(cfg, { combos });
assert.equal(plan.mode, "stacked");
assert.deepEqual(plan.stackedPipeline, [{ engine:"rtk", intensity:"standard" }]);
});
test("master off => off regardless", () => {
assert.equal(resolveCompressionPlan({ ...base, enabled:false }, {}).mode, "off");
});
```
- [ ] **Step 2: Run → FAIL.**
- [ ] **Step 3: Implement** `resolveCompressionPlan.ts`:
```ts
import { deriveDefaultPlan, type DerivedPlan } from "./deriveDefaultPlan.ts";
export interface ResolveCtx {
comboId?: string | null;
header?: string | null; // x-omniroute-compression (Phase 3 parses+passes; Phase 1 callers pass undefined)
combos?: Record<string, Array<{ engine:string; intensity?:string }>>; // named combo pipelines by id
}
export function resolveCompressionPlan(config: any, ctx: ResolveCtx): DerivedPlan {
if (config?.enabled === false) return { mode:"off", stackedPipeline:[] };
// 1. header (Phase 3 supplies parsed value; here it composes if present)
if (ctx.header) {
if (ctx.header === "off") return { mode:"off", stackedPipeline:[] };
if (ctx.header !== "default") {
const fromHeader = headerToPlan(ctx.header, config, ctx);
if (fromHeader) return fromHeader; // unknown => fall through
}
}
// 2. routing-combo override
const ov = ctx.comboId ? config?.comboOverrides?.[ctx.comboId] : undefined;
if (ov) return modeToPlan(ov, config);
// 3. active named combo
if (config?.activeComboId && ctx.combos?.[config.activeComboId]) {
return { mode:"stacked", stackedPipeline: ctx.combos[config.activeComboId] };
}
// 4. derived default
return deriveDefaultPlan(config?.engines ?? {}, config?.enabled !== false);
}
function modeToPlan(mode: string, config: any): DerivedPlan {
return mode === "stacked"
? { mode:"stacked", stackedPipeline: config?.stackedPipeline ?? [] }
: { mode, stackedPipeline:[] };
}
function headerToPlan(h: string, config: any, ctx: ResolveCtx): DerivedPlan | null {
if (h.startsWith("engine:")) { const id = h.slice(7); return config?.engines?.[id]?.enabled ? deriveDefaultPlan({ [id]: config.engines[id] }, true) : null; }
if (ctx.combos?.[h]) return { mode:"stacked", stackedPipeline: ctx.combos[h] };
return null;
}
```
- [ ] **Step 4: Run → PASS.**
- [ ] **Step 5: Commit** `... -m "feat(compression): resolveCompressionPlan precedence resolver (header>override>active>default)"`
---
## Task 7: Wire the resolver into strategy selection + chatCore
**Files:**
- Modify: `open-sse/services/compression/strategySelector.ts` (`selectCompressionStrategy`)
- Modify: `open-sse/handlers/chatCore.ts` (the compression call site)
- Test: `tests/unit/compression/strategySelector.test.ts` (extend with an engines-map case)
- [ ] **Step 1: Add test** asserting `selectCompressionStrategy` with `engines:{rtk:{enabled:true}}` + master on returns mode `rtk`; with `{rtk,caveman}` returns `stacked`. (Use the existing test's import + harness.)
- [ ] **Step 2: Run → FAIL.**
- [ ] **Step 3: Implement**`selectCompressionStrategy` calls `resolveCompressionPlan(config, { comboId, combos })` and returns its `mode` (and expose the `stackedPipeline` so `applyCompressionAsync` uses the derived pipeline when mode==="stacked"). Load named `combos` from the combos DB module. In `chatCore.ts`, pass the active combo set; keep the `header` arg `undefined` (Phase 3 fills it). Keep `autoTriggerMode` behavior (auto-trigger still overrides to its mode on large prompts — apply BEFORE step 4 default).
- [ ] **Step 4: Run → PASS** + full compression suite + typecheck.
- [ ] **Step 5: Commit** `... -m "feat(compression): selectCompressionStrategy uses resolveCompressionPlan"`
---
## Task 8: API — `/api/settings/compression` carries `engines` + `activeComboId`
**Files:**
- Modify: `src/app/api/settings/compression/route.ts`
- Test: `tests/unit/api/compression/compression-api.test.ts` (extend)
- [ ] **Step 1: Add test**: `PUT` with `{engines:{rtk:{enabled:true,level:"standard"}}}` then `GET` returns it; error body has no stack (`!body.error?.message?.includes("at /")`).
- [ ] **Step 2: Run → FAIL.**
- [ ] **Step 3: Implement** — extend the route's Zod body schema with `engines` + `activeComboId` (reuse the db sub-schema); GET returns them; errors via `buildErrorBody`.
- [ ] **Step 4: Run → PASS** + vitest if the route is covered there.
- [ ] **Step 5: Commit** `... -m "feat(api): settings/compression carries engines map + activeComboId"`
---
## Task 9: Remove stored `defaultMode` write-path + default-combo editable route → shim
**Files:**
- Modify: `open-sse/services/compression/types.ts` (drop `defaultMode` from the persisted shape; keep `CompressionMode` type)
- Modify: `src/app/api/context/combos/default/route.ts` (PUT → 410/deprecation; GET → derived default read-only)
- Test: `tests/unit/api/...` for the shim (PUT rejected, GET returns derived).
- [ ] **Step 1: Add test**: `PUT /api/context/combos/default` returns a deprecation error (not 200); `GET` returns the derived default pipeline.
- [ ] **Step 2: Run → FAIL.**
- [ ] **Step 3: Implement**`setEngineInDefaultCombo` no longer the write path; PUT route returns `buildErrorBody` "deprecated: edit engines in /api/settings/compression"; GET returns `deriveDefaultPlan(config.engines, config.enabled)`. Remove remaining reads of stored `defaultMode` (derive).
- [ ] **Step 4: Run → PASS** + full suite + typecheck.
- [ ] **Step 5: Commit** `... -m "refactor(compression): derive default; default-combo route is a read-only shim"`
---
## Task 10: The engine-grid panel UI
**Files:**
- Create: `src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx`
- Modify: `src/app/(dashboard)/dashboard/context/settings/page.tsx` (render `CompressionPanel`)
- Test: `tests/unit/ui/compressionPanel.test.tsx` (vitest, `createRoot`+`act`)
- [ ] **Step 1: Write the failing component test**: render `CompressionPanel` with a stubbed `fetch` returning `{enabled:true, engines:{rtk:{enabled:true,level:"standard"}}}`; assert it renders a row per `ENGINE_IDS`, the rtk level shows "standard", toggling caveman issues a `PUT` with `engines.caveman.enabled:true`, and the derived-pipeline preview text appears.
- [ ] **Step 2: Run → FAIL** (`npm run test:vitest`).
- [ ] **Step 3: Implement** `CompressionPanel.tsx`: master toggle; map `ENGINE_IDS` → a row component `[label+desc][Toggle][LevelSelect if meta.levels][Link → /dashboard/context/<id>]`; a derived-pipeline preview computed client-side via `deriveDefaultPlan` (import the pure fn); a `cavemanOutput` row; an `mcpAccessibility` row (writes its own endpoint, with a "MCP tool outputs" note); general settings (auto-trigger, preserve system prompt). Save via `PUT /api/settings/compression` (debounced, merge-patch like the existing `save()` pattern in `CompressionSettingsTab`). Reuse existing primitives (`Toggle`, segmented control) from the current cards.
- [ ] **Step 4:** Update `page.tsx` to render `<CompressionPanel/>`. Run → PASS.
- [ ] **Step 5: Commit** `... -m "feat(dashboard): engine-grid compression panel (single source for on/off + level)"`
---
## Task 11: Consolidate — remove scattered/duplicate toggles + per-engine on/off
**Files:**
- Modify: `src/app/(dashboard)/dashboard/settings/components/CompressionTokenSaverCard.tsx` (remove; or strip to a read-only summary linking to the panel)
- Modify: `CompressionSettingsTab.tsx` (remove duplicate caveman/rtk on/off + intensity sections; keep only things not in the panel, or delete if fully superseded)
- Modify: `src/shared/components/compression/EngineConfigPage.tsx` + `CavemanContextPageClient.tsx` + `RtkContextPageClient.tsx` (remove on/off + level; keep detailed config; stop writing `/api/context/combos/default`)
- Test: vitest render tests for the per-engine pages assert NO enabled toggle is present; the existing tests updated to the new shape (alignment, not masking).
- [ ] **Step 1:** Update the affected render tests to expect the new (toggle-free) shape; run → FAIL on the still-present toggles.
- [ ] **Step 2:** Implement the removals; per-engine detail save writes detailed config to its facade route (caveman/rtk) or the settings sub-object.
- [ ] **Step 3:** Run vitest + full compression suite → PASS.
- [ ] **Step 4: Commit** `... -m "refactor(dashboard): remove duplicate compression toggles; per-engine pages keep only detailed config"`
---
## Task 12: Menu reorder + integration + full validation
**Files:**
- Modify: `src/shared/constants/sidebarVisibility.ts` (`COMPRESSION_CONTEXT_GROUP`: Settings → Combos → per-engine → Studio)
- Test: `tests/unit/...` sidebar order test (if one exists) + an integration test.
- [ ] **Step 1:** Add an integration test: build a config with `engines:{rtk:{enabled:true},caveman:{enabled:true,level:"full"}}`, call `selectCompressionStrategy` + `applyCompressionAsync` on a realistic body, assert the derived stacked pipeline ran (engineBreakdown has rtk+caveman) and equals the behavior of an explicit `[rtk,caveman]` stacked config. Run → (write fails first if any wiring gap).
- [ ] **Step 2:** Reorder the sidebar group; update any sidebar order test (alignment).
- [ ] **Step 3:** FULL validation: `npm run typecheck:core` (clean) · `npm run lint` (0 errors) · `node --import tsx/esm --test tests/unit/compression/*.test.ts` (green) · `npm run test:vitest` (green) · the api/integration compression tests.
- [ ] **Step 4: Commit** `... -m "feat(compression): unified panel menu order + integration coverage"`
---
## Self-review notes (done while writing)
- Spec coverage: panel (T10), combos boundary (default derived T3/T9; named/active resolver T6 — UI for active selection is Phase 2), per-engine detail-only (T11), header (resolver is header-aware T6; parsing+wiring is Phase 3), migration (T4/T5), menu (T12). ✓
- Type consistency: `EngineToggle` (T2) used by `deriveDefaultPlan` (T3), `resolveCompressionPlan` (T6), normalize (T5), panel (T10). `DerivedPlan` shape consistent T3↔T6↔T7. ✓
- No placeholders: each task has concrete code/tests. UI tasks (T10/T11) specify exact behavior + assertions; final per-line component code is produced at execution following the existing card patterns.
---
# 📌 RESUMO — o que ESTE plano (Fase 1) entrega
1. **Catálogo de engines** (`engineCatalog.ts`) — fonte única de metadados (níveis, single-mode, ordem).
2. **Modelo de dados Fase A**`engines` map + `activeComboId` em `CompressionConfig`, com migração 102 + backfill (zero mudança de comportamento).
3. **`deriveDefaultPlan`** — pipeline default DERIVADO dos toggles (0/1/N engines → off/modo/stacked).
4. **`resolveCompressionPlan`** — resolvedor de precedência (header > override por-rota > perfil ativo > default derivado > off), já header/active-aware.
5. **Fiação no runtime**`selectCompressionStrategy`/`chatCore` usam o resolvedor.
6. **API**`/api/settings/compression` carrega `engines` + `activeComboId`; rota `combos/default` vira shim read-only; `defaultMode` armazenado removido (derivado).
7. **Painel engine-grid**`CompressionPanel.tsx` como fonte única de master + on/off + nível, com preview do pipeline derivado.
8. **Consolidação** — remove toggles duplicados (TokenSaverCard, CompressionSettingsTab) e tira on/off+nível das páginas por-engine (que ficam só com config detalhada).
9. **Menu** — Settings → Combos → páginas por-engine → Studio.
---
# ⏳ PENDÊNCIAS — a fazer DEPOIS deste plano (cada uma vira seu próprio plano/PR)
### Fase 2 — Perfis nomeados + seletor de ativo
- **UI de combos como perfis**: a página `context/combos` lista N combos nomeados, edita pipeline **ordenado** (drag/reorder + nível por step), e tem o seletor **"perfil ativo"** (`Default` | `<combo>`) gravando `activeComboId`. O resolvedor (Fase 1) já consome `activeComboId`; falta a UI + o carregamento dos combos nomeados no `selectCompressionStrategy`. Remover o "master mode selector" do `CompressionHub` (modo agora é derivado).
### Fase 3 — Header por-request `x-omniroute-compression`
- **Parsing + wiring do header**: ler `x-omniroute-compression` no pipeline (espelhando `x-omniroute-no-memory`, PR #4290), passar como `ctx.header` ao `resolveCompressionPlan` (que já trata `off`/`default`/`<combo>`/`engine:<id>`). Doc no `API_REFERENCE` + teste de fetch-capture provando precedência por-request.
### Itens de compressão deferidos (do ciclo de fixes, independentes deste painel)
- **B-OBSERVABILITY** (telemetria): engines no-op somem do `engineBreakdown` (não dá pra distinguir "rodou 0%" de "pulou"). Exige um refactor do modelo de breakdown (campo `ran`/`skipped`) que toca a UI Studio + testes que asseram `.length` — deferido conscientemente.
- **B-CAVEMAN-PACKS**: `de`/`fr`/`ja` sem `dedup.json`+`ultra.json` (ultra==full nessas línguas). Conteúdo linguístico — adicionar os packs (ou contribuição), **sem** fallback EN que mutilaria.
- **js-tiktoken 1.0.21→1.0.22**: bump trivial (o range `^1.0.20` já permite; só pinar o lockfile num install real).
### Decisão operacional pendente (não-código)
- **Ligar o SLM (tier ultra) em produção**: validado ao vivo (49,4% real), mas mantido **OFF** por sua escolha. Quando decidir o trade-off (custo/latência/qualidade do pruning), ligar via o painel (após Fase 1) ou pela escrita de config — começando conservador (≥2000 tok).
### Portes upstream (opcionais, do audit — fora do escopo deste painel)
- headroom *safety-rails*/BM25; filtros novos do rtk/token-savior; conformance GCF v3.1 (já cobrimos o `[..]:`); transformers.js 3.5.2→4.x (arriscado, major).

98
Cluster-Decisions.md Normal file

@@ -0,0 +1,98 @@
> 🌍 [View in other languages](Languages)
# Cluster Decisions — Optional Sidecar Profiles
**Status:** proposal (awaiting @diegosouzapw review)
**Date:** 2026-06-20
**Refs:** [#3932](https://github.com/diegosouzapw/OmniRoute/issues/3932), PR #4381
## TL;DR
Two opt-in compose profiles (`memory`, `bifrost`) for the existing 8-service deployment in [`docker-compose.yml`](../../docker-compose.yml). Default-up behaviour is **unchanged**: 3 × `omniroute` replicas + Caddy + Redis + CliproxyAPI. The two new profiles add Qdrant and Bifrost as optional sidecars, gated by `docker compose --profile <name> up`. **No existing service is removed or replaced.**
## Why this is conservative
OmniRoute's existing deployment shape is already lean and proven:
- **`redis:7-alpine`** handles the rate-limit/cache workload at production scale.
- **SQLite + sqlite-vec + FTS5** cover local memory + vector + text-search (see [`src/lib/memory/vectorStore.ts:108`](../../src/lib/memory/vectorStore.ts)).
- **Caddy** is already the LB + TLS terminator ([`docker-compose.yml`](../../docker-compose.yml)).
- **Bifrost** is already integrated as the Tier-1 router in [`src/app/api/v1/relay/chat/completions/bifrost/route.ts`](../../src/app/api/v1/relay/chat/completions/bifrost/route.ts) (sidecar proxy with kill switch via `BIFROST_ENABLED` env var — set `=0` to bypass the sidecar and fall through to the TS path).
The two profiles here are **scale-out options for deployments that hit the SQLite ceiling** — not migrations. Both are default-off.
## The two profiles
### `memory` — Qdrant Vector Memory Sidecar
**When to flip on:**
- >1M embeddings per deployment (sqlite-vec starts to slow at scale).
- Multi-replica deployment that needs shared vector state across `omniroute-1/2/3`.
- You already have an external Qdrant cluster (Qdrant Cloud, on-prem).
**What it adds:**
| Service | Image | Ports | Notes |
| --------------- | ------------------------ | ----------- | ---------------------------------------------------------------------- |
| `qdrant` | `qdrant/qdrant:v1.12.4` | `6333` HTTP | HNSW index; persistent volume `omniroute_qdrant_data` |
**Activation:** flip `qdrantEnabled = true` in the Settings UI **or** set `QDRANT_HOST=qdrant` env. See [`src/lib/memory/qdrant.ts:60`](../../src/lib/memory/qdrant.ts) for the precedence rules (settings table → env var → default).
**Env vars:** `QDRANT_HOST`, `QDRANT_PORT`, `QDRANT_API_KEY`, `QDRANT_COLLECTION`, `QDRANT_VECTOR_SIZE`, `QDRANT_HNSW_EF_CONSTRUCT` (see `.env.example` lines 1672-1683).
### `bifrost` — Bifrost Tier-1 Router Sidecar
**When to flip on:**
- You run ≥3 `omniroute` replicas and want provider rotation centralised in a single Go process.
- You want a single audit/logging surface for upstream-provider requests across all replicas.
- You want horizontal scaling of the Tier-1 routing layer independent of the OmniRoute replicas.
**What it adds:**
| Service | Image | Ports | Notes |
| --------- | ------------------------------------------- | ---------- | -------------------------------------------------------------------------- |
| `bifrost` | `ghcr.io/maximhq/bifrost:1.5.21` | `8080` | Go-based Tier-1 router; persistent logs volume `omniroute_bifrost_logs` |
**Activation:** set `BIFROST_BASE_URL=http://bifrost:8080` in `.env.example`. The existing sidecar proxy route at [`src/app/api/v1/relay/chat/completions/bifrost/route.ts`](../../src/app/api/v1/relay/chat/completions/bifrost/route.ts) (added in PR #4381) will pick this up automatically.
**Env vars:** `BIFROST_BASE_URL`, `BIFROST_API_KEY`, `BIFROST_STREAMING_ENABLED`, `BIFROST_TIMEOUT_MS` (see `.env.example` lines 1685-1695).
## What this PR explicitly does NOT do
The original issue thread floated a larger cluster rewrite. After auditing the actual workload shape, the following are **rejected** for the reasons given:
| Component | Verdict | Reason |
| --------------- | --------- | ------------------------------------------------------------------------------------------------------------ |
| **Dragonfly** | **DROP** | `redis:7-alpine` is already fine for the rate-limit workload at production scale; no ceiling to break. |
| **NATS** | **DROP** | Each `omniroute` replica is a single Node.js process; no multi-process pub/sub workload exists. |
| **PostgreSQL** | **DROP** | SQLite + sqlite-vec + FTS5 cover all 3 use cases; 97 migrations + Electron packaging block migration. |
| **Neo4j** | **DROP** | Routing is a 5-table join; recursive CTE on SQLite is sufficient. |
| **MinIO** | **DROP** | No multi-MB blob workload; images/audio are passthrough proxies. |
| **pgvector / pg_ai / pg_textsearch** | **DROP** | Same SQLite-ceiling reason as PostgreSQL; pgvector ecosystem fragmented. |
| **HAProxy / Envoy** | **DROP** | Caddy already does LB + TLS; both were explicitly rejected as Tier-1 routers (see `AGENTS.md`). |
If a future use case proves out one of these, this doc is the place to amend.
## 4-week rollout (if approved)
1. **Wk 1** — Land this PR + verification of opt-in profiles with a 3-replica compose stack.
2. **Wk 2** — Bifrost full activation for OpenAI/Claude/Gemini/Ollama (4 of 14+ providers) using the sidecar proxy route at [`src/app/api/v1/relay/chat/completions/bifrost/route.ts`](../../src/app/api/v1/relay/chat/completions/bifrost/route.ts) (gated by `BIFROST_ENABLED`, kill-switchable at runtime).
3. **Wk 3** — Qdrant memory profile enabled in a single test deployment; measure latency delta vs sqlite-vec.
4. **Wk 4** — Observability healthchecks (`docker compose ps` exit codes + `wget` smoke tests); 71-pillar refresh per ADR-041.
## Files changed in this PR
| File | Change |
| ----------------------------------------------------------- | --------------------------------------------------------------------- |
| `docker-compose.yml` | +30 lines: `memory` profile (Qdrant), `bifrost` profile (Bifrost), persistent volumes, healthchecks. |
| `.env.example` | +24 lines: `QDRANT_*` (6 vars), `BIFROST_*` (4 vars). |
| `docs/reference/ENVIRONMENT.md` | +6 rows in section 25 for the `QDRANT_*` env vars. |
| `src/lib/memory/qdrant.ts` | +33 lines: env-var fallback chain (settings → env → default) for `QDRANT_HOST`/`QDRANT_PORT`/`QDRANT_API_KEY`/`QDRANT_COLLECTION`/`QDRANT_VECTOR_SIZE`/`QDRANT_HNSW_EF_CONSTRUCT`/`QDRANT_EMBEDDING_MODEL`. |
| `src/lib/memory/__tests__/qdrant-wiring.test.ts` | +88 lines: 9 new test cases pinning the env-var fallback precedence. |
| `docs/architecture/cluster-decisions.md` (this file) | NEW — decision record for the opt-in profiles. |
| `AGENTS.md` | +1 line: pointer to this doc in the reference documentation table. |
**Net touched code:** 4 production files (`docker-compose.yml`, `qdrant.ts`, `.env.example`, `ENVIRONMENT.md`), 1 test file (`qdrant-wiring.test.ts`), 2 doc files (`cluster-decisions.md`, `AGENTS.md`).

621
Database-Guide.md Normal file

@@ -0,0 +1,621 @@
> 🌍 [View in other languages](Languages)
# Database Schema & Operations Guide
> **TL;DR**: OmniRoute uses **SQLite with WAL journaling** as its primary store, with **AES-256-GCM** encryption at rest for sensitive fields. This guide covers the schema, migrations, backup/recovery, and operational runbooks.
**Sources:**
- `src/lib/db/core.ts` — singleton + SCHEMA_SQL (17 base tables)
- `src/lib/db/migrationRunner.ts` — versioned migrations
- `src/lib/db/migrations/` — 94 versioned SQL files
- `src/lib/db/encryption.ts` — encryption helpers
- `src/lib/db/backup.ts` — backup export/import
- `src/lib/db/healthCheck.ts` — health diagnostics
---
## Why SQLite?
OmniRoute chose SQLite over PostgreSQL/MySQL for several reasons:
| Factor | SQLite | PostgreSQL |
|--------|--------|-----------|
| **Deployment** | Embedded — no separate server | Requires server setup |
| **Encryption** | Application-layer (AES-256-GCM) | Built-in TDE |
| **Performance** | Faster for small/medium workloads | Better for huge concurrent writes |
| **Concurrency** | WAL mode allows concurrent reads | Full MVCC |
| **Backup** | Single-file copy | `pg_dump` or filesystem snapshot |
| **Use case** | Per-user install, embedded | Multi-tenant SaaS |
For **single-user, single-instance** deployments (the primary OmniRoute use case), SQLite is simpler and faster.
### WAL Journaling
`core.ts` opens the database with **WAL (Write-Ahead Logging) mode**:
```ts
// src/lib/db/core.ts
db.pragma("journal_mode = WAL");
db.pragma("busy_timeout = 5000");
db.pragma("synchronous = NORMAL");
db.pragma("cache_size = -2048");
```
WAL allows **concurrent reads** during writes — important for the dashboard, which queries while requests are being recorded.
---
## Database Location
The SQLite file is stored at:
| OS | Path |
|----|------|
| Linux | `~/.omniroute/storage.sqlite` |
| macOS | `~/.omniroute/storage.sqlite` |
| Windows | `%USERPROFILE%\.omniroute\storage.sqlite` |
| Docker | `/app/data/storage.sqlite` (configurable via `DATA_DIR`) |
Companion files:
- `storage.sqlite-wal` — write-ahead log
- `storage.sqlite-shm` — shared memory file
- `call_logs/` — request payload artifacts (if enabled)
**Override the location:**
```bash
DATA_DIR=/custom/path omniroute
```
---
## Domain Module Architecture
OmniRoute's database has **76 domain modules** in `src/lib/db/`. Each module:
- Owns one or more specific tables
- Exports typed CRUD functions
- Never touches another module's tables
- Uses `getDbInstance()` from `core.ts` to access the DB
### The 76 DB Modules
OmniRoute has **76 module files** in `src/lib/db/`. Below is a sampling of core modules; see the directory listing for the complete list:
| Module | Tables | Responsibility |
|--------|--------|----------------|
| `providers.ts` | `provider_connections` | OAuth/API key provider registration and credentials |
| `models.ts` | `key_value` (model data) | Model definitions, capabilities, pricing |
| `combos.ts` | `combos` | Combo routing configs and ordering |
| `apiKeys.ts` | `api_keys` | API key lifecycle, scopes, quota tracking |
| `settings.ts` | `key_value`, `api_keys`, `combos` | System configuration and shared KV store |
| `backup.ts` | — | Backup export/import operations |
| `proxies.ts` | `proxy_registry`, `proxy_assignments`, `provider_connections` | Proxy configs and routing rules |
| `prompts.ts` | `prompt_templates` | Reusable prompt templates, versioning |
| `webhooks.ts` | `webhooks` | Event-driven webhook subscriptions and logs |
| `detailedLogs.ts` | `request_detail_logs` | Per-request audit logging (optional, high volume) |
| `domainState.ts` | `domain_*` (5 tables) | Domain budgets, circuit breakers, lockouts, fallback chains, cost history |
| `registeredKeys.ts` | `registered_keys`, `account_key_limits`, `provider_key_limits` | Whitelisted API keys for MCP/A2A |
| `quotaSnapshots.ts` | `quota_snapshots` | Historical quota usage |
| `modelComboMappings.ts` | `model_combo_mappings` | Map models to combo defaults |
| `cliToolState.ts` | `cli_tool_state` | CLI-specific persistent state |
| `encryption.ts` | — | Helpers for encrypting/decrypting fields |
| `readCache.ts` | — | In-memory cache for read-heavy ops |
| `secrets.ts` | `key_value` (encrypted entries) | Encrypted secret storage |
| `stateReset.ts` | — | Wipe/reset DB state for testing |
| `contextHandoffs.ts` | `context_handoffs` | Session context for agent handoff |
| `usage*.ts` | `usage_history`, `call_logs`, `proxy_logs` | Usage tracking |
| `compression*.ts` | `compression_settings`, `compression_combos` | Compression config |
### Module Boundaries
A core architectural rule: **modules don't access each other's tables directly**. To work with another module's data, import the function from that module.
```ts
// ❌ WRONG: direct SQL from another module
db.prepare("SELECT * FROM provider_connections").all();
// ✅ RIGHT: use the providers module function
import { listProviders } from "@/lib/db/providers";
const providers = await listProviders();
```
This rule is enforced by code review — there's no static check, but violations are flagged.
---
## Base Schema (17 tables)
`core.ts` defines the 17 base tables in `SCHEMA_SQL`. These are created by migration `001_initial_schema.sql` and form the core schema.
### Core Tables (created in initial migration)
| Table | Purpose | Key columns |
|-------|---------|-------------|
| `provider_connections` | Provider credentials (encrypted) | `id`, `provider`, `auth_type`, `api_key`, `is_active` |
| `provider_nodes` | Provider node routing info | `id`, `type`, `name`, `base_url`, `created_at` |
| `key_value` | General KV store | `namespace`, `key`, `value` |
| `combos` | Routing combo definitions | `id`, `name`, `data`, `sort_order` |
| `api_keys` | API keys for the gateway | `id`, `name`, `key`, `machine_id`, `allowed_models` |
| `db_meta` | Database metadata | `key`, `value` |
| `usage_history` | Request usage records | `id`, `provider`, `model`, `tokens_input`, `tokens_output`, `timestamp` |
| `call_logs` | Request payloads & responses | `id`, `timestamp`, `status`, `model`, `provider`, `latency_ms` |
| `proxy_logs` | Proxy request logs | `id`, `timestamp`, `proxy_type`, `status`, `provider` |
| `domain_fallback_chains` | Model-to-provider chains | `model`, `chain` |
| `domain_budgets` | Per-domain spend budgets | `api_key_id`, `daily_limit_usd`, `warning_threshold`, `reset_interval` |
| `domain_budget_reset_logs` | Budget reset history | `id`, `api_key_id`, `reset_interval`, `previous_spend`, `reset_at` |
| `domain_cost_history` | Per-domain cost tracking | `id`, `api_key_id`, `cost`, `timestamp` |
| `domain_lockout_state` | Domain rate-limit state | `identifier`, `attempts`, `locked_until` |
| `domain_circuit_breakers` | Circuit breaker state per domain | `name`, `state`, `failure_count`, `last_failure_time` |
| `semantic_cache` | LLM response cache | `id`, `signature`, `model`, `prompt_hash`, `response` |
| `quota_snapshots` | Historical quota snapshots | `id`, `provider`, `connection_id`, `window_key`, `remaining_percentage` |
### Additional Tables (added by later migrations)
Subsequent migrations add tables such as:
- `cli_tool_state` (migration 011) — CLI tool state
- `mcp_*` tables — MCP server audit
- `a2a_*` tables — A2A task state
- `usage_*` tables — usage tracking
- `plugin_*` tables — plugin system
- `skill_executions` — skill execution history
- `memory_*` tables — memory system
- `compression_*` tables — compression system
- `webhook_*` tables — webhook delivery log
- `acp_*` tables — Agent Client Protocol
- `oneproxy_*` tables — 1proxy marketplace
- `proxy_assignments` — proxy scope bindings
- `detailed_call_artifacts` — call log artifacts metadata
- `quota_alert_history` — quota alert audit
- `command_code_auth_sessions` — Command Code OAuth sessions
The full list of ~30+ tables is in `src/lib/db/migrations/`.
---
## Migrations
OmniRoute uses **versioned, idempotent migrations** in `src/lib/db/migrations/`. Each migration is a single SQL file named `NNN_description.sql`.
### Migration Naming
```
001_initial_schema.sql
002_mcp_a2a_tables.sql
003_provider_node_custom_paths.sql
...
021_combo_call_log_targets.sql
```
### How Migrations Run
At startup, `migrationRunner.ts`:
1. Creates `_omniroute_migrations` table if not exists
2. Queries for already-applied migrations
3. Applies any new migrations in order, each in a transaction
4. Records each applied migration with timestamp
```ts
// src/lib/db/migrationRunner.ts (simplified)
export async function runMigrations(db: SqliteDatabase, migrationsDir: string) {
const applied = getAppliedMigrations(db);
const available = readMigrationFiles(migrationsDir);
for (const migration of available) {
if (applied.includes(migration.id)) continue;
db.transaction(() => {
db.exec(migration.sql);
recordAppliedMigration(db, migration.id);
})();
}
}
```
### Idempotency
Migrations must be **idempotent** — running them twice should be a no-op:
```sql
-- 004_proxy_registry.sql
CREATE TABLE IF NOT EXISTS proxy_registry (
id TEXT PRIMARY KEY,
host TEXT NOT NULL,
port INTEGER NOT NULL,
...
);
```
Use `IF NOT EXISTS`, `IF EXISTS`, and `OR IGNORE` / `OR REPLACE` clauses liberally.
### Adding a New Migration
1. **Identify the next number**: `ls src/lib/db/migrations/ | tail -1`
2. **Create the file**: `NNN_my_change.sql`
3. **Use safe DDL**: `CREATE TABLE IF NOT EXISTS`, `ALTER TABLE ... ADD COLUMN`
4. **Backfill data carefully**: use `UPDATE ... WHERE ...` to handle existing rows
5. **Test on a copy**: never run untested migrations on production
Example:
```sql
-- 022_add_combo_priority.sql
ALTER TABLE combos ADD COLUMN priority INTEGER DEFAULT 100;
UPDATE combos SET priority = 100 WHERE priority IS NULL;
CREATE INDEX IF NOT EXISTS idx_combos_priority ON combos(priority);
```
> **Backwards-incompatible changes** (e.g., dropping columns) are tricky. OmniRoute does NOT support downgrade — once a migration is applied, the schema change is permanent. Plan accordingly.
---
## Encryption at Rest
Sensitive fields (API keys, OAuth tokens, connection strings) are encrypted at rest using **AES-256-GCM**.
### How It Works
```ts
// src/lib/db/encryption.ts (simplified)
const key = deriveKeyFromPassphrase(passphrase, salt);
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const authTag = cipher.getAuthTag();
return { encrypted, iv, authTag };
```
### Where It's Used
- `provider_connections.api_key` — encrypted at application level
- `provider_connections.access_token`, `refresh_token`, `id_token` — encrypted at application level
- `key_value` entries with `namespace = "secrets"` — encrypted at application level
- `proxy_registry.auth` — encrypted at application level (if present)
### Encryption Key
The encryption key is derived from a **passphrase** (set via `STORAGE_ENCRYPTION_KEY` env var) and a **salt** (stored in the DB). Both are required to decrypt data.
```bash
# Generate a secure passphrase
openssl rand -hex 32
# Set in .env
STORAGE_ENCRYPTION_KEY=<your-key>
```
> **Critical**: Losing the encryption key means losing access to all encrypted data. **Back up the key separately from the database**.
### What's NOT Encrypted
For performance reasons, the following are stored in plaintext:
- Provider display names
- Model definitions (already public)
- Routing rules
- Usage records (no PII)
---
## Encryption Caveats (v3.8.16+)
OmniRoute uses **`migrateLegacyEncryptedString()`** to handle two encryption schemes transparently:
- **Legacy** (pre-v3.5.0): XOR-based "encryption" (not real crypto)
- **Current**: AES-256-GCM with proper IV and auth tag
The migration helper detects the legacy format and re-encrypts with the new scheme on first read. This means you can upgrade an old database without losing credentials.
---
## Read Cache
For frequently-read data (models, providers, settings), `readCache.ts` provides an **in-memory cache**:
```ts
// Cached at startup, invalidated on write
const providers = await getCachedProviders(); // Fast, in-memory
const fresh = await listProviders(); // Slow, hits DB
```
| Cached entity | Cache key | TTL |
|---------------|-----------|-----|
| `models` | `models:v1` | Until write |
| `provider_connections` | `providers:v1` | Until write |
| `settings` | `settings:v1` | Until write |
| `combos` | `combos:v1` | Until write |
Cache is invalidated on every write to the corresponding table.
---
## Backup and Recovery
### Manual Backup
```bash
# Use the CLI to create a local backup
omniroute backup create --name pre-migration
# Or via the API
curl -X PUT http://localhost:20128/api/db-backups \
-H "Authorization: Bearer $MANAGEMENT_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "pre-migration"}'
```
The backup file includes:
- All DB tables (serialized to JSON)
- Call log artifacts (base64-encoded, optional)
- Settings + secrets (encrypted)
- Plugin configuration
### Restore
```bash
# Via CLI
omniroute restore pre-migration
# Via API
curl -X POST http://localhost:20128/api/db-backups/restore \
-H "Authorization: Bearer $MANAGEMENT_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "pre-migration"}'
```
> **Warning**: Restore overwrites the entire DB. Stop all clients first.
### Automated Backups
```bash
# Enable automated daily backups via CLI
omniroute backup auto enable --cron "0 2 * * *" --retention 7
```
### SQLite Hot Backup
For zero-downtime backup of a live DB:
```bash
sqlite3 ~/.omniroute/storage.sqlite ".backup /backups/omniroute-hot.db"
```
This uses SQLite's online backup API — safe to run while OmniRoute is running.
---
## Performance Tuning
### WAL Mode
WAL is enabled by default. For high-write workloads, consider:
```sql
PRAGMA wal_autocheckpoint = 1000; -- Checkpoint every 1000 pages
PRAGMA journal_size_limit = 67108864; -- 64MB WAL cap
```
### Indexes
Key indexes for performance (auto-created by migrations):
- `idx_models_provider` — model lookups by provider
- `idx_combo_targets_combo_id` — combo target expansion
- `idx_usage_history_api_key_timestamp` — usage analytics
- `idx_quota_snapshots_api_key_window` — quota tracking
- `idx_call_logs_timestamp` — call log queries
To add a new index, create a migration:
```sql
-- 023_add_my_index.sql
CREATE INDEX IF NOT EXISTS idx_my_table_my_column ON my_table(my_column);
```
### Memory-Mapped I/O
For very large databases (>10GB), memory mapping can be adjusted via SQLite pragma:
```sql
-- Set via SQLite pragma (adjust in core.ts or runtime)
PRAGMA mmap_size = 268435456; -- 256MB
```
### Compaction
Long-running OmniRoute instances benefit from occasional `VACUUM`:
```bash
sqlite3 ~/.omniroute/storage.sqlite "VACUUM;"
```
Run monthly during low-traffic windows. (WAL mode reduces the need, but doesn't eliminate it.)
---
## Health Check
`src/lib/db/healthCheck.ts` provides **DB-level health diagnostics**:
```bash
GET /api/db/health
Returns:
```json
{
"status": "healthy",
"checks": {
"writable": { "status": "pass" },
"integrity": { "status": "pass", "result": "ok" },
"foreign_keys": { "status": "pass", "violations": 0 },
"orphaned_artifacts": { "status": "warn", "count": 12 },
"table_sizes": {
"usage_history": { "rows": 12345, "size_mb": 12.3 },
"call_logs": { "rows": 567, "size_mb": 2.1 }
}
}
}
```
Run `PRAGMA integrity_check` to detect corruption:
```bash
sqlite3 ~/.omniroute/storage.sqlite "PRAGMA integrity_check;"
# Should print: ok
```
If it returns anything other than `ok`, **stop using the database immediately** and restore from backup.
---
## Disaster Recovery
### Scenario 1: WAL File Lost
The `-wal` file is missing but `-shm` and main DB are intact:
```bash
# Recovers automatically on next open
omniroute
```
If SQLite can't auto-recover:
```bash
sqlite3 ~/.omniroute/storage.sqlite ".recover" > recovered.sql
sqlite3 recovered.db < recovered.sql
mv recovered.db ~/.omniroute/storage.sqlite
```
### Scenario 2: Main DB File Corrupted
Restore from backup:
```bash
omniroute sync pull --merge # or: omniroute backup restore <backup-id>
```
### Scenario 3: Encryption Key Lost
**No recovery possible** without the key. The encrypted fields are unreadable. Re-add all providers manually with new credentials.
> **Mitigation**: Always back up the encryption key separately, ideally in a password manager or KMS.
### Scenario 4: Disk Full
SQLite will return `SQLITE_FULL` errors. Free disk space, then:
```bash
# Checkpoint WAL to free up space
sqlite3 ~/.omniroute/storage.sqlite "PRAGMA wal_checkpoint(TRUNCATE);"
```
---
## Common Operations
### Inspect a Table
```bash
sqlite3 ~/.omniroute/storage.sqlite "SELECT * FROM api_keys LIMIT 5;"
```
### Count Rows in All Tables
```bash
sqlite3 ~/.omniroute/storage.sqlite <<EOF
SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';
EOF
```
### Reset (Wipe) All Data
```bash
# Stop OmniRoute first
omniroute stop
# Delete the DB file
rm ~/.omniroute/storage.sqlite*
# Restart (will recreate empty DB)
omniroute
```
For a **selective** reset (keep providers, wipe usage):
```bash
DELETE FROM usage_history WHERE timestamp < datetime('now', '-30 day');
DELETE FROM call_logs WHERE timestamp < datetime('now', '-30 day');
DELETE FROM proxy_logs WHERE timestamp < datetime('now', '-30 day');
```
### Export Single Table
```bash
sqlite3 ~/.omniroute/storage.sqlite <<EOF
.mode csv
.output api_keys.csv
SELECT * FROM api_keys;
EOF
```
---
## Troubleshooting
### "Database is locked"
Another process is holding a write lock. Either:
- Wait for the other process to finish (check `lsof | grep storage.sqlite`)
- Kill the other process
- If persistent, restart OmniRoute
### "Foreign key constraint failed"
A domain module is violating referential integrity. Check:
- Orphaned rows in dependent tables
- Cascading deletes that didn't propagate
- Recent migration that changed a foreign key
Run `PRAGMA foreign_key_check;` to find violations.
### "Out of memory"
SQLite's memory-mapped I/O is exceeding the OS limit. Reduce via SQLite pragma:
```sql
PRAGMA mmap_size = 134217728; -- 128MB instead of 256MB
```
Or disable:
```sql
PRAGMA mmap_size = 0;
```
### "Migration failed mid-way"
The migration ran in a transaction, so it should have rolled back. If not:
1. **Stop OmniRoute** (prevent further attempts)
2. **Check the DB state** with `sqlite3`
3. **Manually fix** the partial migration
4. **Re-run** OmniRoute (the migration will be retried)
To prevent this, always test migrations on a copy first.
---
## See Also
- [USAGE_QUOTA_GUIDE.md](../features/USAGE_QUOTA_GUIDE.md) — usage tables
- [MONITORING_GUIDE.md](./MONITORING_GUIDE.md) — health monitoring
- [RELEASE_CHECKLIST.md](./RELEASE_CHECKLIST.md) — release flow
- Source: `src/lib/db/` (80+ files, ~25K LOC)

468
Monitoring-Guide.md Normal file

@@ -0,0 +1,468 @@
> 🌍 [View in other languages](Languages)
# Monitoring & Observability Guide
> **TL;DR**: OmniRoute ships with built-in health monitoring, provider autopilot, quota tracking, and observability hooks. This guide covers the dashboard, alerts, and troubleshooting.
**Sources:**
- `src/lib/monitoring/observability.ts` — observability snapshot
- `src/lib/monitoring/comboHealthAutopilot.ts` — combo health autopilot
- `src/lib/monitoring/providerHealthAutopilot.ts` — provider autopilot
- `src/lib/monitoring/providerHealthMatrix.ts` — provider health matrix
- `src/lib/localHealthCheck.ts` — local health check
- `src/lib/tokenHealthCheck.ts` — token refresh health
- `src/lib/proxyHealth.ts` — proxy health cache (covered in PROXY_GUIDE.md)
---
## Overview
OmniRoute has **3 layers of monitoring**:
```
┌──────────────────────────────────────────────────────────────┐
│ Layer 1: System Health (server-level) │
│ ├─ localHealthCheck.ts — DB, ports, native deps │
│ ├─ db/healthCheck.ts — integrity, FK, orphaned artifacts │
│ └─ Dashboard: /dashboard/health │
├──────────────────────────────────────────────────────────────┤
│ Layer 2: Provider Health (per-provider resilience) │
│ ├─ providerHealthAutopilot.ts — circuit breaker, cooldowns │
│ ├─ providerHealthMatrix.ts — health scores by provider/model │
│ └─ Dashboard: /dashboard/providers │
├──────────────────────────────────────────────────────────────┤
│ Layer 3: Live Observability (runtime snapshots) │
│ ├─ observability.ts — circuit breakers, sessions, quota │
│ ├─ tokenHealthCheck.ts — OAuth token refresh health │
│ └─ MCP tools: omniroute_get_health, omniroute_get_session_snapshot │
└──────────────────────────────────────────────────────────────┘
```
---
## Dashboard Pages
### `/dashboard/health` (System Health)
The top-level health dashboard shows:
| Section | What it shows |
|---------|---------------|
| **Server status** | Uptime, version, port, active connections |
| **Database** | Connection, integrity, WAL size, recent migrations |
| **Provider summary** | Active count, healthy count, breaker open count |
| **Quota monitors** | Active sessions, alerting, exhausted |
| **Recent errors** | Last 10 errors with stack traces |
| **Resource usage** | Memory, CPU, heap pressure indicator |
### `/dashboard/providers` (Provider Health)
Per-provider dashboard:
| Column | Description |
|--------|-------------|
| Provider | Provider ID + display name |
| Health | Green/yellow/red status |
| Circuit | Open/closed/half-open state |
| Connections | Count of connections, last refresh |
| Models | Available models, health per model |
| Cost | Today's cost, 7-day trend |
| Errors | Last 24h error count, top error class |
Click a provider to see:
- Recent requests with latency breakdown
- Per-connection health scores
- Per-model lockouts
- Autopilot recommendations
### `/dashboard/quota` (Quota Tracking)
For each API key:
- Current usage vs limit (progress bar)
- Quota trend (30-day chart)
- Next reset time
- Alert history
### `/dashboard/combos` (Combo Health)
Per-combo:
- Strategy + targets
- Health per target
- Recent fallback events
- Success rate (24h, 7d, 30d)
---
## Health Check API
> **Note:** Only `GET /api/monitoring/health` is exposed as a REST endpoint. All other monitoring data (provider health, autopilot issues, quota monitors, token health, latency) is accessed via the **MCP tool** `observability_snapshot` or the **dashboard** pages — there are no dedicated REST routes for these.
### System Health
```bash
GET /api/monitoring/health
```
Response:
```json
{
"status": "healthy",
"version": "3.8.16",
"uptime": 123456,
"checks": {
"database": { "status": "pass", "latency_ms": 2 },
"writeable": { "status": "pass" },
"integrity": { "status": "pass", "result": "ok" },
"foreign_keys": { "status": "pass", "violations": 0 },
"heap_pressure": { "status": "pass", "usage_mb": 142, "threshold_mb": 512 },
"active_sessions": 12,
"providers": {
"total": 7,
"healthy": 6,
"degraded": 1,
"down": 0
}
}
}
```
### Provider Health
> **No REST endpoint.** Provider health data is available via the MCP tool `observability_snapshot` or the dashboard `/dashboard/providers` page.
### Provider Detail
> **No REST endpoint.** Per-provider detail is available via the dashboard `/dashboard/providers` page.
---
## Provider Health Autopilot
The `providerHealthAutopilot.ts` module is a **self-healing system** that:
1. Detects provider issues (circuit open, cooldowns, lockouts, quota warnings)
2. Generates **recommended actions** to resolve them
3. Optionally **auto-executes** low-risk actions
### Issue Types Detected
| Issue kind | Severity | Example condition |
|------------|----------|-------------------|
| `provider_circuit_open` | critical | Circuit breaker open after 5 failures |
| `provider_circuit_half_open` | warning | Circuit testing recovery |
| `connection_cooldown` | warning | Connection in cooldown after 429 |
| `stale_connection_error` | warning | Last refresh failed 30+ minutes ago |
| `terminal_connection_error` | critical | OAuth revoked, key invalid |
| `inactive_connection` | info | Connection disabled in settings |
| `model_lockout` | warning | Specific model in quarantine |
| `quota_monitor_warning` | warning | Quota at 80%+ usage |
### Action Types Generated
| Action | Risk | Description |
|--------|------|-------------|
| `clear_provider_breaker` | medium | Reset the circuit breaker to closed |
| `clear_connection_cooldown` | low | Remove cooldown from a connection |
| `clear_stale_connection_error` | low | Clear stale error flag |
| `clear_model_lockout` | low | Re-enable a quarantined model |
| `reactivate_connection` | medium | Re-enable a deactivated connection |
| `deactivate_connection` | high | Disable a problematic connection |
### API
> **No REST endpoint.** Autopilot issues are available via the MCP tool `observability_snapshot` or the dashboard. The autopilot runs internally; its behavior is configured via the settings DB (per-connection `autopilotMode` field), not environment variables — `grep -rn` for an autopilot-mode env var returns zero hits.
### Autopilot Mode
The autopilot operates in **manual mode** by default — it detects issues and generates recommended actions, but does not auto-apply them. Actions can be applied via the dashboard.
---
## Combo Health Autopilot
`comboHealthAutopilot.ts` is the **combo-specific** equivalent of the provider autopilot. It:
- Detects unhealthy combos
- Recommends target reordering
- Suggests disabling broken targets
- Auto-removes dead targets after N failures
### Combo Issue Examples
```
Combo "always-on" (priority strategy)
├─ Target 1: openai/gpt-5 (healthy)
├─ Target 2: anthropic/claude-opus-4-6 (⚠️ model lockout until 14:00)
└─ Target 3: kiro/claude-sonnet-4-5 (healthy)
Recommended action: Reorder — move kiro above anthropic until lockout expires
```
---
## Quota Monitors
`observability.ts` exposes **per-session quota monitors** for subscription providers (Claude Code, Codex, GitHub Copilot):
```ts
interface QuotaMonitorSnapshot {
sessionId: string;
provider: string;
accountId: string;
status: "starting" | "idle" | "healthy" | "warning" | "exhausted" | "error";
lastQuotaPercent: number | null; // 0-100
lastQuotaUsed: number | null;
lastQuotaTotal: number | null;
lastResetAt: string | null;
nextPollAt: string | null;
totalPolls: number;
totalAlerts: number;
consecutiveFailures: number;
}
```
### Status Meanings
| Status | When | UI action |
|--------|------|-----------|
| `starting` | Initial poll in progress | Spinner |
| `idle` | No recent activity | Hidden from dashboard |
| `healthy` | Quota > 50% remaining | Green dot |
| `warning` | Quota < 50% remaining | Yellow alert |
| `exhausted` | Quota = 0% | Red block, route to next provider |
| `error` | Polling failed | Red dot, retry soon |
### API
> **No REST endpoint.** Quota monitor data is available via the MCP tool `observability_snapshot` or the dashboard.
---
## Observability Snapshot
The MCP tool `observability_snapshot` returns a **complete system snapshot** for AI agents:
```json
{
"circuitBreakers": [
{
"name": "openai",
"state": "closed",
"failureCount": 0,
"lastFailureTime": null,
"retryAfterMs": null
}
],
"sessions": [
{
"sessionId": "sess-123",
"createdAt": 1234567890,
"lastActive": 1234567999,
"requestCount": 42,
"connectionId": "conn-456",
"ageMs": 109
}
],
"quotaMonitors": { /* see above */ },
"uptime": 12345,
"version": "3.8.16"
}
```
Agents use this to make **routing decisions** — for example, "if openai's circuit is open, route to anthropic first".
---
## Token Health Check
OAuth providers (Claude Code, GitHub Copilot, Cursor) need **periodic token refresh**. `src/lib/tokenHealthCheck.ts` runs a background scheduler:
- **Sweep tick**: every 60 seconds (sweep in `TICK_MS = 60 * 1000` at `src/lib/tokenHealthCheck.ts:30`)
- **Per-connection health check interval**: default 60 minutes (`DEFAULT_HEALTH_CHECK_INTERVAL_MIN = 60`); configurable via the settings DB
- **Pre-emptive refresh on 401**: handled by the per-connection interceptor
### Token Health Status
```ts
interface TokenHealth {
connectionId: string;
provider: string;
status: "valid" | "expiring_soon" | "expired" | "refresh_failed";
expiresAt: string;
lastRefresh: string;
nextRefresh: string;
consecutiveFailures: number;
}
```
### Configuration
Token health check configuration is handled internally by `tokenHealthCheck.ts`.
### Token Health
> **No REST endpoint.** Token health data is available via the dashboard or the MCP tool `observability_snapshot`.
---
## Alerting
### Built-in Channels
OmniRoute supports **3 alert channels**:
| Channel | Setup | Use case |
|---------|-------|----------|
| Dashboard banner | Always on | In-app notifications |
| Webhook | Configure URL | Slack, Discord, PagerDuty |
| Log | Default | For external log aggregation |
### Webhook Configuration
> **Note:** Webhook alerting configuration is handled via the dashboard Settings page. See the Settings UI for webhook URL, event filtering, and payload customization.
### Alert Types
| Alert | When | Default severity |
|-------|------|------------------|
| `provider_circuit_open` | Circuit opens | critical |
| `provider_circuit_half_open` | Circuit testing recovery | info |
| `quota_warning` | Quota at 80%+ | warning |
| `quota_exhausted` | Quota at 100% | critical |
| `token_refresh_failed` | 3+ consecutive refresh failures | warning |
| `token_expired` | Token past expiry | critical |
| `combo_target_unhealthy` | Combo target in cooldown for 1h+ | warning |
| `db_integrity_warning` | FK violations > 0 | warning |
| `heap_pressure` | Heap usage > 80% of threshold | warning |
---
## Performance Metrics
### Tracked Metrics
| Metric | Type | Source |
|--------|------|--------|
| `request_count` | counter | `services/usage.ts` |
| `request_latency_ms` | histogram | `services/usage.ts` |
| `tokens_consumed` | counter | `services/usage.ts` |
| `cost_usd` | counter | `services/usage.ts` |
| `provider_errors` | counter | `services/errorClassifier.ts` |
| `circuit_state_changes` | counter | `services/resilience.ts` |
| `cache_hits` | counter | `services/signatureCache.ts` |
| `compression_savings` | histogram | `services/compression/stats.ts` |
| `quota_used` | gauge | `services/quotaMonitor.ts` |
| `memory_used_mb` | gauge | `observability.ts` |
### Latency Percentiles (p50/p95/p99)
> **No REST endpoint.** Latency percentile data is available via the dashboard `/dashboard/health` page. Prometheus/OpenTelemetry export is planned for v3.9.
### Prometheus / OpenTelemetry Export (Phase 2)
Planned for v3.9: native export to Prometheus, OpenTelemetry, Datadog.
For now, scrape `/api/monitoring/health` with any HTTP-based monitoring system (Prometheus blackbox exporter, Datadog HTTP check, etc.).
---
## Alerting Recipes
### Slack
> **Note:** Webhook alerting is configured through the dashboard Settings page — there are no dedicated webhook env vars (`grep -rn` returns zero hits). See the Settings UI for webhook URL, event filtering, and payload customization.
### Discord
> Webhook alerting uses the same Settings UI flow as Slack. Discord accepts the same JSON payload shape.
### PagerDuty
> Webhook alerting uses the same Settings UI flow. PagerDuty Events API v2 routing keys are configured in the Settings UI.
### Custom Webhook (JSON)
> Any HTTP endpoint that accepts POST with JSON body will work. Configure the URL in the Settings UI.
---
## Dashboard Configuration
### Customize the Health Dashboard
Create a `~/.omniroute/dashboard.json`:
```json
{
"health": {
"sections": [
"server_status",
"database",
"providers",
"quota_monitors",
"recent_errors"
],
"refresh_interval_ms": 5000
}
}
```
### Pin a Provider to the Top
```json
{
"health": {
"pinned_providers": ["openai", "anthropic"]
}
}
```
---
## Troubleshooting
### "Provider says healthy but requests fail"
1. Check the **autopilot issues** — maybe a model is locked out
2. Look at **recent errors** for the specific error class
3. Try the **connection test** in the provider card
4. Check if the provider is **rate-limited at upstream** (not visible locally)
### "Quota says healthy but I see 429s"
- 429 means the provider says you've used your quota
- OmniRoute's quota tracking may be **stale** — the provider's truth is upstream
- Quota data refreshes automatically via the internal quota monitor
### "Combo is failing but all targets look healthy"
- Check **combo health** dashboard for target ordering issues
- Look at **fallback events** — maybe the combo is exhausting too quickly
- Verify the **strategy** matches your use case (priority vs round-robin vs auto)
### "Database health check is failing"
- Run `sqlite3 ~/.omniroute/storage.sqlite "PRAGMA integrity_check;"`
- If "ok" — false alarm, the health check is being too strict
- If anything else — **stop OmniRoute** and follow the [disaster recovery guide](./DATABASE_GUIDE.md#disaster-recovery)
### "Memory heap pressure is critical"
```bash
# Check current heap
node -e "console.log(process.memoryUsage())"
# Trigger manual GC (if --expose-gc)
node --expose-gc -e "global.gc(); console.log(process.memoryUsage())"
# Reduce concurrent requests (set via the dashboard Settings page, not an env var)
# There is no `MAX_CONCURRENT_REQUESTS` env var — configure it in Settings → Concurrency.
```
---
## See Also
- [USAGE_QUOTA_GUIDE.md](../features/USAGE_QUOTA_GUIDE.md) — usage & cost tracking
- [DATABASE_GUIDE.md](./DATABASE_GUIDE.md) — DB schema + health
- [PROXY_GUIDE.md](./PROXY_GUIDE.md) — proxy health (separate cache)
- [ARCHITECTURE.md](../architecture/ARCHITECTURE.md) — system architecture
- [RESILIENCE_GUIDE.md](../architecture/RESILIENCE_GUIDE.md) — circuit breaker details
- Source: `src/lib/monitoring/` (4 files, 2121 LOC)

564
Open-SSE-Architecture.md Normal file

@@ -0,0 +1,564 @@
> 🌍 [View in other languages](Languages)
# open-sse Architecture
> **TL;DR**: `open-sse/` is the core streaming engine that powers every LLM request in OmniRoute. It contains ~406 files implementing the request pipeline, executors, services, MCP server, and translation layer. This guide explains how the pieces fit together.
**Source:** `open-sse/` (workspace package, ~143K LOC across 406 files)
---
## Why a Separate Workspace Package?
`open-sse/` is a **standalone workspace** in the OmniRoute monorepo for several reasons:
1. **Reusability**`open-sse` is published as `@omniroute/open-sse` on npm, so other projects can use it independently
2. **Clean boundaries** — the streaming engine is decoupled from the OmniRoute-specific UI/DB layer
3. **Performance** — the engine has no Next.js dependencies, enabling faster cold starts in CLI/serverless contexts
4. **Versioning**`open-sse` can release on its own cadence
```json
// package.json
"workspaces": ["open-sse"]
```
---
## Top-Level Structure
```
open-sse/
├── index.ts # Public entry point
├── types.d.ts # Public type exports
├── package.json # @omniroute/open-sse
├── config/ # Provider configs, constants, registries
├── executors/ # Per-provider HTTP executors (59 files)
├── handlers/ # Request handlers (chatCore, responses, etc.)
├── lib/ # Internal utilities
├── mcp-server/ # Model Context Protocol server
├── services/ # ~114 service modules
├── transformer/ # Responses API format transformer
├── translator/ # Format translation (OpenAI ↔ Claude ↔ Gemini)
└── utils/ # Shared utilities (logging, error, stream, etc.)
```
### Module Counts
| Directory | Files | Purpose |
| `executors/` | 62 | Per-provider HTTP executors (unified via DefaultExecutor factory) |
| `handlers/` | ~15 | Request entry points (chatCore, responses, embeddings) |
| `services/` | ~114 | Routing, caching, rate limiting, refresh, etc. |
| `translator/` | ~10 | Format conversion (OpenAI ↔ Claude ↔ Gemini) |
| `mcp-server/` | 30 | MCP tools and transports |
| `utils/` | ~30 | Cross-cutting utilities (logging, error, stream) |
| `config/` | ~10 | Provider configs, constants, registries |
---
## The Request Pipeline
Every LLM request flows through a **5-stage pipeline**:
```
┌──────────────┐
HTTP request │ 1. ROUTE │ combo resolution, model selection
(Next.js route) └──────┬───────┘
┌──────────────┐
│ 2. TRANSLATE│ format conversion (OpenAI ↔ Claude ↔ Gemini)
└──────┬───────┘
┌──────────────┐
│ 3. EXECUTE │ provider executor, HTTP, retry, breaker
└──────┬───────┘
┌──────────────┐
│ 4. STREAM │ SSE transformation, backpressure
└──────┬───────┘
┌──────────────┐
│ 5. RECORD │ usage tracking, call log, error classification
└──────┬───────┘
HTTP response (SSE or JSON)
```
### Stage 1: Route (services/combo.ts)
**Entry point**: `handleComboChat()` in `services/combo.ts`
Resolves the request to a concrete `(provider, model, account, credentials)` tuple:
- Look up the combo by ID (or build a virtual combo for `auto/*` models)
- Apply routing strategy (priority, weighted, round-robin, etc.)
- Filter out unhealthy providers (circuit breaker)
- Pick the next viable target
For `auto/*` models, this stage also:
- Runs the **9-factor scoring** algorithm (`services/autoCombo/`)
- Selects a `provider+model` pair based on health, cost, latency, etc.
### Stage 2: Translate (translator/)
If the source format (e.g., OpenAI) differs from the target format (e.g., Claude), the request is **translated**:
- System prompt → system message
- Tool definitions → provider-specific tool format
- Reasoning/thinking parameters → provider-specific equivalents
- Message role normalization (`developer``system` for non-OpenAI)
The `translator/index.ts` exposes:
```ts
translateRequest(body, sourceFormat, targetFormat): TranslatedRequest
needsTranslation(source, target): boolean
```
### Stage 3: Execute (executors/)
**Entry point**: `getExecutor(providerId).execute(request, options)`
All providers use `DefaultExecutor` (`executors/default.ts`) via the `getExecutor()` factory fallback. The executor:
- Builds the upstream URL (`buildUrl()`)
- Adds provider-specific headers (`buildHeaders()`)
- Transforms the request body (`transformRequest()`)
- Sends the HTTP request with retry + exponential backoff
- Handles auth refresh if needed (OAuth providers)
All executors extend `BaseExecutor` (`executors/base.ts`, 1170 LOC) which provides:
- Common retry logic
- Proxy integration
- Circuit breaker integration
- Usage recording hooks
### Stage 4: Stream (utils/stream.ts)
For streaming responses, the executor returns a **ReadableStream**. The handler:
- Pipes through an SSE transform (`createSSETransformStreamWithLogger`)
- Applies heartbeat pings to detect dead connections
- Handles client disconnect gracefully (`pipeWithDisconnect`)
- Transforms SSE → JSON for non-streaming clients
For non-streaming responses, the executor returns a parsed JSON object that is passed through unchanged.
### Stage 5: Record (services/usage.ts)
After the response (success or failure), usage is recorded:
- `prompt_tokens`, `completion_tokens`, `cached_tokens` from the response
- `cost_usd` computed from pricing data
- `latency_ms`, `status`, `error_class` if failed
- Persisted to `usage_history` table
Call log artifacts (if enabled) are written to `${DATA_DIR}/call_logs/`.
---
## Key Files Deep-Dive
### chatCore.ts (5977 lines)
The **main request handler**. Despite its size, it has a clear structure:
```ts
// Pseudo-structure of chatCore.ts
export async function handleChat(request: NextRequest) {
// 1. Auth + CORS
await authenticateRequest(request);
applyCorsHeaders(response);
// 2. Body validation
const body = await parseRequestBody(request);
// 3. Format detection + translation
const sourceFormat = detectFormat(request);
const targetFormat = getTargetFormat(providerId);
if (needsTranslation(sourceFormat, targetFormat)) {
body = translateRequest(body, sourceFormat, targetFormat);
}
// 4. Combo routing
const targets = await resolveComboTargets(comboId, body);
for (const target of targets) {
try {
const result = await executeOnTarget(target, body);
await recordUsage(result);
return result;
} catch (err) {
// Continue to next target
}
}
// 5. Emergency fallback
return await emergencyFallback(body);
}
```
Despite being one giant function, it's organized into **commented sections** that map to the 5-stage pipeline.
### combo.ts (4456 LOC)
The **routing engine** that resolves a combo to ordered targets.
```ts
// services/combo.ts
export async function handleComboChat(body, comboId): Promise<ChatResult> {
const targets = await resolveComboTargets(comboId, body);
for (const target of targets) {
try {
return await handleSingleModel(target, body);
} catch (err) {
log.warn("target failed, trying next", { target, err });
}
}
throw new ComboExhaustedError("All targets failed");
}
```
Supports **15 routing strategies** (see `src/shared/constants/routingStrategies.ts`):
| Strategy | Behavior |
|----------|----------|
| `priority` | First-target ordered list |
| `weighted` | Probabilistic by per-target weight |
| `round-robin` | Cycle through targets in order |
| `context-relay` | Hand off context across targets |
| `fill-first` | Fill quota before moving to next |
| `p2c` | Power of two choices |
| `random` | Uniform random |
| `least-used` | Pick the one with fewest recent uses |
| `cost-optimized` | Cheapest healthy target first |
| `reset-aware` | Aware of provider reset windows |
| `reset-window` | Reset window-based routing |
| `strict-random` | Truly uniform (no quality weighting) |
| `auto` | Use 9-factor scoring (`autoCombo/`) |
| `lkgp` | Last known good provider first |
| `context-optimized` | Best for long-context requests |
### base.ts (1170 LOC)
The **abstract executor** that all 59 executors extend. It contains:
- `buildUrl()` — default URL construction (subclasses override for custom)
- `buildHeaders()` — default headers (auth, content-type)
- `transformRequest()` — pass-through by default
- `execute()` — the main HTTP loop with retry/backoff/breaker
```ts
// open-sse/executors/default.ts
export class DefaultExecutor extends BaseExecutor {
// Handles all OpenAI/Anthropic-compatible providers
// Providers register configurations (URL, auth, headers) but share executor logic
}
```
Provider-specific behavior (auth headers, base URL, version headers) is configured via the provider registry, not separate executor classes.
```
---
## Services (117 modules)
Services are **focused, single-purpose modules** that handlers compose. The big categories:
### Routing & Combo
- `combo.ts` — entry point for combo-routed requests
- `services/autoCombo/` — 9-factor scoring, 8 auto routing strategies
- `wildcardRouter.ts` — matches wildcard routes (`gpt-*`)
- `modelFamilyFallback.ts` — T5 intra-family fallback
### Rate Limiting & Quota
- `rateLimitManager.ts` — token bucket per key+provider
- `usage.ts` — usage recording
- `quotaCache.ts` — in-memory quota snapshots
### Account & Token
- `tokenRefresh.ts` — OAuth refresh on 401
- `accountFallback.ts` — switch to alternate account
- `sessionManager.ts` — multi-turn session state
### Intelligence
- `intentClassifier.ts` — classify request intent
- `taskAwareRouter.ts` — route by task type
- `thinkingBudget.ts` — allocate thinking tokens
- `contextManager.ts` — inject routing context
### Resilience
- `resilience.ts` — retry, backoff, breaker orchestration
- `emergencyFallback.ts` — last-resort fallback
- `modelDeprecation.ts` — auto-route to successor models
### State
- `signatureCache.ts` — dedup by request signature
- `volumeDetector.ts` — load shedding
- `contextHandoff.ts` — session serialization
### Compression
- `compression/` (subdirectory) — full compression pipeline
- 39 files covering engines, rule packs, adapters
### Skills
- (covered in [SKILLS.md](./SKILLS.md))
### Memory
- (covered in [MEMORY.md](./MEMORY.md))
---
## Executors (75+ files)
One file per provider. They all extend `BaseExecutor` and override what differs.
### Common Patterns
Providers are resolved via `getExecutor(providerId)`, which returns the configured executor. OpenAI/Anthropic-compatible providers use `DefaultExecutor` (`executors/default.ts`). Provider-specific behavior (base URL, auth headers, API version) is configured in `open-sse/config/providers/`, while request body transformations are handled in `open-sse/translator/`.
**Custom URL** is set via provider configuration:
```ts
// Provider config in open-sse/config/providers/
export default {
id: "together",
baseURL: "https://api.together.xyz/v1/chat/completions",
}
```
**Custom auth** is handled through the provider registry's auth configuration (API key, OAuth, header profiles).
**Custom request body** transformations (e.g., Anthropic separating `system` from `messages`) are registered per-provider in `open-sse/translator/`.
```
### The Executor Factory
`executors/index.ts` exports `getExecutor(providerId)`:
```ts
import { getExecutor } from "@omniroute/open-sse/executors";
const executor = getExecutor("anthropic");
const result = await executor.execute({
model: "claude-sonnet-4-5",
messages: [...],
});
```
The factory is generated from `config/providerRegistry.ts` which lists all 212+ providers and their executor class.
---
## Translators
Translate between **3 formats**: OpenAI, Anthropic, Gemini, plus the new Responses API.
### When Translation Happens
```ts
import { needsTranslation, translateRequest } from "@omniroute/open-sse/translator";
if (needsTranslation(sourceFormat, targetFormat)) {
body = translateRequest(body, sourceFormat, targetFormat);
}
```
Common translations:
- `OpenAI → Anthropic`: separate `system` field, `x-api-key` header
- `OpenAI → Gemini`: `contents` instead of `messages`, `systemInstruction`
- `OpenAI → Responses API`: `input` array, `previous_response_id` state
### Edge Cases Handled
- `developer` role → `system` for non-OpenAI
- `system` role → merged into first user message for GLM/ERNIE
- `json_schema` → Gemini's `responseMimeType` + `responseSchema`
- `tools` → provider-specific tool format
- Thinking parameters (o1, Claude) → provider-specific equivalents
---
## MCP Server
`open-sse/mcp-server/` implements the **Model Context Protocol** server:
- **30+ tools** (provider management, combos, memory, cache, compression, 1proxy, skills)
- **3 transports**: stdio, SSE, Streamable HTTP
- **13 scopes** for fine-grained authorization
### Tool Registration
Tools are registered as standalone files in `open-sse/mcp-server/tools/`, each exporting a name, schema, handler, and scope:
```ts
// open-sse/mcp-server/tools/getHealth.ts
import { z } from "zod";
export default {
name: "omniroute_get_health",
description: "Get system health snapshot",
scope: "read:health",
inputSchema: z.object({}),
handler: async (_args, ctx) => {
return await getSystemHealth();
},
};
```
### Transports
```ts
// stdio (CLI usage)
startMcpStdio(server);
// SSE (HTTP-based streaming)
startMcpSse(server, port);
// Streamable HTTP (modern MCP)
startMcpStreamable(server, port);
```
### Authorization
Every tool call goes through scope checks (`open-sse/mcp-server/auth/`):
```ts
if (!hasScope(apiKey, "providers:read")) {
throw new Error("Insufficient scope");
}
```
---
## Transformers
`open-sse/transformer/` converts between **Chat Completions** and **Responses API** formats.
### Why a Separate Transformer?
The Responses API is OpenAI's new format with **stateful conversations** (`previous_response_id`). When a client sends a Responses request, OmniRoute:
1. Converts Responses → Chat Completions internally
2. Sends to provider (any provider that supports Chat Completions)
3. Converts the response back to Responses format
4. Streams the converted response to the client
The transformer (`transformer/responsesTransformer.ts`) provides:
```ts
createResponsesApiTransformStream(): TransformStream
```
This handles:
- `response.output_item.added` events
- `response.output_text.delta` events
- `response.completed` event
- Tool call mapping (`function_call``tool_calls`)
---
## Configuration
`open-sse/config/` holds the configuration layer:
| File | Purpose |
|------|---------|
| `providerRegistry.ts` | 212+ provider definitions |
| `providerModels.ts` | Model aliases, format mapping |
| `constants.ts` | Timeouts, limits, status codes |
| `defaultThinkingSignature.ts` | Default Claude thinking signature |
| `modelStrip.ts` (in services) | Per-provider field stripping |
### Provider Registry Schema
```ts
interface ProviderConfig {
id: string;
name: string;
baseUrl: string;
authType: "bearer" | "api-key" | "oauth" | "cookie";
executorClass: string;
defaultModel: string;
capabilities: ProviderCapabilities;
models: ModelDefinition[];
}
```
Zod validation at module load ensures all provider configs are valid.
---
## Performance Constraints
The routing engine has strict performance budgets:
| Operation | Target | Measurement |
|-----------|--------|-------------|
| Combo resolution | <10ms | For 50 targets |
| Rate limit check | <1ms | In-memory token bucket |
| Model family fallback | <5ms | Cached family definitions |
| Request routing dispatch | <2ms | Hot path |
| **No blocking I/O in routing hot path** | — | All async |
---
## Anti-Patterns
**Synchronous DB calls in `combo.ts`** — pre-compute and cache
**Retry logic in handlers** — use `retry()` from resilience service
**Direct provider config access** — use `providerRegistry` getters
**Hardcoded fallback chains** — define in `modelFamilyFallback.ts`
**State mutations across concurrent requests** — use request-scoped context only
---
## Adding a New Component
### Adding a New Service
1. Create `open-sse/services/[serviceName].ts` with focused responsibility
2. Export main handler function and any constants
3. Add unit tests in `tests/unit/services/[serviceName].test.mjs`
4. Integrate into request pipeline in `handlers/chatCore.ts` (if routing-related)
5. Update routing logic in `combo.ts` if service affects target selection
6. Document in this file
### Adding a New Executor
1. Create `open-sse/executors/[provider].ts` extending `BaseExecutor`
2. Register in `config/providerRegistry.ts`
3. Add to `executors/index.ts` factory
4. Add unit tests for the executor
5. Document in `docs/architecture/ARCHITECTURE.md`
### Adding a New MCP Tool
1. Create or update `open-sse/mcp-server/tools/[category]Tools.ts`
2. Define Zod schema for inputs
3. Register tool in `mcp-server/index.ts`
4. Add to scope matrix in `mcp-server/auth/`
5. Add unit tests
---
## See Also
- [ARCHITECTURE.md](../architecture/ARCHITECTURE.md) — high-level architecture
- [CODEBASE_DOCUMENTATION.md](../architecture/CODEBASE_DOCUMENTATION.md) — engineering reference
- [REPOSITORY_MAP.md](../architecture/REPOSITORY_MAP.md) — directory-by-directory
- [AUTO-COMBO.md](../routing/AUTO-COMBO.md) — 9-factor scoring
- [MCP-SERVER.md](./MCP-SERVER.md) — MCP server
- [A2A-SERVER.md](./A2A-SERVER.md) — A2A server
- Source: `open-sse/` (400+ files, ~143K LOC)

427
Usage-Quota-Guide.md Normal file

@@ -0,0 +1,427 @@
> 🌍 [View in other languages](Languages)
# Usage, Quota & Spend Tracking
> **TL;DR**: OmniRoute tracks every request's token usage, computes cost, enforces per-API-key quota, and surfaces analytics in the dashboard. This guide explains how it all works.
**Sources:**
- `open-sse/services/usage.ts` (~70KB) — main usage tracking
- `src/lib/usageAnalytics.ts` (~10KB) — aggregation for dashboard
- `src/lib/db/quotaSnapshots.ts` — historical quota data
- `src/lib/db/usage*.ts` — multiple usage-related DB modules
---
## Overview
Every request that flows through OmniRoute generates a **usage record** that captures:
- **Identity**: which API key, provider, model, combo
- **Tokens**: prompt tokens, completion tokens, cached tokens, total
- **Cost**: USD amount (computed from pricing data)
- **Timing**: latency, start/end timestamps
- **Status**: success, error, rate-limited, etc.
These records are aggregated into **analytics**, persisted as **quota snapshots**, and used to enforce **per-key budget limits**.
```
Request ──▶ chatCore ──▶ usage.record() ──▶ SQLite
┌───────┼───────┐
▼ ▼ ▼
analytics quota billing
(dashboard) (enforce) (export)
```
---
## What Gets Recorded
The `usage.ts` service captures a **usage event** for every request:
| Field | Type | Source |
|-------|------|--------|
| `id` | string | UUID generated on record |
| `apiKeyId` | string | The API key that initiated the request |
| `provider` | string | Provider ID (openai, anthropic, etc.) |
| `model` | string | Model ID (gpt-5, claude-opus-4-6, etc.) |
| `comboId` | string? | Combo ID if routed through a combo |
| `promptTokens` | number | From upstream response |
| `completionTokens` | number | From upstream response |
| `cachedTokens` | number | Cache hit tokens (Anthropic prompt caching, etc.) |
| `totalTokens` | number | prompt + completion |
| `costUsd` | number | Computed from pricing data |
| `latencyMs` | number | End-to-end request duration |
| `status` | enum | `success`, `error`, `rate_limited`, `timeout`, `cancelled` |
| `errorClass` | string? | Error class if status != success |
| `timestamp` | string | ISO 8601 UTC |
| `metadata` | object | Custom plugin-injected data |
### Where Tokens Come From
Tokens are extracted from the upstream provider's response in the **response handler**:
```ts
// From open-sse/handlers/chatCore.ts
const response = await providerExecutor.execute(provider, request);
const usage = response.usage || {
prompt_tokens: 0,
completion_tokens: 0,
cached_tokens: 0,
};
```
For providers that don't return usage (some web-cookie providers), OmniRoute **estimates** tokens using a `~4 chars per token` heuristic (see `open-sse/services/autoCombo/pipelineRouter.ts`).
### Cached Tokens
OmniRoute tracks `cached_tokens` separately from `prompt_tokens` because:
- Anthropic prompt caching charges a reduced rate for cached tokens (10% of normal)
- Some providers return `cache_read_input_tokens` that should be priced differently
- Analytics can show the **cache hit rate** = `cached_tokens / prompt_tokens`
---
## Cost Calculation
Costs are computed from **pricing data** synced from LiteLLM (`src/lib/pricingSync.ts`):
| Model | Input $/1M | Output $/1M | Cached $/1M |
|-------|-----------|-------------|-------------|
| gpt-5 | $2.50 | $10.00 | — |
| claude-opus-4-6 | $15.00 | $75.00 | $1.50 |
| claude-sonnet-4-5 | $3.00 | $15.00 | $0.30 |
| gemini-2.5-pro | $1.25 | $10.00 | — |
The cost formula (`src/lib/usage/costCalculator.ts`):
```ts
cost = (prompt_tokens - cached_tokens) * input_price
+ cached_tokens * cached_price
+ completion_tokens * output_price
```
> **Why subtract cached from prompt?** The cached portion is priced separately; charging input price on the whole prompt would over-count.
### Pricing Sync
Pricing data is auto-synced from LiteLLM via the `/api/pricing/sync` endpoint (triggered by the built-in cron task, not a user-facing env var):
```bash
# Manual trigger
curl -X POST http://localhost:20128/api/pricing/sync
```
For models with no pricing data, OmniRoute falls back to **estimating cost** using internal average rates (sourced from LiteLLM's pricing data).
---
## Date Range Aggregation
The `usageAnalytics.ts` module computes dashboard widgets from raw usage data. It supports 7 time ranges:
| Range | Window | Use case |
|-------|--------|----------|
| `1d` | Last 24 hours | Hourly cost spike detection |
| `7d` | Last 7 days | Weekly review |
| `30d` | Last 30 days | Monthly billing |
| `90d` | Last 90 days | Quarterly analysis |
| `ytd` | Since Jan 1 of current year | Annual budget tracking |
| `all` | All time | Lifetime stats |
| `custom` | User-defined start/end | Audits, ad-hoc queries |
### Dashboard Widgets Computed
For any date range, the analytics layer computes:
| Widget | Description |
|--------|-------------|
| **Summary cards** | Total requests, total cost, total tokens, success rate |
| **Daily trend chart** | Cost + tokens per day, stacked by model |
| **Activity heatmap** | Hour-of-day × day-of-week grid, color = request count |
| **Model breakdown** | Pie chart of cost by model |
| **Provider breakdown** | Bar chart of requests by provider |
| **Top API keys** | Table of top 10 keys by cost |
| **Error analysis** | Error rate over time, top error classes |
### Programmatic Access
```ts
import { computeAnalytics } from "@/lib/usageAnalytics";
const analytics = await computeAnalytics(
history, // usage history records
"7d", // time range: "1d" | "7d" | "30d" | "90d" | "ytd" | "all" | "custom"
connectionMap, // provider connection map (connectionId → account name)
{
startDate: "2025-01-01", // optional: for "custom" range
endDate: "2025-06-01", // optional: for "custom" range
}
);
console.log(analytics.summary.totalCost); // 12.34 (cents)
console.log(analytics.byModel[0]); // { model, cost, requests, promptTokens, completionTokens }
---
## Quota Enforcement
Per-API-key quota is enforced in two places:
1. **Soft limit** (`quotaWarnAt`): dashboard warning when usage exceeds threshold
2. **Hard limit** (`quotaLimit`): request rejected with HTTP 429 when exceeded
### Configuration
```ts
// Per API key
await updateApiKey(keyId, {
quotaWarnAt: 5_00, // $5.00 — show warning
quotaLimit: 10_00, // $10.00 — hard stop
quotaWindow: "month", // "day" | "week" | "month" | "all"
});
```
### Enforcement Flow
```
Request ──▶ quotaCheck()
├── Within limit? ──▶ allow
└── Over limit? ──▶ 429 Too Many Requests
with Retry-After header
```
### Quota Snapshots
`quotaSnapshots` table stores **historical quota state** for trend analysis:
| Field | Description |
|-------|-------------|
| `apiKeyId` | The key being tracked |
| `window` | "day" | "week" | "month" |
| `used` | Cost used in this window (cents) |
| `limit` | The limit (cents) |
| `resetAt` | When the window resets |
| `createdAt` | When the snapshot was taken |
Snapshots are taken **on every request** that uses > 0 cost, and used to:
- Render the quota progress bar in the dashboard
- Show 30-day quota trend charts
- Trigger alerts when usage approaches the limit
---
## REST API
### List Usage Records
```bash
GET /api/usage?range=7d&limit=100
GET /api/usage?apiKeyId=key-123&range=30d
GET /api/usage?provider=openai&range=1d
```
Response:
```json
{
"records": [
{
"id": "uuid",
"apiKeyId": "key-123",
"provider": "openai",
"model": "gpt-5",
"promptTokens": 1234,
"completionTokens": 567,
"totalTokens": 1801,
"costUsd": 0.0050,
"latencyMs": 1234,
"status": "success",
"timestamp": "2026-06-08T12:00:00Z"
}
],
"total": 1234,
"nextCursor": "..."
}
```
### Get Analytics Summary
```bash
GET /api/usage/analytics?range=7d&groupBy=model
```
Response:
```json
{
"summary": {
"totalCost": 12.34,
"totalRequests": 5678,
"totalTokens": 12345678,
"successRate": 0.987,
"avgLatencyMs": 1234
},
"models": [
{ "model": "gpt-5", "cost": 8.50, "requests": 1234, "tokens": 4567890 },
{ "model": "claude-opus-4-6", "cost": 3.84, "requests": 234, "tokens": 234567 }
],
"daily": [
{ "date": "2026-06-01", "cost": 1.50, "requests": 800 },
{ "date": "2026-06-02", "cost": 2.00, "requests": 1000 }
]
}
```
### Query Usage Analytics
Usage data is accessed via the dashboard or MCP tools, not direct REST export endpoints. Available analytics:
- **`/api/usage/analytics`** — aggregated usage metrics (group by model, provider, key)
- **`/api/usage/quota`** — current quota status per API key
- **`/api/usage/history`** — request history logs
---
## MCP Tools
Two MCP tools expose usage data to agents (see `open-sse/mcp-server/tools/`):
| Tool | Description |
|------|-------------|
| `omniroute_cost_report` | Generates a per-key cost report for a given period |
| `omniroute_check_quota` | Returns current quota status for an API key |
Example agent invocation:
```json
{
"tool": "omniroute_cost_report",
"args": { "period": "week" }
}
```
---
## Retention and Cleanup
Usage data grows ~1-10KB per request. At scale, this can be significant.
### Retention Settings
Usage history retention is configured via the Database Settings in the UI or via `/api/settings/database`.
By default, usage history is retained for **90 days**.
### Cleanup
Old records are cleaned up by `src/lib/db/cleanup.ts`:
- Triggered by the background cron process
- Deletes records from `usage_history` older than the configured `usageHistory` retention setting
### Storage Estimation
| Request rate | 30-day storage | 90-day storage |
|--------------|----------------|----------------|
| 100 req/day | ~3MB | ~9MB |
| 1,000 req/day | ~30MB | ~90MB |
| 10,000 req/day | ~300MB | ~900MB |
| 100,000 req/day | ~3GB | ~9GB |
For very high traffic, consider:
- Reducing the retention period via Database Settings
- Using `aggregated_metrics` instead of raw records (only for analytics)
---
## Cost Optimization Tips
### 1. Use the Right Model
```bash
# Quick answer — use cheap + fast
curl -d '{"model":"auto/fast","messages":[...]}'
# Complex task — use quality
curl -d '{"model":"auto/smart","messages":[...]}'
```
### 2. Enable Caching
Anthropic prompt caching saves **90% on repeated context**:
```ts
// The caching is automatic — just include the same large system prompt
const response = await openai.chat({
model: "claude-sonnet-4-5",
system: longSystemPrompt, // Will be cached automatically
messages: [{ role: "user", content: "..." }]
});
```
### 3. Use Compression
RTK + Caveman compression saves **15-95% on tool-heavy sessions**:
```ts
const config = {
compression: {
engine: "rtk",
intensity: "aggressive"
}
};
```
### 4. Set Per-Key Quotas
Always set `quotaLimit` to prevent runaway costs:
```ts
await updateApiKey(keyId, { quotaLimit: 10_00 }); // $10/month cap
```
### 5. Audit Top Consumers
Use the dashboard or **`/api/usage/analytics`** to group by API key and sort by cost:
```bash
GET /api/usage/analytics?groupBy=apiKey
```
---
## Troubleshooting
### "Cost is higher than expected"
1. Check **`/api/usage/analytics?groupBy=model`** — find the expensive model
2. Check **`/api/usage/analytics?groupBy=apiKey`** — find the heavy consumer
3. Verify pricing data is up to date: `POST /api/pricing/sync`
### "Records missing"
- Check DB retention settings under Dashboard → Database → Cleanup — old records are deleted by the periodic cleanup task (`src/lib/db/cleanup.ts`)
- Check for errors in `src/lib/db/usage*.ts` — DB write failures are logged but not surfaced
- Verify the request actually reached `chatCore` — check combo routing
### "Quota not enforcing"
- Check the key's `quotaLimit` setting
- Verify `quotaWindow` is set correctly
- Look for `quotaSnapshots` records — they should be created on every request
---
## See Also
- [DATABASE_GUIDE.md](../ops/DATABASE_GUIDE.md) — Schema for usage tables
- [ENVIRONMENT.md](../reference/ENVIRONMENT.md#18-pricing-sync) — pricing sync env vars
- [AUTO-COMBO.md](../routing/AUTO-COMBO.md) — How `auto/fast`, `auto/cheap` reduce cost
- [API_REFERENCE.md](../reference/API_REFERENCE.md) — Full `/api/usage/*` reference
- Source: `open-sse/services/usage.ts`, `src/lib/usageAnalytics.ts`, `src/lib/db/usage*.ts`