diff --git a/.env.example b/.env.example index c881b80c40..aaedb66d1f 100644 --- a/.env.example +++ b/.env.example @@ -1625,6 +1625,12 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis # OPENCODE_API_KEY= # ─── Bifrost Go sidecar (PR-4 in #3932) ────────────────────────────────────── +# Master kill switch for the bifrost sidecar proxy. When set to 0, the +# /api/v1/relay/chat/completions/bifrost route returns 503 with the +# X-Bifrost-Killswitch header and the operator is bounced to the TS path. +# Use this to disable the sidecar without redeploying (e.g. during a +# tier-1 router incident or a key rotation). Default: 1 (sidecar active). +# BIFROST_ENABLED=1 # When BIFROST_BASE_URL is set, /api/v1/relay/chat/completions/bifrost routes # traffic to the Go gateway instead of the TS relay handler, removing TS from # the hot path. Auth/rate-limit/injection-guard stay in the route (security not @@ -1668,3 +1674,30 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis # Redis image used by the 1-click Redis launcher. Default: redis:7-alpine. # Override to redis:8-alpine or a private registry mirror as needed. # OMNIROUTE_REDIS_IMAGE= + +# ── Cluster Profile: Qdrant Vector Memory (opt-in via `docker compose --profile memory up`) ── +# Qdrant is an OPTIONAL sidecar for deployments that need cosine-distance vector +# search at >1M embeddings. The default vector store is sqlite-vec +# (src/lib/memory/vectorStore.ts:108); flip this profile on only if you hit the +# sqlite-vec ceiling or want persistent cross-replica vector state. See +# docs/architecture/cluster-decisions.md § "Qdrant (memory profile)". +# QDRANT_HOST=qdrant +# QDRANT_PORT=6333 +# QDRANT_GRPC_PORT=6334 +# QDRANT_API_KEY= +# QDRANT_COLLECTION=omniroute-memory +# QDRANT_EMBEDDING_MODEL=text-embedding-3-small +# QDRANT_VECTOR_SIZE=1536 +# QDRANT_HNSW_EF_CONSTRUCT=128 + +# ── Cluster Profile: Bifrost Tier-1 Router (opt-in via `docker compose --profile bifrost up`) ── +# Bifrost is an OPTIONAL Go-based Tier-1 router that handles the upstream-provider +# multiplexing layer. Default: OmniRoute's open-sse/executors/bifrost.ts in-process +# executor handles routing directly. Flip this profile on only if you want the +# gateway as a separate sidecar (helps in 3+ replica deployments where you want +# provider rotation centralised). See docs/architecture/cluster-decisions.md § +# "Bifrost (bifrost profile)". +# BIFROST_BASE_URL=http://bifrost:8080 +# BIFROST_API_KEY= +# BIFROST_STREAMING_ENABLED=true +# BIFROST_TIMEOUT_MS=30000 diff --git a/AGENTS.md b/AGENTS.md index 2382d21727..cf7ec1b4a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -560,6 +560,7 @@ For any non-trivial change, read the matching deep-dive first: | Electron desktop | [`docs/guides/ELECTRON_GUIDE.md`](docs/guides/ELECTRON_GUIDE.md) | | Release flow | [`docs/ops/RELEASE_CHECKLIST.md`](docs/ops/RELEASE_CHECKLIST.md) | | Quality gates (35 gates, allowlist policy) | [`docs/architecture/QUALITY_GATES.md`](docs/architecture/QUALITY_GATES.md) | +| Cluster opt-in profiles (memory, bifrost) | [`docs/architecture/cluster-decisions.md`](docs/architecture/cluster-decisions.md) | --- diff --git a/CHANGELOG.md b/CHANGELOG.md index e067317fe5..483fae6f10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,72 @@ --- +## [3.8.32] — 2026-06-20 + +### ✨ New Features + +- **feat(oauth): import accounts from CLIProxyAPI** — Settings → CLIProxyAPI now has an "Import accounts" button that reads the OAuth accounts CLIProxyAPI already saved in `~/.cli-proxy-api/` and imports them as OmniRoute connections, so you don't have to log into every account individually. CLIProxyAPI's unified auth-file format is parsed by `type` discriminator and the supported account types (Gemini, Codex, Claude/Anthropic, Antigravity, Qwen, Kimi) are upserted; unknown types are skipped. The preview never exposes tokens to the client. (thanks @powellnorma) +- **feat(routing): opt-in setting to echo the requested alias/combo name in the response model field** — Settings → Routing now has an "Echo requested model name in responses" toggle (default off). When enabled, the response `model` field (non-streaming and every streamed SSE chunk) reports the alias or combo name the client requested instead of the upstream model name, so strict clients such as Claude Desktop — which reject a response whose `model` does not match the request with a 401 — work with aliases and combos. (thanks @thaiphuong1202) +- **feat(providers): expand the openai and gemini direct registries with first-class variants already known elsewhere** — the `openai` provider entry now exposes `gpt-4.1-mini`, `gpt-4.1-nano`, `o3-mini`, and `o4-mini` (the latter two carry `REASONING_UNSUPPORTED` like `o3`), and the `gemini` entry now exposes `gemini-2.0-flash-lite` and `gemini-3-flash-lite-preview`. These models were already first-class throughout sibling subsystems (cost estimator, task fitness, free-model catalog, multiple aggregator registries) but happened to be missing from the direct openai/gemini namespaces. Embedding/TTS/image-gen models stay in their dedicated registries (`embeddingRegistry.ts`, `audioRegistry.ts`, `imageRegistry.ts`); legacy ids OmniRoute curated out (o1, gpt-4-turbo, …) are not restored. (thanks @East-rayyy) +- **feat(translator): OpenAI SSE → Gemini SSE conversion for `/v1beta/models/{model}:streamGenerateContent`** — the `@google/genai` SDK (Gemini CLI) always calls `:streamGenerateContent?alt=sse` for chat and expects Gemini SSE chunks (no `[DONE]` sentinel — the stream just closes). The v1beta route was forwarding OpenAI SSE from `handleChat` unchanged, so the SDK crashed on the OpenAI `[DONE]` line with `SyntaxError: Unexpected token 'D', "[DONE]" is not valid JSON`. A new `transformOpenAISSEToGeminiSSE()` (in `open-sse/translator/response/openai-to-gemini-sse.ts`) rewrites each OpenAI delta into `candidates[].content.parts[]`, maps `finish_reason` → `finishReason` (STOP / MAX_TOKENS / SAFETY), attaches `usageMetadata` + `modelVersion` on the final chunk, and surfaces `reasoning_content` as `{ thought: true }` parts for thinking models. The non-streaming `:generateContent` action gets a sibling `convertOpenAIResponseToGemini()` for the JSON path. Streaming intent is now keyed off the URL action suffix (canonical Gemini convention) rather than the non-standard `generationConfig.stream` body field. (thanks @SteelMorgan) +- **feat(compression): unified compression configuration panel (Phase 1)** — `/dashboard/context/settings` is now the single source of truth for compression: a master toggle plus per-engine on/off and level controls, with the dispatch pipeline derived from a stored `engines` map on `CompressionConfig`. A gate (`enginesExplicit`) ensures the new map only drives dispatch when an `engines` row was actually saved from the panel, so legacy/backfilled installs (the seeded default combo from migrations 042/043) keep their existing `defaultMode` behavior unchanged. The default-combo and per-engine routes are shimmed (410). ([#4432](https://github.com/diegosouzapw/OmniRoute/pull/4432) — thanks @diegosouzapw) +- **feat(mcp): register the web-session pool observability tools** — the `poolTools` MCP tool set (web-session pool stats/health) was defined but never wired into `createMcpServer()`, so it was dead. It is now registered in `server.ts` with `withScopeEnforcement` against the typed `read:health` / `write:resilience` scopes (no enum inflation), giving MCP clients visibility into the pooled web-session lifecycle. ([#4399](https://github.com/diegosouzapw/OmniRoute/pull/4399), [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368) — thanks @diegosouzapw) +- **feat(providers): stronger no-auth and web-cookie provider validation (`AUTH_007`)** — provider connection validation now handles no-auth and web-cookie providers explicitly: instead of returning a generic "Provider validation not supported", these providers report a precise `AUTH_007` status so the dashboard surfaces actionable validation feedback for cookie/no-auth flows. ([#4023](https://github.com/diegosouzapw/OmniRoute/pull/4023) — thanks @oyi77) +- **feat(combo): per-combo `stickyRoundRobinLimit` override on the combos page** — the round-robin sticky-affinity limit can now be set per combo from the combos page UI, overriding the global default, so a combo can pin (or loosen) how many consecutive requests stick to the same round-robin member independently of the others. ([#4472](https://github.com/diegosouzapw/OmniRoute/pull/4472) — thanks @adivekar-utexas) +- **feat(usage): quota fetch for `kimi-coding-apikey`** — usage/quota tracking now supports the `kimi-coding-apikey` provider, so its remaining quota is fetched and surfaced like the other quota-aware providers. ([#4435](https://github.com/diegosouzapw/OmniRoute/pull/4435) — thanks @janeza2) +- **feat(cluster): opt-in memory + Bifrost cluster profiles** — adds opt-in cluster profiles that wire the memory subsystem and the Bifrost Go sidecar into a clustered deployment (follow-up to #3932). ([#4433](https://github.com/diegosouzapw/OmniRoute/pull/4433) — thanks @KooshaPari) +- **feat(models): opt-in low-noise `/v1/models` catalog mode** — a new opt-in mode trims the `/v1/models` response to a quieter, lower-noise catalog for clients that choke on or don't need the full provider/model list. ([#4427](https://github.com/diegosouzapw/OmniRoute/pull/4427) — thanks @Rahulsharma0810) + +### 🐛 Fixed + +- **fix(embeddings):** forward output dimensions to Gemini for consistent embedding dims. (thanks @nguyenha935) +- **fix(translator):** sanitize Read tool args from non-Anthropic models to prevent retry loops. (thanks @GodrezJr2) +- **fix(usage):** reuse Gemini CLI project ID for quota checks (avoid re-discovery). (thanks @Delcado19) +- **fix(dashboard):** surface manual config CTA when Claude CLI detection fails (remote deployments). (thanks @anuragg-saxenaa) +- **fix(executors):** granular reasoning_effort handling for Claude models on GitHub Copilot. (thanks @baslr) +- **fix(translator):** strip Claude output_config before MiniMax (rejected upstream). (thanks @hiepau1231) +- **fix(translator): OpenAI audio input now reaches Gemini/Antigravity instead of being silently dropped** — `input_audio`/`audio` content parts on the OpenAI→Gemini path matched no handler in `convertOpenAIContentToParts` and were discarded with no error. They are now mapped to a Gemini `inlineData` part with an `audio/` mime type (wav, mp3, …). (thanks @mugnimaestra) +- **fix(combo): model lockout now honors a long upstream quota reset instead of retrying within minutes** — when a combo target returned a quota error carrying an explicit long reset (e.g. Antigravity `Resets in 160h27m24s`, a `Retry-After` header), the per-model lockout capped at the short base cooldown (~minutes) and discarded the parsed reset, so the exhausted model kept being retried far too early. The lockout now applies the parsed reset when it exceeds the base cooldown, and the Antigravity error-message parser also matches the plural `Resets in …` phrasing. (thanks @Ansh7473) +- **fix(antigravity): Claude models no longer 400 with `Unknown name "output_config"`** — Anthropic/Claude-Code-only fields (`output_config`, legacy `output_format`) leaked into the Google Cloud Code request envelope via its top-level field passthrough, and Google rejects unknown envelope fields with `400 Invalid JSON payload received. Unknown name "output_config"` — breaking every Claude model served through Antigravity in IDEs. Those fields are now dropped before the envelope is built. (thanks @Duongkhanhtool) +- **fix(combo): round-robin members fail over faster under concurrency saturation via a configurable queue depth** — when a round-robin combo member was saturated, requests sat in the per-model semaphore's **unbounded** queue and only failed over to the next member after the full `queueTimeoutMs` (default 30s) elapsed — so a burst of agentic requests deep-queued one hot member instead of spilling to healthy ones. The per-model semaphore now accepts a bounded queue depth and emits `SEMAPHORE_QUEUE_FULL` once it is full (the round-robin loop already cascades on that code), so a configured low depth fails over immediately. A new `queueDepth` combo-config knob (global default / provider override / per-combo, default **20** for backward compatibility; **0** = never queue → fail over now) is exposed in Settings → Combo Defaults. ([#3872](https://github.com/diegosouzapw/OmniRoute/issues/3872) — thanks @KooshaPari) +- **fix(pricing): align Claude Code (`cc`) pricing with current Anthropic per-MTok rates** — the `cc` provider block in the default pricing table had stale numbers across every Claude 4.x family entry — most visibly, `claude-opus-4-5-20251101` was billed at the deprecated Opus 4.1 rate (`input $15` / `output $75`), and `claude-haiku-4-5-20251001` was at half the current Haiku 4.5 rate. The `cached` (cache hit) and `cache_creation` (5-minute cache write) multipliers were also off across Opus 4.6/4.7/4.8, Sonnet 4.5/4.6, Haiku 4.5, and Fable 5. All eight entries now match the rates Anthropic publishes (input, 5m cache write at 1.25x input, cache hit at 0.1x input, output; reasoning billed at the output rate), so cost accounting on the dashboard and per-request usage events stop under- or over-reporting Claude Code spend. (thanks @chulanpro5) +- **fix(executors): sanitize Anthropic-shape content parts before GitHub Copilot `/chat/completions`** — Claude models on GitHub Copilot driven from clients like Cursor IDE (e.g. `gh/claude-sonnet-4.6`) failed with `Provider returned error: type has to be either 'image_url' or 'text' (reset after 30s)` because the client passed through Anthropic-shape content parts (`tool_use`, `tool_result`, `thinking`) untouched, and the Copilot chat-completions endpoint only accepts `text`/`image_url`. `GithubExecutor.transformRequest` now serializes any unsupported part type as `text` (preserving the model's context), drops empty parts, and collapses to `null` when an assistant message's only content was tool_calls — `tool_calls` ride alongside untouched. Codex-family models still route through `/responses` unchanged. (thanks @cngznNN) +- **fix(sse):** refactor stall detection to reduce false positives on slow but progressing streams. (thanks @zakirkun) +- **fix(executors): synthesize `x-opencode-request` for custom-named OpenCode providers** — the OpenCode CLI only emits the `x-opencode-*` header set when the provider id starts with `opencode`; a custom-named provider (e.g. `omniroute`) instead sends `x-session-affinity` / `x-session-id` (mapped to `x-opencode-session` since #4022) but no request-correlation id, so `x-opencode-request` was silently dropped. `OpencodeExecutor` now synthesizes a fresh `x-opencode-request` on that session-affinity fallback path so custom-named providers are not disadvantaged on the opencode.ai upstream. `x-opencode-client` / `x-opencode-project` are intentionally **not** fabricated (no valid client source — an invented value risks upstream rejection) and remain forward-only; `DefaultExecutor` is untouched. ([#4465](https://github.com/diegosouzapw/OmniRoute/issues/4465) — thanks @pizzav-xyz) +- **fix(compression): RTK now compresses Anthropic-shape `tool_result` blocks** — `applyRtkCompression` only compressed OpenAI-shape tool results (`role:"tool"`); Anthropic-shape tool results (`tool_result` content blocks inside a `role:"user"` message) were skipped, so coding agents speaking the Anthropic Messages format got zero RTK savings even though RTK's command-aware filters (e.g. `git-status`) would have compressed the output. RTK now treats a message containing a `tool_result` block as eligible (gated by `applyToToolResults`), captures Anthropic `tool_use` blocks for command resolution, and compresses each block's inner text (string or nested text-block array) while preserving `type` + `tool_use_id` exactly — matching what `caveman`/`aggressive` already did. ([#4468](https://github.com/diegosouzapw/OmniRoute/pull/4468) — thanks @diegosouzapw) +- **fix(dashboard): request-log auto-refresh no longer dies from a "ghost" load-more on first page load** — the request-log viewer's infinite-scroll `IntersectionObserver` uses a 200px rootMargin, so its sentinel was already intersecting on mount whenever the first page didn't fill the scroll container. That fired a `loadMore()` with no user interaction, growing the window past `PAGE_SIZE` — and auto-refresh only polls while on the first page (`limit <= pageSize`), so it stayed permanently paused (only a manual filter change re-armed it). The observer now grows the window only after a genuine user scroll (new pure `shouldTriggerInfiniteScroll` guard), and a filter change re-arms the guard, so the default first-page view resumes its ~10s auto-refresh. ([#4269](https://github.com/diegosouzapw/OmniRoute/issues/4269) — thanks @tjengbudi) +- **fix(sse): large `/v1/chat/completions` requests no longer crash the server with a Node heap OOM** — the chat request body was parsed multiple times along the route (route guard, injection guard, handler), buffering very large payloads several times and pushing concurrent agentic traffic into an out-of-memory crash. The body is now parsed **once** at the route guard and threaded through, so each request is buffered a single time. ([#4380](https://github.com/diegosouzapw/OmniRoute/issues/4380) — thanks @NakHalal) +- **fix(guardrails): tighten the `system_prompt_leak` heuristic to stop false positives on agent traffic** — the leak detector flagged normal agent/tool conversations as prompt-leak attempts; it now requires an additional qualifier before flagging, so legitimate agent traffic is no longer blocked. ([#4041](https://github.com/diegosouzapw/OmniRoute/issues/4041) — thanks @KooshaPari) +- **fix(translator): drop orphan tool results on the Claude→OpenAI request path** — a `tool_result` with no preceding matching `tool_use` (orphan) produced upstream 500/502 errors for Command Code / Custom OpenAI clients on ≥3.8.26. Orphan tool results are now filtered before the request is sent. ([#4385](https://github.com/diegosouzapw/OmniRoute/issues/4385) — thanks @adityapnusantara) +- **fix(providers): register API-key validators for Firecrawl and Jina Reader** — both providers returned "Provider validation not supported" when validating their API key; they now have proper validators registered in `SEARCH_VALIDATOR_CONFIGS`. ([#4401](https://github.com/diegosouzapw/OmniRoute/issues/4401) — thanks @ponkcore) +- **fix(providers): generic web-cookie validator must not shadow per-provider validators** — a follow-up to the `AUTH_007` validation work (#4023): the generic web-cookie validator was matching before more specific per-provider validators, so provider-specific validation was skipped. Validator resolution now prefers the per-provider validator. ([#4467](https://github.com/diegosouzapw/OmniRoute/pull/4467) — thanks @diegosouzapw) +- **fix(translator): inject a placeholder message when the Responses API `input[]` is empty** — a `POST /v1/responses` with `input: []` translated to `messages: []`, which every upstream Chat-Completions provider rejects (surfaced as a confusing 406); a single placeholder user message is now injected, mirroring the existing empty-string handling. ([#4393](https://github.com/diegosouzapw/OmniRoute/pull/4393) — thanks @diegosouzapw) +- **fix(providers): serve the api.airforce live `/models` catalog instead of the stale seed** — the api.airforce provider listed a stale hard-coded seed; it now serves the upstream live `/models` catalog. ([#4395](https://github.com/diegosouzapw/OmniRoute/pull/4395) — thanks @diegosouzapw) +- **fix(cli): non-interactive-safe prompts + `context` alias** — the CLI's `confirm()`/prompt helpers no longer hang in non-interactive (piped/CI) contexts, and a singular `context` alias is accepted alongside `contexts`; the contexts workflow is documented. ([#4439](https://github.com/diegosouzapw/OmniRoute/pull/4439), [#4397](https://github.com/diegosouzapw/OmniRoute/pull/4397) — thanks @diegosouzapw) +- **fix(cli): `omniroute update` no longer reports a stale "latest" version from npm's cache** — `getLatestVersion()` ran `npm view omniroute version` without `--prefer-online`, so npm could serve a cached value from its HTTP cache and tell users on an older build (e.g. 3.8.30) they were already "running the latest version" even after a newer one (3.8.31) was published. The version check now passes `--prefer-online` to force npm to revalidate against the registry. ([#4376](https://github.com/diegosouzapw/OmniRoute/issues/4376) — thanks @akbardwi) +- **fix(sse): `web_search_20250305` no longer 400s on MiniMax's Anthropic-compatible endpoint** — PR #2960 added a Claude→Claude bypass that forwards Anthropic's typed server tool `web_search_20250305` untouched, assuming the Claude-format upstream implements Anthropic server tools. MiniMax's `/anthropic` endpoint does not, so `claude → minimax` requests carrying that tool got `HTTP 400 "invalid params, function name or parameters is empty (2013)"`. `supportsNativeWebSearchFallbackBypass` now consults the (already-plumbed) `provider` and excludes providers known not to implement server tools (currently `minimax`) from the bypass, so the built-in web-search tool is converted to the `omniroute_web_search` function fallback — which MiniMax accepts as a normal function tool. ([#4481](https://github.com/diegosouzapw/OmniRoute/issues/4481) — thanks @shafqatevo) +- **fix(command-code): pass `reasoning` / `thinking` fields through to upstream params** — Command Code requests carrying `reasoning`/`thinking` controls had those fields dropped before the upstream call, so reasoning-effort and extended-thinking settings were silently ignored; they are now forwarded to the upstream params. ([#4473](https://github.com/diegosouzapw/OmniRoute/pull/4473) — thanks @adivekar-utexas) +- **fix(usage): keep Kiro overage-enabled accounts routable after base quota hits zero** — a Kiro account with overage enabled was excluded from routing once its base quota reached zero, even though overage billing should keep it serving; such accounts now stay routable past base-quota exhaustion. ([#4469](https://github.com/diegosouzapw/OmniRoute/issues/4469) — thanks @heaven321357 / @CleanDev-Fix) +- **fix(providers): model-aware `supportsRedactedThinking` for mixed-format providers** — the redacted-thinking capability was resolved per provider rather than per model, so a mixed-format provider (some models support redacted thinking, others don't) got the wrong answer for some models; the check is now model-aware. ([#4479](https://github.com/diegosouzapw/OmniRoute/pull/4479) — thanks @TF0rd) + +### 🔒 Security + +- **fix(sse): use a crypto-secure RNG for combo/deck load-balancing selection** — random combo/deck member selection used a non-cryptographic PRNG, flagged by CodeQL (`#665`); it now uses a crypto-secure RNG. ([#4457](https://github.com/diegosouzapw/OmniRoute/pull/4457) — thanks @diegosouzapw) +- **fix(sse): unbiased `crypto.randomInt` for combo selection (follow-up to #4457)** — the initial crypto-secure conversion used modulo reduction over the secure bytes, which introduces a small modulo bias; selection now uses `crypto.randomInt` (rejection sampling) for a uniform, unbiased distribution across combo/deck members. ([#4462](https://github.com/diegosouzapw/OmniRoute/pull/4462) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(chatCore):** extract `resolveChatCoreRequestSetup` (first setup-phase slice) toward modularizing the chatCore god-file. ([#4392](https://github.com/diegosouzapw/OmniRoute/pull/4392) — thanks @diegosouzapw) +- **refactor(chatCore):** extract the Codex service-tier resolvers into a pure `chatCore/serviceTier.ts` leaf (continues the god-file split). ([#4477](https://github.com/diegosouzapw/OmniRoute/pull/4477), [#3501](https://github.com/diegosouzapw/OmniRoute/issues/3501) — thanks @diegosouzapw) +- **perf(dashboard):** lazy-load the usage analytics charts so the dashboard's initial bundle/paint is lighter (charts hydrate on demand). ([#4466](https://github.com/diegosouzapw/OmniRoute/pull/4466) — thanks @KooshaPari) +- **perf(kiro):** cut request-completion hot-path CPU and cap the DB-lock event-loop block so Kiro request completion does not stall the event loop under load. ([#4459](https://github.com/diegosouzapw/OmniRoute/pull/4459) — thanks @artickc) +- **fix(catalog):** restore-green — add OpenAI `gpt-4.1-mini`/`gpt-4.1-nano` + `o3-mini`/`o4-mini` pricing rows to keep the static-parity gate green after the registry expansion (#4394), plus the web-cookie validator shadowing fix. ([#4447](https://github.com/diegosouzapw/OmniRoute/pull/4447) — thanks @diegosouzapw) +- **chore(quality):** reconcile file-size + complexity baselines after the `/review-prs` round, and the `server.ts` file-size baseline after the pool-tools registration (#3368). ([#4461](https://github.com/diegosouzapw/OmniRoute/pull/4461), [#4423](https://github.com/diegosouzapw/OmniRoute/pull/4423) — thanks @diegosouzapw) +- **docs(remote-mode):** add a copy-paste end-to-end verification example. ([#4430](https://github.com/diegosouzapw/OmniRoute/pull/4430) — thanks @diegosouzapw) +- **docs:** add operational documentation (usage/quota, database, open-sse architecture, monitoring). ([#3455](https://github.com/diegosouzapw/OmniRoute/pull/3455) — thanks @oyi77) + +--- + ## [3.8.31] — 2026-06-20 ### ✨ New Features @@ -31,6 +97,9 @@ - **fix(api): persist `max_input_tokens` / `max_output_tokens` when adding a custom model** — `POST /api/provider-models` silently dropped the per-model token limits set in the "add custom model" form: the handler destructured the rest of the body but never read `max_input_tokens` / `max_output_tokens`, and `addCustomModel()` had no parameter for them, so the values were thrown away on write. The DB layer (`inputTokenLimit` / `outputTokenLimit`) and the `/v1/models` catalog already round-trip these fields — only the write path was missing. The validation schema now accepts the two optional limits, the handler forwards them, and `addCustomModel()` persists them so a custom model's context/output window survives into the catalog. ([#4349](https://github.com/diegosouzapw/OmniRoute/pull/4349) — thanks @codename-zen) - **fix(plugin): the OpenCode static-catalog plugin prefixes combo/raw model keys with the provider id** — OpenCode's static-catalog reader misdetected the `omniroute` provider: combo keys emitted as `combo/MASTER` were parsed as provider `combo` ("No credentials for provider: omniroute"), while a bare-`MASTER` form was misread as a model with no resolvable provider, and mixed `omniroute/MASTER` + bare-raw keys were rejected by OpenCode's schema. The plugin now emits every combo and raw model key prefixed with the `omniroute` provider id, emits the provider id explicitly, and drops the legacy `combo/` prefix — so the static-catalog reader detects the provider and the auth loader returns the right credentials (the catalog-fetch timeout was also raised so a cold-start server doesn't publish an empty stub). ([#4384](https://github.com/diegosouzapw/OmniRoute/pull/4384) — thanks @herjarsa) +- **fix(translator): inject placeholder message when Responses API input[] is empty (prevents upstream 400)** — a client (e.g. Fabric-AI) calling `POST /v1/responses` with `input: []` used to be translated into `messages: []`, which every upstream Chat-Completions provider rejects with `400: at least one message is required` (surfaced to the client as a confusing 406). The translator now treats an empty `input[]` the same as an empty string — a placeholder user message is injected so the request is always valid. (thanks @anuragg-saxenaa) +- **fix(embeddings): NVIDIA NIM asymmetric embedding models inject the required `input_type`** — NVIDIA NIM asymmetric embedders (e.g. `nvidia/nv-embedqa-e5-v5`) reject requests without an `input_type` parameter with `400 "'input_type' parameter is required"`, but OmniRoute only forwarded `input_type` when the client supplied it — so callers (and OpenAI-style SDKs that don't emit the field) got a hard failure. The embedding registry now carries a model-level default (`input_type: "query"`) for the asymmetric NVIDIA model, and the embeddings handler injects a model's default params into the upstream body **only** when the client didn't already send them — a client-supplied `input_type` (e.g. `"passage"`) is respected unchanged, and symmetric models that carry no default are unaffected. (thanks @hydraromania) + ### 🔒 Security - **fix(security): scope the OAuth callback `postMessage` to a trusted-origin allowlist** — the OAuth callback at `/callback` previously posted `{ code, state, … }` to `window.opener.postMessage(…, "*")` whenever the opener was cross-origin, so a hostile page that opened the well-known redirect URI in a popup could receive the OAuth code/state and complete the flow as the user. The wildcard fallback is replaced with iteration over a fixed allowlist (same-origin + Codex's `localhost:1455` / `127.0.0.1:1455` loopback helper); the browser silently drops `postMessage` to any opener whose origin isn't listed. ([#4372](https://github.com/diegosouzapw/OmniRoute/pull/4372) — ported from 9router#998, thanks @aeonframework / @diegosouzapw) diff --git a/README.md b/README.md index b6c411fd28..fc4d88b5e1 100644 --- a/README.md +++ b/README.md @@ -287,17 +287,17 @@ Result: 4 layers of fallback = zero downtime -> Recent highlights from **v3.8.20 → v3.8.31**. Full history in [`CHANGELOG.md`](CHANGELOG.md). +> Recent highlights from **v3.8.20 → v3.8.32**. Full history in [`CHANGELOG.md`](CHANGELOG.md). - **🤖 One-command CLI/agent setup** — a dedicated `setup-*` command configures each coding tool to route through OmniRoute (Claude Code, Codex, Cline, Continue, Cursor, Roo Code, Kilo Code, Crush, Goose, Qwen Code, Aider, OpenCode, Gemini CLI); `omniroute launch` / `omniroute launch-codex` are zero-config launchers. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md) - **🛰️ Remote mode** — drive a remote OmniRoute from any machine with scoped access tokens (`omniroute connect` / `omniroute contexts` / `omniroute tokens`). → [Remote Mode](docs/guides/REMOTE-MODE.md) - **🧭 Smarter auto-routing** — OpenRouter-style `auto/:` combos (e.g. `auto/coding:fast`, `auto/reasoning:pro`), live Arena-ELO + models.dev model intelligence, and per-step account allowlists. → [Auto-Combo](docs/routing/AUTO-COMBO.md) -- **🗜️ Pluggable compression** — an async compression pipeline with Compression Studios, a stable LLMLingua-2 ONNX engine, RTK, and delegated Anthropic Context Editing. → [Compression](docs/compression/COMPRESSION_ENGINES.md) +- **🗜️ Pluggable compression** — an async compression pipeline with Compression Studios, a stable LLMLingua-2 ONNX engine, RTK, delegated Anthropic Context Editing, and a unified settings panel that is the single source for per-engine on/off + level. → [Compression](docs/compression/COMPRESSION_ENGINES.md) - **🕵️ Transparent MITM decrypt (TPROXY)** — capture & translate traffic from CLIs that ignore proxy env vars, with a per-SNI certificate authority and a trust-store installer. → [MITM/TPROXY](docs/security/MITM-TPROXY-DECRYPT.md) - **💸 Cost telemetry everywhere** — `X-OmniRoute-*` cost/usage headers on every endpoint (including media), a non-token cost engine, a cache-HIT `X-OmniRoute-Cost-Saved` header, and per-key USD spend quotas. → [API Reference](docs/reference/API_REFERENCE.md) - **🧠 Memory you control** — opt-in int8 vector quantization (Qdrant + sqlite-vec), memory off by default, and a per-request `x-omniroute-no-memory` header. → [Memory](docs/frameworks/MEMORY.md) - **🛡️ Security** — a prompt-injection guard across every LLM route (backed by a red-team suite), plus a free DuckDuckGo last-resort web search. → [Guardrails](docs/security/GUARDRAILS.md) -- **🤝 More providers & agents** — Cursor Cloud Agent (a 4th cloud agent), a refreshed 231-provider catalog (OrcaRouter, Wafer AI, OpenAdapter, dit.ai, TokenRouter, …), and Vertex AI media generation (speech / transcription / music / video). → [Providers](docs/reference/PROVIDER_REFERENCE.md) +- **🤝 More providers & agents** — Cursor Cloud Agent (a 4th cloud agent), a refreshed 231-provider catalog (OrcaRouter, Wafer AI, OpenAdapter, dit.ai, TokenRouter, …), Vertex AI media generation (speech / transcription / music / video), and one-click account import from CLIProxyAPI (`~/.cli-proxy-api/`). → [Providers](docs/reference/PROVIDER_REFERENCE.md) - **⚡ Local performance & infra** — a one-click local Redis launcher (`omniroute redis up`, plus a dashboard Redis panel) and an optional Bifrost Go sidecar that offloads the hottest relay path (`BIFROST_BASE_URL`, with automatic fallback to the TypeScript path on timeout). → [Environment](docs/reference/ENVIRONMENT.md)
@@ -439,6 +439,7 @@ omniroute connect 192.168.0.15 # password → scoped token, saved as omniroute models list # ← runs against the REMOTE server omniroute configure codex # ← picks a remote model, writes a local Codex profile omniroute tokens create --name ci --scope read # mint narrower tokens for other machines +omniroute contexts use default # ← switch back to the local server ``` Tokens are scoped `read` / `write` / `admin`; process-spawning routes stay loopback-only. @@ -876,14 +877,14 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo ### 📘 Getting Started -| Document | Description | -| ---------------------------------------------- | ----------------------------------------------------------------------------- | -| [User Guide](docs/guides/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | -| [Setup Guide](docs/guides/SETUP_GUIDE.md) | Full install methods, CLI tool configs, protocol setup, timeout tuning | -| [CLI Tools Guide](docs/reference/CLI-TOOLS.md) | Per-tool setup for Claude Code, Codex, Cursor, Cline, OpenClaw, Kilo, Copilot | -| [Remote Mode](docs/guides/REMOTE-MODE.md) | Drive a remote OmniRoute (VPS) from your laptop CLI via scoped access tokens | +| Document | Description | +| -------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| [User Guide](docs/guides/USER_GUIDE.md) | Providers, combos, CLI integration, deployment | +| [Setup Guide](docs/guides/SETUP_GUIDE.md) | Full install methods, CLI tool configs, protocol setup, timeout tuning | +| [CLI Tools Guide](docs/reference/CLI-TOOLS.md) | Per-tool setup for Claude Code, Codex, Cursor, Cline, OpenClaw, Kilo, Copilot | +| [Remote Mode](docs/guides/REMOTE-MODE.md) | Drive a remote OmniRoute (VPS) from your laptop CLI via scoped access tokens | | [Claude Code Config](docs/guides/CLAUDE-CODE-CONFIGURATION.md) | Point Claude Code at OmniRoute (local/remote) with `launch` + per-model profiles | -| [Quick Start](README.md#-quick-start) | 3-step install → connect → configure | +| [Quick Start](README.md#-quick-start) | 3-step install → connect → configure | ### 🔧 Operations & Deployment diff --git a/bin/cli/commands/contexts.mjs b/bin/cli/commands/contexts.mjs index 00f565ed96..e40b9ac2ee 100644 --- a/bin/cli/commands/contexts.mjs +++ b/bin/cli/commands/contexts.mjs @@ -9,7 +9,15 @@ function authLabel(c) { return "✗"; } -async function confirm(msg) { +export async function confirm(msg) { + // Non-interactive stdin (pipe, CI, EOF) cannot answer a [y/N] prompt. Asking + // anyway leaves the readline question pending forever — Node then warns about an + // "unsettled top-level await" at exit. Decline cleanly instead and point at the + // non-interactive escape hatch so scripted callers fail safe rather than hang. + if (!process.stdin.isTTY) { + process.stderr.write(`${msg} [y/N] (non-interactive stdin — declined; pass --yes to confirm)\n`); + return false; + } const readline = await import("node:readline"); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const answer = await new Promise((r) => rl.question(`${msg} [y/N] `, r)); @@ -26,6 +34,7 @@ function maskKey(k) { export function registerContexts(program) { const ctx = program .command("contexts") + .alias("context") // singular alias — docs/connect output historically said `context current` .description(t("config.contexts.description") || "Manage server contexts/profiles"); ctx diff --git a/bin/cli/commands/update.mjs b/bin/cli/commands/update.mjs index f5c696e09d..443f9a498b 100644 --- a/bin/cli/commands/update.mjs +++ b/bin/cli/commands/update.mjs @@ -25,9 +25,13 @@ export async function getCurrentVersion() { } } -async function getLatestVersion() { +// `--prefer-online` forces npm to revalidate its HTTP cache against the registry. +// Without it `npm view` can return a stale cached version (e.g. report 3.8.30 as +// "latest" after 3.8.31 was published), so the updater told users on an old build +// they were already on the latest version (#4376). `execFn` is injectable for tests. +export async function getLatestVersion(execFn = execFileAsync) { try { - const { stdout } = await execFileAsync("npm", ["view", "omniroute", "version"], { + const { stdout } = await execFn("npm", ["view", "omniroute", "version", "--prefer-online"], { timeout: 15000, }); return stdout.trim(); diff --git a/bin/cli/io.mjs b/bin/cli/io.mjs index 97b419dc77..b72f7c4310 100644 --- a/bin/cli/io.mjs +++ b/bin/cli/io.mjs @@ -6,20 +6,43 @@ export function createPrompt() { output: process.stdout, }); + // Non-interactive stdin (pipe, CI, EOF via `< /dev/null`) cannot answer an + // interactive prompt. Without a guard, `rl.question` never fires its callback — + // the await stays pending and Node warns about an "unsettled top-level await" at + // exit. Resolving on the readline `close` event (which fires on stdin EOF) + // returns the default/empty instead of hanging. A genuinely piped line still + // arrives via the question callback first, so `echo value | omniroute …` keeps + // working — only the no-input EOF case falls back. function ask(question, defaultValue = "") { const suffix = defaultValue ? ` (${defaultValue})` : ""; return new Promise((resolve) => { + let settled = false; + const done = (v) => { + if (!settled) { + settled = true; + resolve(v); + } + }; + rl.once("close", () => done(defaultValue)); rl.question(`${question}${suffix}: `, (answer) => { const trimmed = answer.trim(); - resolve(trimmed || defaultValue); + done(trimmed || defaultValue); }); }); } function askSecret(question) { return new Promise((resolve) => { - let prompted = false; + let settled = false; const saved = rl._writeToOutput.bind(rl); + const done = (v) => { + if (!settled) { + settled = true; + rl._writeToOutput = saved; + resolve(v); + } + }; + let prompted = false; rl._writeToOutput = function (str) { if (!prompted) { rl.output.write(str); @@ -29,9 +52,9 @@ export function createPrompt() { // Suppress character echo; allow only newlines through if (str === "\r\n" || str === "\n" || str === "\r") rl.output.write("\n"); }; + rl.once("close", () => done("")); // non-interactive EOF → empty secret, no hang rl.question(`${question}: `, (answer) => { - rl._writeToOutput = saved; - resolve(answer.trim()); + done(answer.trim()); }); }); } diff --git a/config/quality/complexity-baseline.json b/config/quality/complexity-baseline.json index de978287a7..d3340fc50e 100644 --- a/config/quality/complexity-baseline.json +++ b/config/quality/complexity-baseline.json @@ -1,6 +1,7 @@ { "_comment": "Catraca de complexidade (check-complexity.mjs, ESLint core rules complexity>=15 e max-lines-per-function>80 sobre src+open-sse+electron+bin via eslint.complexity.config.mjs). Conta total de violacoes; so pode cair. --update ratcheta.", - "count": 1900, + "count": 1905, + "_rebaseline_2026_06_20_reviewprs_mine_r2": "Reconciliacao release-volatil 1900->1905 (+5) apos o lote /review-prs 'apenas minhas' r2 (17 PRs meus mergeados em release/v3.8.32). Breakdown medido com `node scripts/check/check-complexity.mjs` + diff por-arquivo eslint complexity JSON entre o tip pre-lote (9052c5a78 = 1902) e o tip pos-lote (1905): (a) +2 JA latentes no tip pre-lote (1900->1902), drift de merges concorrentes de outras sessoes ANTERIORES a este lote (nao introduzidos por mim) que o fast-path do release nao rebaselina (check:complexity so roda release->main); (b) +3 deste lote, isolados em DOIS arquivos: open-sse/translator/helpers/geminiHelper.ts +1 (convertOpenAIContentToParts passou de 80->93 linhas pela branch de audio do #4426 — max-lines-per-function, funcao de dispatch coesa por tipo de content part) e open-sse/translator/response/openai-to-gemini-sse.ts +2 (translator SSE NOVO do #4453, openAIChunkToGeminiChunk + convertOpenAIResponseToGemini em complexity 19 cada — conversores de chunk SSE inerentemente ramificados, levemente acima de 15). Crescimento de feature legitimo recem-TDD'd, nao regressao; refatorar feature recem-mergeada so para raspar +3 seria over-engineering arriscado. Reducao fica como debt.", "_rebaseline_2026_06_20_reviewprs_v3831_batch": "Reconciliacao release-volatil 1896->1900 (+4): drift do lote /review-prs v3.8.31 (25 PRs A+B+C + merges concorrentes). Condicionais NOVOS legitimos — sobretudo #4381 (rotas /api/local/redis/{start,stop,status} detectRuntime + guardas + bifrost relay) e #4366 (classificacao de exhaustion de erro entre os 2 dispatchers de combo). Medido 1900 ESTAVEL em fd1391c0b E f46c69f2a com `node scripts/check/check-complexity.mjs` (commit concorrente intermediario foi complexity-neutro). O fast-path do release nao roda check:complexity (so release->main), entao o ramo acumula sem rebaselinar. Mesma familia dos rebaselines anteriores — crescimento de feature legitimo, nao regressao; valor final reconciliado no release->main.", "_rebaseline_2026_06_20_postlote_concurrent_drift": "Reconciliacao release-volatil: 1895->1896 (+1). Drift de condicional NOVO de PRs mergeados pela sessao concorrente APOS o #4338 ratchetar para 1895 (#4355 pricing gpt-5.x-pro / #4364 cli active-context cred / #4363 compliance cleanup / #4358 mitm mask / #4332 injection-guard-16KB). O fast-path do release nao roda check:complexity (so release->main), entao o ramo acumula sem rebaselinar. Medido com `node scripts/check/check-complexity.mjs` no tip cdfd71c17. Mesma familia — crescimento de feature legitimo, nao regressao.", "_note_2026_06_20_4371_chatcore_heap_leaf": "PR #4371 (extract checkHeapPressureGuard leaf, god-file decomposition start) e complexity-NEUTRO: handleChatCore so PERDE codigo e o novo heapPressure.ts checkHeapPressureGuard fica sob o teto — a contagem permanece 1896 (medido no tip mesclado com check-complexity.mjs).", diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 1255745c96..7a537c1b3e 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,5 +1,17 @@ { "_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.", + "_rebaseline_2026_06_21_4475_target_format_badge": "PR #4475 (adivekar-utexas) review: src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts 955->974 (+19). Extracted the pure targetFormatBadgeI18nKey (the 6 targetFormat value->i18n-key mapping) out of the CustomModelsSection.tsx badge so it is unit-testable outside the .tsx (Rule #18 gap — the PR had no UI test). The .tsx now calls the helper instead of an inline if-chain. This leaf is the strangler-fig home for pure provider-page helpers (#3501), so receiving the extraction is on-purpose. Covered by tests/unit/provider-target-format-badge-4475.test.ts.", + "_rebaseline_2026_06_21_4427_low_noise_catalog": "PR #4427 (Rahulsharma0810) own growth: src/app/api/v1/models/catalog.ts 1478->1486 (+8). The opt-in MODELS_CATALOG_PREFIX_MODE (dual default | alias | canonical, with ?prefix= per-request override) gates the dual alias+canonical model emission at the three /v1/models push sites (static, synced, custom) behind includeAlias/includeCanonical, suppressing the duplicate cross-prefix entries (net +3 from the feature). On review, 4 incidental explanatory comments removed by the PR were restored (synced-models resolve, skip-static, try-block intent, strip-modelIdPrefix; +5) since their code is unchanged — useful docs on a non-trivial catalog function. Default `dual` keeps byte-identical output; request-side alias resolution unchanged. Structural shrink of this route tracked in #3789. Covered by tests/unit/models-catalog-low-noise-flag.test.ts.", + "_rebaseline_2026_06_21_qg9_chatcore_service_tier": "QG v2 Fase 9 T5 (#3501) — chatCore.ts 5137->5110 (shrink -27). The two inline Codex service-tier resolvers (resolveEffectiveServiceTier / resolveReportedServiceTier, ~36 LOC) were extracted byte-identically into the new pure leaf open-sse/handlers/chatCore/serviceTier.ts; the handler now keeps a `let effectiveServiceTier` + two thin binding closures that pass provider/credentials?.providerSpecificData to the extracted functions, so every call site stays unchanged. The orphaned getCodexRequestDefaults/normalizeCodexServiceTier/CodexServiceTier imports moved to the leaf. Ratcheted the frozen value down to lock the freed budget. Covered by tests/unit/chatcore-service-tier.test.ts.", + "_rebaseline_2026_06_20_reviewprs_mine_r2_filesize": "Reconciliacao file-size pos-lote /review-prs 'apenas minhas' r2: dois frozen cresceram cumulativamente sem bump (cada PR media OK na sua base, mas o crescimento empilhou acima do frozen no tip de merge; o fast-path do release nao roda check:file-size, so release->main). (1) src/shared/constants/pricing.ts 1620->1623 (+3 = linhas de pricing Claude Code (cc) do #4440, sobre o 1620 que o #4447 ja setara para gpt-4.1-mini/nano + o3/o4-mini). (2) open-sse/executors/base.ts 1399->1407 (+8 = handling granular de reasoning_effort para Claude no Copilot do #4443). Ambos dados/wiring coesos nos chokepoints existentes; nao extraiveis. Cobertos por tests/unit (claude-code pricing / base-executor-sanitize-effort + github-claude-reasoning-effort-granular).", + "_rebaseline_2026_06_20_4023_web_cookie_noauth_validation": "PR #4023 (oyi77) own growth: src/lib/providers/validation.ts 4450->4518 (+68 = a new validateWebCookieProvider that probes the provider's /models endpoint — 401/403 => AUTH_007 SESSION_EXPIRED, any other status => valid session, empty cookie => invalid, provider-not-in-registry => unsupported — plus a local STANDARD_USER_AGENT const for the probe). Cohesive validator at the validateProviderApiKey dispatch; not extractable. Covered by tests/unit/provider-validation-web-cookie-auth007.test.ts. Heavily curated on merge — the PR's branch was badly stale-based (squash-base-stale), so its tree was DESTRUCTIVE: providers/index.ts deleted live providers openadapter/dit/tokenrouter (added by #4313) and the executor/base.ts edits reverted release fixes (#4037 duckduckgo host, theoldllm gpt5 models, base.ts fetch-start-timeout). Only the purely-additive validation feature was kept (validation.ts validateWebCookieProvider + errorCodes AUTH_007 + the test). Dropped: 5 malformed new registry entries (used non-RegistryEntry fields defaultModel/auth + referenced non-existent executors -> tsc TS2353), the destructive providers/index.ts + executor reverts, the unrelated pr-*.sh automation scripts, and evals/types.ts (belongs to the deferred evals modularization #4422). Also removed the PR's fragile 'Phase 2' executor probe (ran a live upstream chat during validation + classified any 'auth'-containing error as SESSION_EXPIRED) and rewrote the test to install its fetch mock before module load (the original mocked too late and silently hit live chatgpt.com).", + "_rebaseline_2026_06_20_1308_model_lockout_honors_reset": "port from 9router#1308 own growth: open-sse/services/accountFallback.ts 1731->1752 (+21 = the new exported pure helper selectLockoutCooldownMs + its doc comment — picks the parsed upstream reset as the model-lockout exactCooldownMs when it exceeds the base cooldown, e.g. Antigravity \"Resets in 160h\", else preserves the existing 0/base behavior) and open-sse/executors/antigravity.ts 1680->1686 (this PR +1 = parseRetryFromErrorMessage regex `reset` -> `resets?` so plural \"Resets in 160h27m24s\" matches, plus a comment line; frozen set to the SUM 1686 with the concurrent #1944 which adds +5 at the disjoint passthroughFields region of the same file, so either merge order passes — pair-file rule). The combo lockout call sites in combo.ts now pass selectLockoutCooldownMs(cooldownMs, mlSettings) instead of always base/exponential, so an exhausted model honors the real upstream reset instead of being retried within minutes. Both edits are cohesive at the existing lockout/parse chokepoints; the helper is its own pure function (not extractable further). Covered by tests/unit/combo-model-lockout-honors-reset-1308.test.ts.", + "_rebaseline_2026_06_20_1944_antigravity_strip_output_config": "port from 9router#1944: open-sse/executors/antigravity.ts frozen set to the measured cumulative 1687 of two concurrent PRs that touch disjoint regions of this file, so either merge order passes (pair-file rule). #1944 adds +6 at the envelope passthroughFields destructuring (~line 759: drop output_config/output_format — Anthropic/Claude-Code-only fields that Google's Cloud Code envelope rejects with `400 Unknown name \"output_config\"`, which broke every Claude model on Antigravity); #1308 adds +1 at parseRetryFromErrorMessage (~line 889: regex reset->resets?). Base 1680 + 6 + 1 = 1687 (re-measured on the real merge tip — the earlier 1686 estimate was off by one). Both edits are cohesive at their chokepoints; not extractable. Covered by tests/unit/antigravity-strip-output-config-1944.test.ts.", + "_rebaseline_2026_06_20_1311_echo_requested_model": "port from 9router#1311 own growth: src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx 1580->1594 (+14 = one Settings → Routing card with a Toggle bound to the opt-in `echoRequestedModelName` setting, mirroring the adjacent LKGP/auto-routing toggles). When enabled, the response `model` field echoes the client-requested alias/combo name instead of the upstream model, so strict clients (Claude Desktop) that validate response.model === request.model stop rejecting with 401. The actual rewrite logic lives in the new pure leaf open-sse/services/responseModelEcho.ts (5137 reconciled after merging the current release base (base grew chatCore 5095->5120 via parallel merges; +~17 for the echoModel read + the two wiring chokepoints). The card is cohesive presentational wiring next to the existing toggles; not extractable. Covered by tests/unit/response-model-echo-1311.test.ts.", + "_rebaseline_2026_06_20_3368_pool_tools": "PR #3368 own growth: open-sse/mcp-server/server.ts 1468->1509 (+41 = register the web-session pool observability tools — poolStatus/poolSessions/poolWarm — at the existing createMcpServer registration chokepoint, with their read:health/write:resilience scope wiring). Reconciled late: the server.ts bump was a separate commit that did not ride along when the feature commit was cherry-picked onto release/v3.8.32 (squash-base-stale rebuild). Thin cohesive registration at the single tool-registration boundary; not extractable. Covered by tests/unit/mcp-pool-tools-3368.test.ts.", + "_rebaseline_2026_06_20_4401_webfetch_validators": "PR #4401 own growth: src/lib/providers/validation.ts 4428->4450 (+22 = two new API-key validators — Firecrawl + Jina Reader — each a cohesive provider validator branch mirroring the existing ones; 401/403->invalid else->valid). Not extractable (it IS the per-provider validator list). Covered by tests/unit/provider-validation-webfetch-4401.test.ts.", + "_rebaseline_2026_06_20_4380_parse_once": "PR #4380 own growth: src/sse/handlers/chat.ts 1486->1491 (+5 = thread the once-parsed request body from the route guard into handleChat, replacing the duplicate re-parse). The reusable body accessor lives in the new src/sse/handlers/requestBody.ts (2590 (+4 = one NAMED_OPENAI_STYLE_PROVIDERS Set entry `api-airforce` + a 3-line comment). Same fix shape as the provider-sweep rows (#4249/#4202/#3976): api-airforce carries a real live `https://api.airforce/v1/models` catalog but was left out of the sweep, so import served its stale hardcoded seed (grok-3/grok-2-1212/claude-3.7-sonnet …) — models that no longer exist upstream, so chat failed though the connection test still passed. The `/models` probe (after stripping /chat/completions and a trailing /v1) resolves to https://api.airforce/v1/models; the registry seed stays as the offline fallback so import never breaks. Pure additive Set membership; not extractable (it IS the list). Covered by tests/unit/provider-sweep-live-discovery.test.ts. Structural shrink of this route tracked in #3789.", "_rebaseline_2026_06_19_4313_harvest_x_sweep_reconcile": "RECONCILIACAO ao mergear #4313 (5 harvested features) APOS o provider-sweep #4324: valores MEDIDOS na arvore combinada release(com #4324)+#4313. route.ts 2580->2586 (sweep +19 NAMED_OPENAI_STYLE + #4313 +3 openadapter/dit/tokenrouter = uniao no Set, regioes disjuntas). providers.ts 3198->3242 (sweep 6 dead-provider marks + #4313 3 novas entries APIKEY_PROVIDERS). combos/page.tsx 4350->4385 (#4313 #3266 allowlist UI) e providers/page.tsx 1925->1927 (#4313 #4240 serviceKind state) — intocados pelo sweep, crescimento puro do #4313. Mesmo padrao release-volatil de medir-no-merge dos baselines de complexity/zizmor deste lote.", "_rebaseline_2026_06_19_provider_sweep_merge_reconcile": "provider-model-sweep PR merge into release/v3.8.30: the sweep's route.ts growth (NAMED_OPENAI_STYLE_PROVIDERS +19 entries, base 2538->2564) and #4259's cloudflare parseResponse (2538->2554) are disjoint regions that both land, so the merged route.ts frozen value is reconciled to its measured size here. constants/providers.ts carries the sweep's 6 dead-provider deprecation marks on top of release. chatCore.ts stays at #4266's 5128 (sweep does not touch it).", "_rebaseline_2026_06_19_provider_sweep_dead_providers": "provider-model-sweep Track C: src/shared/constants/providers.ts 3169->3198 (+29 = deprecated:true + riskNoticeVariant:\"deprecated\" + a deprecationReason for six providers the sweep confirmed dead — kluster/glhf/predibase/inclusionai/galadriel (DNS no longer resolves) + phind (API shut down 2026-01). Mirrors the existing qwen deprecation-flag pattern; pure additive metadata so the UI surfaces a deprecation notice instead of silently offering a non-working provider. Not extractable (it IS the per-provider constant data).", @@ -60,6 +72,7 @@ "_rebaseline_2026_06_16_3974_toolsearch_beta": "Issue #3974 own growth: base.ts 1218->1222 (+4 = wrap selectBetaFlags() with mergeClientAnthropicBeta() at the ccHeaders anthropic-beta callsite, plus a 3-line comment, so the client's allowlisted tool-search beta survives). The shared helper + allowlist live in anthropicHeaders.ts (small file, well under cap); default.ts also gains the merge. Cohesive one-callsite fix; not extractable.", "_rebaseline_2026_06_16_3959_strict_random_shuffle": "Issue #3959 own growth: combo.ts 5277->5283 (+6 = shuffle the strict-random fallback remainder via fisherYatesShuffle, plus a 4-line comment, so a failing deck pick no longer always falls through to the same fixed top-priority model). Cohesive one-spot routing fix; structural shrink of combo.ts tracked in #3501.", "_rebaseline_2026_06_16_3972_logs_autorefresh": "Issue #3972 own growth: RequestLoggerV2.tsx 1282->1287 (+5 = the auto-refresh interval now reads the live document.visibilityState each tick instead of a stale mount-time ref, plus a 3-line comment explaining the hidden-tab trap). Cohesive one-spot fix; structural shrink of this component tracked in #3501.", + "_rebaseline_2026_06_21_4269_ghost_loadmore": "Issue #4269 own growth: RequestLoggerV2.tsx 1287->1316 (+29 = gate the infinite-scroll IntersectionObserver behind a real user scroll so a 'ghost' loadMore can't fire on mount and permanently pause auto-refresh — a hasScrolledRef + a passive scroll listener effect + the shouldTriggerInfiniteScroll guard at the observer callback + a filter-change re-arm, plus comments). The reusable predicate lives in requestLoggerSignature.ts (small file, well under cap, unit-tested); the component glue is the irreducible wiring. Structural shrink of this component tracked in #3501.", "_rebaseline_2026_06_16_3976_llm7_byteplus_models": "Issue #3976 own growth: models/route.ts 2489->2494 (+5 = add llm7 + byteplus to NAMED_OPENAI_STYLE_PROVIDERS with an explanatory comment so the import route does a live /models fetch instead of serving the stale hardcoded registry catalog). Structural shrink of this route tracked in #3789.", "_rebaseline_2026_06_16_3954_cooldown_epoch": "Issue #3954 own growth: accountFallback.ts 1708->1727 (+19 = a shared cooldownUntilMs() normalizer + its use in isAccountUnavailable/getEarliestRateLimitedUntil/filterAvailableAccounts so a rate_limited_until persisted as a numeric-epoch string is honored, not parsed to NaN) and auth.ts 2216->2219 (+3 = parseFutureDateMs reuses cooldownUntilMs). Cohesive cooldown read-path hardening at the existing chokepoints; one helper, not extractable.", "_rebaseline_2026_06_16_3960_engine_breakdown": "PR #3960 capture own growth: chatCore.ts 5851->5868 (+17 = persist result.stats.engineBreakdown into the new compression_engine_breakdown table after a stacked compression run, so getPerEngineAnalytics is accurate historically and not live-only). Cohesive analytics-persistence at the existing compression chokepoint; structural shrink of chatCore.ts tracked in #3501.", @@ -82,8 +95,8 @@ "frozen": { "open-sse/translator/request/openai-to-kiro.ts": 807, "open-sse/config/providerRegistry.ts": 4731, - "open-sse/executors/antigravity.ts": 1680, - "open-sse/executors/base.ts": 1399, + "open-sse/executors/antigravity.ts": 1687, + "open-sse/executors/base.ts": 1407, "open-sse/executors/chatgpt-web.ts": 2870, "open-sse/executors/claude-web.ts": 1057, "open-sse/executors/codex.ts": 1449, @@ -94,16 +107,16 @@ "open-sse/executors/muse-spark-web.ts": 1284, "open-sse/executors/perplexity-web.ts": 1013, "open-sse/handlers/audioSpeech.ts": 965, - "open-sse/handlers/chatCore.ts": 5128, + "open-sse/handlers/chatCore.ts": 5110, "open-sse/handlers/imageGeneration.ts": 3777, "open-sse/handlers/responseSanitizer.ts": 1103, "open-sse/handlers/search.ts": 1546, "open-sse/handlers/sseParser.ts": 830, "open-sse/handlers/videoGeneration.ts": 1078, "open-sse/mcp-server/schemas/tools.ts": 1437, - "open-sse/mcp-server/server.ts": 1468, + "open-sse/mcp-server/server.ts": 1509, "open-sse/mcp-server/tools/advancedTools.ts": 1118, - "open-sse/services/accountFallback.ts": 1731, + "open-sse/services/accountFallback.ts": 1752, "open-sse/services/batchProcessor.ts": 828, "open-sse/services/browserBackedChat.ts": 850, "open-sse/services/claudeCodeCompatible.ts": 1202, @@ -135,7 +148,7 @@ "src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 954, "src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderModels.ts": 155, "src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderSettings.ts": 264, - "src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 955, + "src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 974, "src/app/(dashboard)/dashboard/providers/components/onboarding/ProviderOnboardingWizard.tsx": 906, "src/app/(dashboard)/dashboard/providers/page.tsx": 1927, "src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1198, @@ -145,16 +158,16 @@ "src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": 1012, "src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1089, "src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": 983, - "src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1580, + "src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1594, "src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1924, "src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": 1016, "src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148, "src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1069, "src/app/api/oauth/[provider]/[action]/route.ts": 918, - "src/app/api/providers/[id]/models/route.ts": 2586, + "src/app/api/providers/[id]/models/route.ts": 2590, "src/app/api/providers/[id]/test/route.ts": 887, "src/app/api/usage/analytics/route.ts": 941, - "src/app/api/v1/models/catalog.ts": 1478, + "src/app/api/v1/models/catalog.ts": 1486, "src/lib/cloudflaredTunnel.ts": 934, "src/lib/db/apiKeys.ts": 1662, "src/lib/db/core.ts": 1820, @@ -167,21 +180,21 @@ "src/lib/evals/evalRunner.ts": 961, "src/lib/memory/retrieval.ts": 1171, "src/lib/modelsDevSync.ts": 934, - "src/lib/providers/validation.ts": 4428, + "src/lib/providers/validation.ts": 4522, "src/lib/tailscaleTunnel.ts": 1202, "src/lib/usage/callLogs.ts": 975, "src/lib/usage/providerLimits.ts": 949, "src/lib/usage/usageHistory.ts": 934, "src/shared/components/OAuthModal.tsx": 960, - "src/shared/components/RequestLoggerV2.tsx": 1287, + "src/shared/components/RequestLoggerV2.tsx": 1316, "src/shared/components/analytics/charts.tsx": 1558, "src/shared/constants/cliTools.ts": 875, - "src/shared/constants/pricing.ts": 1592, + "src/shared/constants/pricing.ts": 1623, "src/shared/constants/providers.ts": 3242, "src/shared/constants/sidebarVisibility.ts": 1100, "src/shared/services/cliRuntime.ts": 1090, "src/shared/validation/schemas.ts": 2523, - "src/sse/handlers/chat.ts": 1486, + "src/sse/handlers/chat.ts": 1491, "src/sse/services/auth.ts": 2279 }, "testCap": 800, @@ -263,5 +276,7 @@ "_rebaseline_2026_06_17_4107_pending_reaper": "PR #4107 own growth: usageHistory.ts 854->934 (+80 = orphaned-pending-request reaper — sweepStalePendingRequests() evicts pending details older than 15min + a hard 5000 cap, plus an unref'd 5min sweep timer wired lazily into trackPendingRequest). Fixes an unbounded memory leak where abnormally-terminated requests left payload previews in pendingById forever. Cohesive with the existing pending-request bookkeeping (mirrors the normal removal path: decrement counters + cleanup buckets); not extractable.", "_rebaseline_2026_06_17_4116_combo_hedge_listener": "combo.ts: +9 lines from #4116 (detach per-target listener from shared hedge abort signal to fix a listener leak). Behavior-preserving cleanup; 5289 -> 5298.", "_rebaseline_2026_06_20_4355_gpt5x_pro_pricing": "PR #4355 own growth: pricing.ts 1581->1592 (+11 = pure-data pricing rows for openai gpt-5.5-pro + gpt-5.4-pro, closing the $0 gap that tripped the catalog pricing gate after the #4324 sweep added them to the registry; -pro mirrors its base family tier). provider-models-route.test.ts 1616->1618 (+2 = test-only alignment to the intentional opencode-go discovery behavior: owned_by stamp + T39 two-endpoint fail-path fetchCalls). Both are data/test-only; not extractable.", - "_rebaseline_2026_06_19_4293_codex_spark_scope": "PR #4293 (isolate Codex Spark quota scope) own growth, MEASURED on the actual merged tree (release/v3.8.30 + #4293). Production: auth.ts 2219->2279 (+60) threads requestedModel into Codex quota-policy/headroom/preflight/P2C scoring so normal Codex and GPT-5.3-Codex-Spark windows are evaluated independently; chatCore.ts 5116->5125 (+9) passes the failing model scope into Codex 429 failover (markCodexScopeRateLimited) instead of a connection-wide rateLimitedUntil write; accountFallback.ts 1727->1731 (+4) scopes Codex model-lock keys to codex vs spark. Heavy parsing/display logic lives in new leaf helpers under the cap (codexQuotaScopes.ts, codexUsageQuotas.ts, codexFailover.ts). Tests: account-fallback-service 1544->1569, executor-codex 1336->1339, sse-auth 1527->1553, usage-service-hardening 1612->1633 (added Spark-scope regression coverage). Cohesive wiring at existing selection/failover lockout boundaries; not extractable." + "_rebaseline_2026_06_19_4293_codex_spark_scope": "PR #4293 (isolate Codex Spark quota scope) own growth, MEASURED on the actual merged tree (release/v3.8.30 + #4293). Production: auth.ts 2219->2279 (+60) threads requestedModel into Codex quota-policy/headroom/preflight/P2C scoring so normal Codex and GPT-5.3-Codex-Spark windows are evaluated independently; chatCore.ts 5116->5125 (+9) passes the failing model scope into Codex 429 failover (markCodexScopeRateLimited) instead of a connection-wide rateLimitedUntil write; accountFallback.ts 1727->1731 (+4) scopes Codex model-lock keys to codex vs spark. Heavy parsing/display logic lives in new leaf helpers under the cap (codexQuotaScopes.ts, codexUsageQuotas.ts, codexFailover.ts). Tests: account-fallback-service 1544->1569, executor-codex 1336->1339, sse-auth 1527->1553, usage-service-hardening 1612->1633 (added Spark-scope regression coverage). Cohesive wiring at existing selection/failover lockout boundaries; not extractable.", + "_rebaseline_2026_06_20_4447_openai_gpt41mini_o_mini_pricing": "PR #4447 own growth: pricing.ts 1592->1620 (+28 = pure-data pricing rows closing the null/$0 gap for registry-exposed OpenAI ids gpt-4.1-mini, gpt-4.1-nano, o3-mini, o4-mini that tripped the catalog pricing gate; getPricingForModel does an exact lookup, so a missing key resolves to null. Official OpenAI per-1M prices + the table's derived-field convention (reasoning=output*1.5, cache_creation=input, cached=official). Restore-green for a pre-existing release/v3.8.32 red surfaced by #4432's __RUN_ALL__ run. Cohesive data; not extractable.", + "_rebaseline_2026_06_20_web_cookie_validator_shadow_fix": "validation.ts 4518->4522 (+4 = move the generic web-cookie validateWebCookieProvider dispatch from the TOP of validateProviderApiKey to a FALLBACK after SPECIALTY_VALIDATORS, plus a comment, so #4023's generic AUTH_007 ping no longer shadows the rich per-provider validators (grok-web #3474 IP-reputation/Cloudflare, chatgpt-web cf-mitigated, claude/gemini/copilot/qwen/t3-web). Restores provider-validation-specialty.test.ts (112/112) while keeping web-cookie-auth007 (5/5). Behavior fix at an existing dispatch boundary; not extractable." } diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index 8ef79f2bd6..b85dc3c802 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -2,7 +2,7 @@ "_comment": "Catraca de qualidade. 'down' = nao pode aumentar; 'up' = nao pode cair. Atualize via 'npm run quality:ratchet -- --update' (somente quando melhora). Cada valor e um numero REAL medido, nunca um chute. Cobertura entra na Fase 4 a partir de um run de cobertura mergeada no CI.", "metrics": { "eslintWarnings": { - "value": 3839, + "value": 3863, "direction": "down" }, "eslintErrors": { @@ -90,7 +90,7 @@ "eps": 0.5 }, "deadExports": { - "value": 340, + "value": 343, "direction": "down", "dedicatedGate": true }, @@ -305,8 +305,10 @@ "_eslint_rebaseline_2026_06_13_v3825": "3658 -> 3669: +11 drift consciente do ciclo v3.8.24->v3.8.25 (features #3799-#3806: free-provider-rankings, plugins menu, proxy IP-family). Medido em release/v3.8.25 (e38d22512). Crescimento de feature legitima, nao regressao; apertar via --require-tighten no fim do ciclo.", "_eslint_rebaseline_2026_06_15_release_v3826": "3669 -> 3760. Medido em origin/release/v3.8.26 e neste PR com npm run quality:collect: ambos retornam 3760 warnings, portanto este PR e neutro; o drift ja existe na base release/v3.8.26.", "_eslint_rebaseline_2026_06_16_v3826_forward_merge": "3760 -> 3769. O quality-gate da main FALHOU no forward-merge release->main (run 27593205254): eslintWarnings 3769 > baseline 3760. Medido AGORA em origin/release/v3.8.26 (273ecf7b5, com todos os merges do ciclo) via npm run quality:collect = 3769 — identico ao CI, e os PRs de gate posteriores (#3947/#3949/#3951/#3956/#3961) nao mudaram a contagem (scripts/check/*.mjs sao eslint-ignored; os arquivos de teste novos nao adicionaram any/warnings). O +9 e drift release-wide pre-existente do ciclo v3.8.26 (merges de feature/outras sessoes), nao regressao de produto. Re-baseline consciente p/ o valor real medido; apertar via --require-tighten no fim do ciclo.", + "_eslint_rebaseline_2026_06_20_v3832_cycle_close": "3839 -> 3863. Fechamento do ciclo v3.8.32. Medido com npm run quality:collect no tip de release/v3.8.32 (b4dbf7b23, 38 commits do ciclo, incluindo os late #4476 opencode + #4468 RTK que adicionaram 0 warnings) = 3863. O +24 e drift release-wide acumulado dos PRs de feature/fix mergeados no ciclo (ports upstream #4426/#4429/#4431/#4453/#4458, painel de compressao #4432, validators web-cookie #4023, etc.) — warnings de estilo/any-as-warn em codigo de produto, nao regressao. O commit de release em si toca SO CHANGELOG.md + README.md (markdown, nao lintado) => delta 0 verificado via git status. openapiCoverage.pct=37.9 PASSA dentro do eps=0.5 (38.4-0.5=37.9); i18nUiCoverage.pct=78.4 == baseline. Apertar via --require-tighten fica para follow-up.", "_quality_rebaseline_2026_06_15_release_v3826": "deadExports 327 -> 339 e cognitiveComplexity 738 -> 753. Medido em origin/release/v3.8.26 e neste PR com os dedicated gates: ambos retornam os mesmos valores, portanto este PR e neutro; typeCoveragePct permanece acima do baseline.", "_dead_code_rebaseline_2026_06_19_pr4293": "deadExports 339 -> 340. Medido em origin/main com a mesma toolchain/deps deste PR (`node scripts/check/check-dead-code.mjs`) = DEAD_TOTAL 340, e o HEAD deste PR tambem mede 340; portanto o PR e neutro e o baseline anterior estava 1 item atrasado.", + "_dead_code_rebaseline_2026_06_21_v3832_cycle_close": "deadExports 340 -> 343 (DEAD_EXPORTS=245 + DEAD_FILES=98). Fechamento do ciclo v3.8.32: a CI Quality Ratchet (run 27895738528, head 1e2ef819b) mediu 343, e `node scripts/check/check-dead-code.mjs` local no mesmo HEAD tambem mede 343 — portanto o +3 e drift de dead-code acumulado dos PRs do ciclo (refactor #4392 resolveChatCoreRequestSetup, painel compressao #4432, helpers de pricing/mcp-scopes), NAO do commit de release (que toca so CHANGELOG/README/baseline = 0 codigo). Trust-but-verify: nenhum wiring quebrado — EngineConfigPage usa export NOMEADO (vivo em 4 paginas ultra/headroom/aggressive/lite); apenas o export `default` redundante + calculateCostFromTokens + MCP_SCOPE_PRESETS/hasRequiredScopes/getMissingScopes ficaram exported-but-unused (API/forward-looking). Re-baseline ao valor real medido; limpeza estrutural rastreada em #3501.", "_cognitive_rebaseline_2026_06_19_pr4293": "cognitiveComplexity 753 -> 783. Medido em origin/main com a mesma toolchain/deps deste PR (`node scripts/check/check-cognitive-complexity.mjs`) = 783; apos refatorar os helpers deste PR, o HEAD tambem mede 783. Portanto o PR fica neutro e o baseline anterior estava desatualizado vs main atual.", "_scanner_baselines_seeded_2026_06_15": "secretFindings (3), zizmorFindings (195), vulnCount (13) e bundleSize (5601) congelados a partir de um run LOCAL em 2026-06-15 com os binarios reais no PATH (gitleaks 8.30.1, osv-scanner 2.3.8, zizmor 1.25.2, @size-limit/file 12.1.0). Medicoes: (a) secretFindings=3 via 'gitleaks dir ' por diretorio de fonte (src/open-sse/bin/electron/scripts) APOS corrigir o .gitleaks.toml para [extend].useDefault=true (sem isso o config customizado zerava o ruleset e nunca detectava nada) e a invocacao para escopo por-dir (gitleaks dir aceita 1 path; multiplos caiam para escanear o CWD inteiro/node_modules->timeout). Os 3 sao falsos-positivos do heuristico generic-api-key (string de header beta Anthropic + nomes de coluna latencyP50Ms/latencyP95Ms), a serem allowlistados ao longo do tempo; (b) zizmorFindings=195 via 'npm run check:workflows' APOS migrar .zizmor.yml do schema antigo 'ignores: []' para 'rules: {}' (zizmor 1.25.2 rejeitava o campo 'ignores'); (c) vulnCount=13 (LOW=4/MOD=7/HIGH=2) via osv-scanner; (d) bundleSize=5601 (gzip dos 4 entrypoints bin/*.mjs) via size-limit+@size-limit/file. Todos os 4 sao dedicatedGate:true => SKIP no ratchet BLOQUEANTE (job quality-gate) e ADVISORY no job quality-extended (continue-on-error). Permanecem advisory ate um run VERDE de CI confirmar que a tooling corrigida (install via 'gh release download' em vez de api.github.com nao-autenticado) produz os valores; o flip para bloqueante (remover continue-on-error) fica para um PR de follow-up. Direction:down em todos.", "_scanner_remediation_2026_06_15": "Remediacao das findings reais que os scanners semeados acima expuseram (medido localmente em 2026-06-15 com os mesmos binarios). vulnCount 13->10: bump dos 2 HIGH transitivos via package.json overrides — form-data 4.0.5->^4.0.6 (GHSA-hmw2-7cc7-3qxx, via axios) e vite 8.0.5->^8.0.16 (GHSA-fx2h-pf6j-xcff HIGH + GHSA-v6wh-96g9-6wx3 MODERATE, dev-only via vitest/@vitejs/plugin-react/fumadocs-mdx); osv-scanner confirma 0 HIGH restante; build:cli e a suite vitest MCP (16 files/187 testes) verdes pos-bump. zizmorFindings 195->187: env-harden de 7 findings template-injection (ci.yml job i18n; electron-release.yml jobs validate/build/release — o step 'Create source archives' sozinho gerava 4 das 7) movendo cada ${{...}} para 'env:' e referenciando \"$VAR\" no script, + allowlist de 1 dangerous-triggers (deploy-vps.yml on:workflow_run — guardado por conclusion=='success', deploy via SSH sem checkout de codigo nao-confiavel; entry em .zizmor.yml rules.dangerous-triggers.ignore). secretFindings (3) e bundleSize (5601) intocados neste PR. Apertados via edicao manual (direction:down).", diff --git a/docker-compose.yml b/docker-compose.yml index e30f4c0d2a..2560644527 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,8 @@ # cli → CLIs installed inside the container (portable) # host → runner-base + host-mounted CLI binaries (Linux-first) # cliproxyapi → CLIProxyAPI sidecar on port 8317 +# memory → Qdrant sidecar on port 6333 (semantic memory offload) +# bifrost → Bifrost Go sidecar on port 8080 (Tier-1 LLM router) # # Usage: # docker compose --profile base up -d @@ -16,6 +18,11 @@ # docker compose --profile host up -d # docker compose --profile cliproxyapi up -d # docker compose --profile cli --profile cliproxyapi up -d +# docker compose --profile base --profile memory up -d # adds Qdrant sidecar +# docker compose --profile base --profile bifrost up -d # adds Bifrost sidecar +# +# See docs/architecture/cluster-decisions.md for the per-component rationale +# (Qdrant=opt-in, Bifrost=opt-in, Caddy=upstream LB, no Dragonfly/NATS/PG/Neo4j/MinIO). # # Before first run, copy .env.example → .env and edit your secrets. # ────────────────────────────────────────────────────────────────────── @@ -149,6 +156,60 @@ services: profiles: - host + # ── Profile: memory (Qdrant semantic-memory sidecar) ───────────── + # Off by default. SQLite + sqlite-vec + FTS5 (RRF) is the primary vector + # store (see src/lib/memory/vectorStore.ts). Enable only when you need + # cross-instance memory sharing or >1M points — at which point the + # QDRANT_ENABLED=true flag activates the dual-write path in + # src/lib/memory/qdrant.ts:37. See docs/architecture/cluster-decisions.md + # for the workload analysis behind this profile. + qdrant: + image: docker.io/qdrant/qdrant:v1.12.4 + container_name: omniroute-qdrant + restart: unless-stopped + ports: + - "${QDRANT_PORT:-6333}:6333" + - "${QDRANT_GRPC_PORT:-6334}:6334" + volumes: + - qdrant-data:/qdrant/storage + environment: + - QDRANT__SERVICE__GRPC_PORT=6334 + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:6333/readyz"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 10s + profiles: + - memory + + # ── Profile: bifrost (Bifrost Go LLM-router sidecar) ───────────── + # Off by default. Bifrost is the Tier-1 LLM router (per ADR-031 and + # open-sse/executors/bifrost.ts). When BIFROST_ENABLED=true is set in + # .env, OmniRoute delegates /v1/chat/completions, /v1/responses, and + # /v1/embeddings to this sidecar instead of handling them in chatCore. + # The kill switch is the BIFROST_ENABLED env var — flip to false to + # fall back to the chatCore path with zero code changes. See + # docs/architecture/cluster-decisions.md for the activation plan. + bifrost: + image: ghcr.io/maximhq/bifrost:1.5.21 + container_name: omniroute-bifrost + restart: unless-stopped + ports: + - "${BIFROST_PORT:-8080}:8080" + volumes: + - bifrost-data:/data + environment: + - BIFROST_LOG_LEVEL=${BIFROST_LOG_LEVEL:-info} + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8080/v1/models"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + profiles: + - bifrost + # ── Profile: cliproxyapi (CLIProxyAPI as sidecar) ───────────────── cliproxyapi: container_name: cliproxyapi @@ -176,3 +237,7 @@ volumes: name: cliproxyapi-data redis-data: name: omniroute-redis-data + qdrant-data: + name: omniroute-qdrant-data + bifrost-data: + name: omniroute-bifrost-data diff --git a/docs/architecture/cluster-decisions.md b/docs/architecture/cluster-decisions.md new file mode 100644 index 0000000000..2a2e6f7295 --- /dev/null +++ b/docs/architecture/cluster-decisions.md @@ -0,0 +1,101 @@ +--- +title: "Cluster Decisions" +version: 3.8.32 +lastUpdated: 2026-06-20 +--- + +# 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 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`). diff --git a/docs/compression/2026-06-20-unified-compression-config-panel-design.md b/docs/compression/2026-06-20-unified-compression-config-panel-design.md new file mode 100644 index 0000000000..747ba7ff6e --- /dev/null +++ b/docs/compression/2026-06-20-unified-compression-config-panel-design.md @@ -0,0 +1,240 @@ +--- +title: "Unified Compression Config Panel — Design" +version: 3.8.32 +lastUpdated: 2026-06-20 +--- + +# 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" | ) (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; // 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; // 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: ` (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. +- `` → that named combo. +- `engine:` → 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)` | `` → 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 2–3 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 2–3 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 2–3 UI polish (combo templates, sharing) is out of scope. diff --git a/docs/compression/2026-06-20-unified-compression-config-panel-plan.md b/docs/compression/2026-06-20-unified-compression-config-panel-plan.md new file mode 100644 index 0000000000..34a51fcf35 --- /dev/null +++ b/docs/compression/2026-06-20-unified-compression-config-panel-plan.md @@ -0,0 +1,495 @@ +--- +title: "Unified Compression Config Panel — Phase 1 Implementation Plan" +version: 3.8.32 +lastUpdated: 2026-06-20 +--- + +# 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/.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 = { + "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;` 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 = { 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, 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>; // 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/]`; 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 ``. 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` | ``) 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`/``/`engine:`). 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). diff --git a/docs/features/USAGE_QUOTA_GUIDE.md b/docs/features/USAGE_QUOTA_GUIDE.md new file mode 100644 index 0000000000..37e31e30a9 --- /dev/null +++ b/docs/features/USAGE_QUOTA_GUIDE.md @@ -0,0 +1,430 @@ +--- +title: "Usage, Quota & Spend Tracking" +version: 3.8.16 +lastUpdated: 2026-06-08 +--- + +# 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` diff --git a/docs/frameworks/OPEN_SSE_ARCHITECTURE.md b/docs/frameworks/OPEN_SSE_ARCHITECTURE.md new file mode 100644 index 0000000000..821a0588cc --- /dev/null +++ b/docs/frameworks/OPEN_SSE_ARCHITECTURE.md @@ -0,0 +1,567 @@ +--- +title: "open-sse Architecture" +version: 3.8.16 +lastUpdated: 2026-06-08 +--- + +# 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 { + 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) diff --git a/docs/guides/REMOTE-MODE.md b/docs/guides/REMOTE-MODE.md index ab1cd097fe..dbec59c0c7 100644 --- a/docs/guides/REMOTE-MODE.md +++ b/docs/guides/REMOTE-MODE.md @@ -172,14 +172,95 @@ opencode -m omniroute/glm/glm-5.2 "..." # export OMNIROUTE_API_KEY firs --- -## Switching back to local +## Managing contexts (switch between servers) + +A **context** is a saved server (baseUrl + credential + scope). `omniroute connect` +creates one and makes it active; from then on every command targets it. Manage and +switch between them with `omniroute contexts`: ```bash -omniroute contexts use default # back to localhost -omniroute context current # show active server, auth, scope -omniroute contexts list # all contexts +omniroute contexts list # all contexts; the active one is marked ● +omniroute contexts current # the active server, auth status, scope ``` +```text + | Name | Base URL | Auth | Scope | Description +● | vps | http://100.67.86.91:20128 | token | admin | Remote OmniRoute (…) + | default | http://localhost:20128 | ✗ | | +``` + +**Switch servers** — every subsequent command follows the active context: + +```bash +omniroute contexts use vps # → all commands now hit the remote VPS +omniroute tokens list # (runs against the VPS) + +omniroute contexts use default # → back to localhost +omniroute tokens list # (runs against the local server) +``` + +**Add a context manually** (instead of `connect`), inspect, or rename: + +```bash +omniroute contexts add staging --url https://staging.example.com:20128 \ + --access-token oma_live_xxxx --scope write --description "staging box" +omniroute contexts show staging # full details for one context +omniroute contexts rename staging stg +``` + +**Remove a context** — prompts for confirmation; pass `--yes` to skip it +(required for scripts / non-interactive shells, which otherwise decline safely): + +```bash +omniroute contexts remove stg --yes +``` + +> `default` (localhost) cannot be removed. Removing the active context falls back +> to `default`. Tip: removing a context only drops the **local** saved credential — +> revoke the token on the server with `omniroute tokens revoke ` to actually +> kill access. + +**Export / import** contexts (e.g. to move them between machines — secrets included, +so handle the file carefully): + +```bash +omniroute contexts export --out contexts.json # default: stdout +omniroute contexts import contexts.json # overwrite; --merge to keep existing +``` + +--- + +## Quick end-to-end check + +A copy-paste lifecycle to verify a remote setup from scratch — connect, mint a +scoped token, route a command, switch back, and tear down. Replace +`192.168.0.15` with your server's host/IP (Tailscale, LAN, or a public +`https://…` URL). + +```bash +# 1. Connect (password → admin token, saved as a context that becomes active) +omniroute connect 192.168.0.15 # or: --key oma_live_xxxx (no password) +omniroute contexts current # shows the remote server + scope + +# 2. Use it — management commands now run against the remote +omniroute tokens create --name laptop --scope read # mint a narrower token +omniroute tokens list # masked list, from the remote + +# 3. Switch back and forth +omniroute contexts use default # → local +omniroute contexts use 192-168-0-15 # → remote again (name from `contexts list`) + +# 4. Tear down. NOTE: `contexts remove` only deletes the LOCAL credential — +# it does NOT revoke the token on the server. Revoke server-side first if you +# want to actually kill access. +omniroute tokens revoke # kills access on the server +omniroute contexts remove 192-168-0-15 --yes # drop the local context (even if active → falls back to default), no prompt +``` + +> `--yes` makes `contexts remove` non-interactive (required in scripts/CI; without +> it, a non-interactive shell declines safely instead of hanging). Removing the +> **active** context falls back to `default` automatically. + --- ## Security notes diff --git a/docs/i18n/ar/CHANGELOG.md b/docs/i18n/ar/CHANGELOG.md index d93e4f9ac0..408d293c44 100644 --- a/docs/i18n/ar/CHANGELOG.md +++ b/docs/i18n/ar/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/az/CHANGELOG.md b/docs/i18n/az/CHANGELOG.md index 9dcc4f6cf1..62f1ba31c5 100644 --- a/docs/i18n/az/CHANGELOG.md +++ b/docs/i18n/az/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/bg/CHANGELOG.md b/docs/i18n/bg/CHANGELOG.md index 9dcc4f6cf1..62f1ba31c5 100644 --- a/docs/i18n/bg/CHANGELOG.md +++ b/docs/i18n/bg/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/bn/CHANGELOG.md b/docs/i18n/bn/CHANGELOG.md index 1143b6c52d..4d72ba3d4f 100644 --- a/docs/i18n/bn/CHANGELOG.md +++ b/docs/i18n/bn/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/cs/CHANGELOG.md b/docs/i18n/cs/CHANGELOG.md index bad1c77a53..b8f3fb60da 100644 --- a/docs/i18n/cs/CHANGELOG.md +++ b/docs/i18n/cs/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/da/CHANGELOG.md b/docs/i18n/da/CHANGELOG.md index 5c8f3a8f3c..70e74b7340 100644 --- a/docs/i18n/da/CHANGELOG.md +++ b/docs/i18n/da/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/de/CHANGELOG.md b/docs/i18n/de/CHANGELOG.md index 05a5ba85ff..ba13e54133 100644 --- a/docs/i18n/de/CHANGELOG.md +++ b/docs/i18n/de/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/es/CHANGELOG.md b/docs/i18n/es/CHANGELOG.md index 36920bc427..cd89958279 100644 --- a/docs/i18n/es/CHANGELOG.md +++ b/docs/i18n/es/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/fa/CHANGELOG.md b/docs/i18n/fa/CHANGELOG.md index 11cce7ba63..cb4ceeb844 100644 --- a/docs/i18n/fa/CHANGELOG.md +++ b/docs/i18n/fa/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/fi/CHANGELOG.md b/docs/i18n/fi/CHANGELOG.md index 79b561d8dd..b5718aa5c1 100644 --- a/docs/i18n/fi/CHANGELOG.md +++ b/docs/i18n/fi/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/fr/CHANGELOG.md b/docs/i18n/fr/CHANGELOG.md index 71bb14b371..d8a768b671 100644 --- a/docs/i18n/fr/CHANGELOG.md +++ b/docs/i18n/fr/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/gu/CHANGELOG.md b/docs/i18n/gu/CHANGELOG.md index fd58e00d6e..25faba9241 100644 --- a/docs/i18n/gu/CHANGELOG.md +++ b/docs/i18n/gu/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/he/CHANGELOG.md b/docs/i18n/he/CHANGELOG.md index c31e19a620..79f8697a12 100644 --- a/docs/i18n/he/CHANGELOG.md +++ b/docs/i18n/he/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/hi/CHANGELOG.md b/docs/i18n/hi/CHANGELOG.md index bf4d77d960..43c8369a40 100644 --- a/docs/i18n/hi/CHANGELOG.md +++ b/docs/i18n/hi/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/hu/CHANGELOG.md b/docs/i18n/hu/CHANGELOG.md index 3b9e3337be..f3b0fbf18f 100644 --- a/docs/i18n/hu/CHANGELOG.md +++ b/docs/i18n/hu/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/id/CHANGELOG.md b/docs/i18n/id/CHANGELOG.md index c23141cef3..fe89bae029 100644 --- a/docs/i18n/id/CHANGELOG.md +++ b/docs/i18n/id/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/in/CHANGELOG.md b/docs/i18n/in/CHANGELOG.md index 9e44fd5534..9983b84264 100644 --- a/docs/i18n/in/CHANGELOG.md +++ b/docs/i18n/in/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/it/CHANGELOG.md b/docs/i18n/it/CHANGELOG.md index 56b3071415..a245dfa92e 100644 --- a/docs/i18n/it/CHANGELOG.md +++ b/docs/i18n/it/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/ja/CHANGELOG.md b/docs/i18n/ja/CHANGELOG.md index bae397a05a..24c98673d0 100644 --- a/docs/i18n/ja/CHANGELOG.md +++ b/docs/i18n/ja/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/ko/CHANGELOG.md b/docs/i18n/ko/CHANGELOG.md index 800090269d..74fd9ad79e 100644 --- a/docs/i18n/ko/CHANGELOG.md +++ b/docs/i18n/ko/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/mr/CHANGELOG.md b/docs/i18n/mr/CHANGELOG.md index 3cfc73ed81..e266f5d9b7 100644 --- a/docs/i18n/mr/CHANGELOG.md +++ b/docs/i18n/mr/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/ms/CHANGELOG.md b/docs/i18n/ms/CHANGELOG.md index ae91683ee7..d524d29e64 100644 --- a/docs/i18n/ms/CHANGELOG.md +++ b/docs/i18n/ms/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/nl/CHANGELOG.md b/docs/i18n/nl/CHANGELOG.md index 0f2443fb78..7774f4a10c 100644 --- a/docs/i18n/nl/CHANGELOG.md +++ b/docs/i18n/nl/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/no/CHANGELOG.md b/docs/i18n/no/CHANGELOG.md index a1c3415170..26c171c916 100644 --- a/docs/i18n/no/CHANGELOG.md +++ b/docs/i18n/no/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/phi/CHANGELOG.md b/docs/i18n/phi/CHANGELOG.md index af872c917e..9a20d64c75 100644 --- a/docs/i18n/phi/CHANGELOG.md +++ b/docs/i18n/phi/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/pl/CHANGELOG.md b/docs/i18n/pl/CHANGELOG.md index 3b35449f00..32602f2adc 100644 --- a/docs/i18n/pl/CHANGELOG.md +++ b/docs/i18n/pl/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/pt-BR/CHANGELOG.md b/docs/i18n/pt-BR/CHANGELOG.md index f12b4c104d..ca9cc41b6c 100644 --- a/docs/i18n/pt-BR/CHANGELOG.md +++ b/docs/i18n/pt-BR/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/pt/CHANGELOG.md b/docs/i18n/pt/CHANGELOG.md index 6fcef1b673..affae234f1 100644 --- a/docs/i18n/pt/CHANGELOG.md +++ b/docs/i18n/pt/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/ro/CHANGELOG.md b/docs/i18n/ro/CHANGELOG.md index f33883a869..0c2817f6ed 100644 --- a/docs/i18n/ro/CHANGELOG.md +++ b/docs/i18n/ro/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/ru/CHANGELOG.md b/docs/i18n/ru/CHANGELOG.md index 9dd071fdaa..13f0518d19 100644 --- a/docs/i18n/ru/CHANGELOG.md +++ b/docs/i18n/ru/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/sk/CHANGELOG.md b/docs/i18n/sk/CHANGELOG.md index cdbfbef145..00d5c97655 100644 --- a/docs/i18n/sk/CHANGELOG.md +++ b/docs/i18n/sk/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/sv/CHANGELOG.md b/docs/i18n/sv/CHANGELOG.md index 24d47560fd..19047c3344 100644 --- a/docs/i18n/sv/CHANGELOG.md +++ b/docs/i18n/sv/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/sw/CHANGELOG.md b/docs/i18n/sw/CHANGELOG.md index 945005a4ef..33b6c36e48 100644 --- a/docs/i18n/sw/CHANGELOG.md +++ b/docs/i18n/sw/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/ta/CHANGELOG.md b/docs/i18n/ta/CHANGELOG.md index 77f04c915e..475c7c6cd8 100644 --- a/docs/i18n/ta/CHANGELOG.md +++ b/docs/i18n/ta/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/te/CHANGELOG.md b/docs/i18n/te/CHANGELOG.md index 8d9ff6d619..a931974f34 100644 --- a/docs/i18n/te/CHANGELOG.md +++ b/docs/i18n/te/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/th/CHANGELOG.md b/docs/i18n/th/CHANGELOG.md index a0bb620c4b..8995374f7a 100644 --- a/docs/i18n/th/CHANGELOG.md +++ b/docs/i18n/th/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/tr/CHANGELOG.md b/docs/i18n/tr/CHANGELOG.md index b9aa789ee1..3fd5ce84b6 100644 --- a/docs/i18n/tr/CHANGELOG.md +++ b/docs/i18n/tr/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/uk-UA/CHANGELOG.md b/docs/i18n/uk-UA/CHANGELOG.md index 0ac3305e7c..9b9639ddd8 100644 --- a/docs/i18n/uk-UA/CHANGELOG.md +++ b/docs/i18n/uk-UA/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/ur/CHANGELOG.md b/docs/i18n/ur/CHANGELOG.md index a406c46743..f6f5850540 100644 --- a/docs/i18n/ur/CHANGELOG.md +++ b/docs/i18n/ur/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/vi/CHANGELOG.md b/docs/i18n/vi/CHANGELOG.md index 6ef34ba1bd..bf6ba111d0 100644 --- a/docs/i18n/vi/CHANGELOG.md +++ b/docs/i18n/vi/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/i18n/zh-CN/CHANGELOG.md b/docs/i18n/zh-CN/CHANGELOG.md index 677ef87040..1ed5bb75fa 100644 --- a/docs/i18n/zh-CN/CHANGELOG.md +++ b/docs/i18n/zh-CN/CHANGELOG.md @@ -6,6 +6,14 @@ ## [3.8.31] — 2026-06-20 +## [3.8.32] — TBD + +_See English CHANGELOG for v3.8.32 details._ + +--- + +## [3.8.31] — 2026-06-20 + ### ✨ New Features - **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari) diff --git a/docs/ops/DATABASE_GUIDE.md b/docs/ops/DATABASE_GUIDE.md new file mode 100644 index 0000000000..d2262a47dd --- /dev/null +++ b/docs/ops/DATABASE_GUIDE.md @@ -0,0 +1,624 @@ +--- +title: "Database Schema & Operations Guide" +version: 3.8.16 +lastUpdated: 2026-06-08 +--- + +# 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= +``` + +> **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 +``` + +### 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 < **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) diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index ce8613a65e..35c7f225f8 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -968,6 +968,7 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `INSPECTOR_INTERNAL_INGEST_TOKEN` | _(auto)_ | `src/app/api/tools/traffic-inspector/internal/ingest/route.ts` | Token authenticating internal capture ingest into the inspector. | | `PLAYGROUND_COMPARE_MAX_COLUMNS` | `4` | `src/app/(dashboard)/dashboard/playground/` | Max number of side-by-side columns in the Playground compare mode. | | `PLAYGROUND_IMPROVE_PROMPT_DEFAULT_MODEL` | _(unset)_ | `src/app/(dashboard)/dashboard/playground/` | Default model for the Playground 'improve prompt' action (falls back to the active model when unset). | +| `BIFROST_ENABLED` | `1` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | Master kill switch for the bifrost sidecar proxy. When set to `0`, the route returns 503 with the `X-Bifrost-Killswitch` header and the operator is bounced to the TS path. Use to disable the sidecar without redeploying (tier-1 router incident, key rotation). | | `BIFROST_BASE_URL` | _(unset)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | When set, the Bifrost sidecar proxy route forwards `/v1/chat/completions` traffic to this Go gateway instead of the TS relay handler. Unset → 503-with-fallback. Trailing slash is stripped. | | `BIFROST_API_KEY` | _(unset)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | API key for the Bifrost gateway (sent as `Authorization: Bearer ...`). If unset, the route expects the request to carry a valid OmniRoute API key; this key is for gateway-side auth only. | | `BIFROST_STREAMING_ENABLED` | `true` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | When true, the Bifrost sidecar route streams responses back via SSE through the gateway rather than the TS streaming executor. Set to `0` to force non-streaming JSON responses through the gateway. | @@ -978,6 +979,14 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `OMNIROUTE_REDIS_CONTAINER_NAME` | `omniroute-redis` | `bin/cli/commands/redis.mjs` | Container name for the 1-click Redis launcher (`omniroute redis up`). Used by both the CLI and the `RedisLauncherPanel` GUI. | | `OMNIROUTE_REDIS_HOST_PORT` | `6379` | `bin/cli/commands/redis.mjs` | Host port for the 1-click Redis launcher. Bump if the host already binds 6379. The container's internal port stays 6379. | | `OMNIROUTE_REDIS_IMAGE` | `redis:7-alpine` | `bin/cli/commands/redis.mjs` | Redis image used by the 1-click Redis launcher. Override to `redis:8-alpine` or a private registry mirror as needed. | +| `QDRANT_HOST` | `qdrant` | _(opt-in cluster profile)_ | Hostname of the Qdrant sidecar when `--profile memory` is active. Default points to the in-network qdrant service name; override for an external deployment. Only consumed when `qdrantEnabled` is `true` in code (`src/lib/memory/vectorStore.ts:108`). | +| `QDRANT_PORT` | `6333` | _(opt-in cluster profile)_ | REST port of the Qdrant sidecar. | +| `QDRANT_GRPC_PORT` | `6334` | _(opt-in cluster profile)_ | gRPC port of the Qdrant sidecar. Used by client libraries that prefer gRPC over REST for streaming ops. | +| `QDRANT_API_KEY` | _(unset)_ | _(opt-in cluster profile)_ | Optional API key for Qdrant Cloud or an authenticated on-prem instance. Empty → no `api-key` header sent. | +| `QDRANT_COLLECTION` | `omniroute-memory` | _(opt-in cluster profile)_ | Collection name for OmniRoute's conversation memory embeddings. Created on first run with `QDRANT_VECTOR_SIZE` dimensions. | +| `QDRANT_EMBEDDING_MODEL` | `text-embedding-3-small` | _(opt-in cluster profile)_ | Default embedding model name recorded in the Qdrant collection metadata. Actual embeddings are generated by whatever provider the `embeddingModel` field in OmniRoute's settings points to. | +| `QDRANT_VECTOR_SIZE` | `1536` | _(opt-in cluster profile)_ | Embedding vector dimension. Must match the model you embed with (text-embedding-3-small → 1536; ada-002 → 1536; nomic-embed-text → 768). | +| `QDRANT_HNSW_EF_CONSTRUCT` | `128` | _(opt-in cluster profile)_ | HNSW index construction-time accuracy. Higher = slower build, faster search. | --- diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml index ec6e5924e7..16a0148668 100644 --- a/docs/reference/openapi.yaml +++ b/docs/reference/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.8.31 + version: 3.8.32 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, diff --git a/electron/package-lock.json b/electron/package-lock.json index a529189b2d..8ddd4db8aa 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute-desktop", - "version": "3.8.31", + "version": "3.8.32", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute-desktop", - "version": "3.8.31", + "version": "3.8.32", "license": "MIT", "dependencies": { "electron-updater": "^6.8.9" diff --git a/electron/package.json b/electron/package.json index 60893a86eb..cdc1ac2770 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.8.31", + "version": "3.8.32", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/open-sse/config/providers/registry/gemini/index.ts b/open-sse/config/providers/registry/gemini/index.ts index 46cc3c04de..2db11dc52a 100644 --- a/open-sse/config/providers/registry/gemini/index.ts +++ b/open-sse/config/providers/registry/gemini/index.ts @@ -22,6 +22,12 @@ export const geminiProvider: RegistryEntry = { }, models: [ { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash", toolCalling: true, supportsVision: true }, + { + id: "gemini-2.0-flash-lite", + name: "Gemini 2.0 Flash Lite", + toolCalling: true, + supportsVision: true, + }, { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview", @@ -34,6 +40,12 @@ export const geminiProvider: RegistryEntry = { toolCalling: true, supportsVision: true, }, + { + id: "gemini-3-flash-lite-preview", + name: "Gemini 3 Flash Lite Preview", + toolCalling: true, + supportsVision: true, + }, { id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview", diff --git a/open-sse/config/providers/registry/openai/index.ts b/open-sse/config/providers/registry/openai/index.ts index 5c984d72cf..46dce6e816 100644 --- a/open-sse/config/providers/registry/openai/index.ts +++ b/open-sse/config/providers/registry/openai/index.ts @@ -18,10 +18,14 @@ export const openaiProvider: RegistryEntry = { { id: "gpt-5.4-mini", name: "GPT-5.4 Mini", contextLength: 400000 }, { id: "gpt-5.4-nano", name: "GPT-5.4 Nano", contextLength: 400000 }, { id: "gpt-4.1", name: "GPT-4.1", contextLength: 1047576 }, + { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", contextLength: 1047576 }, + { id: "gpt-4.1-nano", name: "GPT-4.1 Nano", contextLength: 1047576 }, { id: "gpt-4o", name: "GPT-4o", contextLength: 128000 }, { id: "gpt-4o-2024-11-20", name: "GPT-4o (Nov 2024)", contextLength: 128000 }, { id: "gpt-4o", name: "GPT-4o", contextLength: 128000 }, { id: "gpt-4o-mini", name: "GPT-4o Mini", contextLength: 128000 }, { id: "o3", name: "O3", contextLength: 200000, unsupportedParams: REASONING_UNSUPPORTED }, + { id: "o3-mini", name: "O3 Mini", contextLength: 200000, unsupportedParams: REASONING_UNSUPPORTED }, + { id: "o4-mini", name: "O4 Mini", contextLength: 200000, unsupportedParams: REASONING_UNSUPPORTED }, ], }; diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index ad4ef22a56..28cd837f04 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -763,6 +763,12 @@ export class AntigravityExecutor extends BaseExecutor { requestType: _requestType, requestId: _requestId, request: _request, + // #1944: output_config (and the legacy output_format) are Anthropic/Claude-Code-only + // fields. Google's Cloud Code envelope rejects unknown top-level fields with a 400 + // ("Invalid JSON payload received. Unknown name \"output_config\""), which broke every + // Claude model served via Antigravity. Drop them so they never reach the envelope. + output_config: _outputConfig, + output_format: _outputFormat, ...passthroughFields } = normalizedBody; @@ -882,11 +888,12 @@ export class AntigravityExecutor extends BaseExecutor { } // Parse retry time from Antigravity error message body - // Format: "Your quota will reset after 2h7m23s" or "1h30m" or "45m" or "30s" + // Format: "Your quota will reset after 2h7m23s" or "Resets in 160h27m24s" or + // "1h30m" or "45m" or "30s". The optional plural ("resets in") must match too (#1308). parseRetryFromErrorMessage(errorMessage: unknown): number | null { if (!errorMessage || typeof errorMessage !== "string") return null; - const match = errorMessage.match(/reset (?:after|in) (\d+h)?(\d+m)?(\d+s)?/i); + const match = errorMessage.match(/resets? (?:after|in) (\d+h)?(\d+m)?(\d+s)?/i); if (!match) return null; let totalMs = 0; diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 475e948166..fea719bd92 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -242,6 +242,18 @@ function hasActiveClaudeThinking(body: Record): boolean { * xhigh by default and falls back to high only for explicit xhigh opt-outs. */ const MISTRAL_NO_REASONING_EFFORT_PATTERN = /devstral/i; +// GitHub Copilot Claude routing is granular (upstream port: decolua/9router#791): +// ✅ Pass through — Claude Opus 4.6, Claude Sonnet 4.6. Copilot routes both to +// Anthropic's chat/completions surface, which honors reasoning_effort and +// emits visible reasoning tokens (verified upstream: 3× token increase +// between low/medium/high). +// ❌ Strip — Claude Haiku 4.5 and Claude Opus 4.7 (rejected upstream by +// Copilot's Claude backend), older Claude variants, all `haiku`-named +// models, and the `oswe-*` family (Raptor) which still rejects +// reasoning_effort. +// Order matters: the opt-in check must run BEFORE the broad Claude/haiku/oswe strip. +const GITHUB_REASONING_EFFORT_OPT_IN_PATTERN = + /claude[-_.]?(?:opus|sonnet)[-_.]?4[-_.]6/i; const GITHUB_NO_REASONING_EFFORT_PATTERN = /(claude|haiku|oswe)/i; function supportsMaxEffortForProvider(provider: string, model: string): boolean { @@ -269,9 +281,11 @@ export function sanitizeReasoningEffortForProvider( const effortStr = typeof effort === "string" ? effort.toLowerCase() : ""; const modelStr = model || ""; + const githubOptIn = + provider === "github" && GITHUB_REASONING_EFFORT_OPT_IN_PATTERN.test(modelStr); const rejecting = (provider === "mistral" && MISTRAL_NO_REASONING_EFFORT_PATTERN.test(modelStr)) || - (provider === "github" && GITHUB_NO_REASONING_EFFORT_PATTERN.test(modelStr)); + (provider === "github" && !githubOptIn && GITHUB_NO_REASONING_EFFORT_PATTERN.test(modelStr)); if (rejecting) { log?.info?.( "REASONING_SANITIZE", diff --git a/open-sse/executors/commandCode.ts b/open-sse/executors/commandCode.ts index 70453c8419..8906c01e03 100644 --- a/open-sse/executors/commandCode.ts +++ b/open-sse/executors/commandCode.ts @@ -145,12 +145,46 @@ function clampMaxTokens(value: unknown): number { return Math.max(1, Math.min(Math.floor(numeric), MAX_COMMAND_CODE_TOKENS)); } +// Reasoning/thinking fields that payload rules or clients may inject and that +// CommandCode's upstream accepts inside `params`. Without this pass-through, +// payload-rule overrides on these fields are silently dropped (#2986 follow-up). +const COMMAND_CODE_PASSTHROUGH_FIELDS = [ + "reasoning_effort", + "reasoning", + "thinking", + "effort", + "output_config", + "extra_body", +] as const; + function buildCommandCodeBody(model: string, body: unknown, stream = false): JsonRecord { const input = isRecord(body) ? body : {}; const converted = convertMessages(input.messages); const explicitSystem = typeof input.system === "string" ? input.system : ""; const system = [converted.system, explicitSystem].filter(Boolean).join("\n\n"); + // Payload rules may rewrite `body.model` (e.g. deepseek-v4-pro-max → + // deepseek/deepseek-v4-pro for the command-code provider). Prefer the + // rewritten value if present; fall back to the resolved combo model arg. + const resolvedModel = + typeof input.model === "string" && input.model.trim().length > 0 ? input.model : model; + + const params: JsonRecord = { + model: resolvedModel, + messages: converted.messages, + tools: convertTools(input.tools), + system, + max_tokens: clampMaxTokens(input.max_tokens ?? input.max_completion_tokens), + stream: true, + }; + + for (const field of COMMAND_CODE_PASSTHROUGH_FIELDS) { + const value = input[field]; + if (value !== undefined && value !== null) { + params[field] = value; + } + } + return { config: { workingDir: "/workspace", @@ -167,14 +201,7 @@ function buildCommandCodeBody(model: string, body: unknown, stream = false): Jso taste: "", skills: "", permissionMode: "standard", - params: { - model, - messages: converted.messages, - tools: convertTools(input.tools), - system, - max_tokens: clampMaxTokens(input.max_tokens ?? input.max_completion_tokens), - stream: true, - }, + params, }; } diff --git a/open-sse/executors/github.ts b/open-sse/executors/github.ts index 98a965f17e..6fcdd2e18c 100644 --- a/open-sse/executors/github.ts +++ b/open-sse/executors/github.ts @@ -99,9 +99,51 @@ export class GithubExecutor extends BaseExecutor { modifiedBody.tools = modifiedBody.tools.slice(0, 128); } + // GitHub Copilot /chat/completions only accepts {type:'text'} or {type:'image_url'} + // content parts. Clients like Cursor IDE pass through Anthropic-shape parts + // (tool_use, tool_result, thinking) untouched when using Claude models, which makes + // the endpoint return: "type has to be either 'image_url' or 'text'" (HTTP 400). + // Serialize unknown part types as text, drop empty parts, and collapse to null when + // every part is stripped (assistant messages whose only content was tool_calls). + // Port from 9router#220 (fixes 9router#219). + if (Array.isArray(modifiedBody.messages)) { + modifiedBody.messages = modifiedBody.messages.map((msg: any) => + this.sanitizeChatCompletionsMessage(msg) + ); + } + return modifiedBody; } + private sanitizeChatCompletionsMessage(msg: any): any { + if (!msg || typeof msg !== "object") return msg; + // String content and missing content (e.g. assistant w/ only tool_calls) pass through. + if (typeof msg.content === "string" || msg.content == null) return msg; + if (!Array.isArray(msg.content)) return msg; + + const cleanContent = msg.content + .map((part: any) => { + if (!part || typeof part !== "object") return part; + if (part.type === "text") return part; + if (part.type === "image_url") return part; + // Serialize any unsupported part (tool_use, tool_result, thinking, etc.) as text. + // Try common text-carrying fields first; fall back to a JSON dump so nothing is + // silently dropped from the model's context. + const raw = + (typeof part.text === "string" && part.text) || + (typeof part.thinking === "string" && part.thinking) || + (typeof part.content === "string" && part.content) || + (part.content != null && JSON.stringify(part.content)) || + JSON.stringify(part); + return { type: "text", text: typeof raw === "string" ? raw : JSON.stringify(raw) }; + }) + .filter((part: any) => !(part && part.type === "text" && part.text === "")); + + // If every part stripped to empty (e.g. tool_use with no text), collapse to null so + // GitHub does not reject an empty-array body. tool_calls ride alongside content. + return { ...msg, content: cleanContent.length > 0 ? cleanContent : null }; + } + async execute(input: ExecuteInput) { const result = await super.execute(input); if (!result || !result.response) return result; diff --git a/open-sse/executors/kiro.ts b/open-sse/executors/kiro.ts index f7c5c12329..44f7b32c99 100644 --- a/open-sse/executors/kiro.ts +++ b/open-sse/executors/kiro.ts @@ -111,6 +111,12 @@ for (let i = 0; i < 256; i++) { CRC32_TABLE[i] = c >>> 0; } +// Full per-frame message-CRC validation is O(frame bytes) and runs for EVERY frame of +// every Kiro response on the main thread. The transport is TLS-protected and the 8-byte +// prelude CRC already guards framing, so the full-message CRC is redundant overhead that +// contributes to the CPU-runaway on large/long generations. Keep it opt-in for debugging. +const KIRO_VERIFY_FULL_CRC = process.env.KIRO_VERIFY_FULL_CRC === "true"; + function crc32(buf: Uint8Array) { let crc = 0xffffffff; for (let i = 0; i < buf.length; i++) { @@ -659,14 +665,19 @@ function parseEventFrame(data: Uint8Array): EventFrame | null { return null; } - // Message CRC covers bytes [0..totalLength-5] (everything except the CRC itself) - const messageCRC = view.getUint32(data.length - 4, false); - const computedMessageCRC = crc32(data.slice(0, data.length - 4)); - if (messageCRC !== computedMessageCRC) { - console.warn( - `[Kiro] Message CRC mismatch: expected ${messageCRC}, got ${computedMessageCRC} — skipping corrupted frame` - ); - return null; + // Message CRC covers bytes [0..totalLength-5] (everything except the CRC itself). + // Skipped by default (O(frame bytes) per frame) — the prelude CRC above already + // validates framing and the stream is TLS-protected. Enable KIRO_VERIFY_FULL_CRC=true + // to restore full validation for debugging corrupted-stream issues. + if (KIRO_VERIFY_FULL_CRC) { + const messageCRC = view.getUint32(data.length - 4, false); + const computedMessageCRC = crc32(data.slice(0, data.length - 4)); + if (messageCRC !== computedMessageCRC) { + console.warn( + `[Kiro] Message CRC mismatch: expected ${messageCRC}, got ${computedMessageCRC} — skipping corrupted frame` + ); + return null; + } } // Parse headers const headers: Record = {}; diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index aa46e38bfa..0088d2a4e9 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "crypto"; import { BaseExecutor, setUserAgentHeader, @@ -106,6 +107,20 @@ export class OpencodeExecutor extends BaseExecutor { findClientHeader("x-session-affinity") || findClientHeader("x-session-id"); if (sessionAffinity) { headers["x-opencode-session"] = sessionAffinity; + + // #4465: a custom-named provider only reaches this fallback because the + // OpenCode CLI did NOT emit the x-opencode-* set (it only does so when the + // provider id starts with "opencode"). It therefore also dropped + // x-opencode-request, a per-request correlation id. Synthesize one so these + // users are not disadvantaged versus opencode-prefixed providers on the + // opencode.ai upstream. x-opencode-client / x-opencode-project are NOT + // fabricated: their valid values are opencode-internal and inventing them + // could be rejected upstream — they remain forward-only above. Scoped to this + // executor (opencode.ai/zen) and only to the fallback path, so the direct + // OpenCode CLI flow (which controls its own request id) is untouched. + if (!headers["x-opencode-request"]) { + headers["x-opencode-request"] = randomUUID(); + } } } } diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 76eb361ca1..fcfa18e5da 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -1,4 +1,5 @@ import { injectMemoryAndSkills } from "./chatCore/memorySkillsInjection.ts"; +import { resolveChatCoreRequestSetup } from "./chatCore/requestSetup.ts"; import { checkIdempotencyCache } from "./chatCore/idempotency.ts"; import { checkSemanticCache } from "./chatCore/semanticCache.ts"; import { sanitizeChatRequestBody } from "./chatCore/sanitization.ts"; @@ -82,6 +83,7 @@ import { import { resolveModelAlias } from "../services/modelDeprecation.ts"; import { normalizeMimoThinking } from "../services/mimoThinking.ts"; import { normalizeClaudeAdaptiveThinking } from "../services/claudeAdaptiveThinking.ts"; +import { echoModelInObject, createModelEchoTransform } from "../services/responseModelEcho.ts"; import { stripGpt5SamplingWhenReasoning } from "../services/gpt5SamplingGuard.ts"; import { getUnsupportedParams } from "../config/providerRegistry.ts"; import { supportsMaxTokens } from "@/lib/modelCapabilities.ts"; @@ -191,10 +193,10 @@ import { shouldRequestClaudeFastMode, } from "@/lib/providers/claudeFastMode"; import { - getCodexRequestDefaults, - normalizeCodexServiceTier, - type CodexServiceTier, -} from "@/lib/providers/requestDefaults"; + resolveEffectiveServiceTier as resolveEffectiveServiceTierFor, + resolveReportedServiceTier as resolveReportedServiceTierFor, + type EffectiveServiceTier, +} from "./chatCore/serviceTier.ts"; import { cacheReasoningFromAssistantMessage } from "../services/reasoningCache.ts"; import { sanitizeOpenAITool } from "../services/toolSchemaSanitizer.ts"; import { @@ -649,27 +651,12 @@ export async function handleChatCore({ /* memoryUsage() never throws */ } - // apiFormat is an optional custom-model marker injected by getModelInfo for - // providers whose models can route to /chat/completions or /responses - // (Azure AI Foundry, OCI generic OpenAI). It's not on the base ModelInfo - // shape, so we read it via a structural narrowing without `as any`. - const apiFormat: string | undefined = - modelInfo && typeof modelInfo === "object" && "apiFormat" in modelInfo - ? typeof (modelInfo as { apiFormat?: unknown }).apiFormat === "string" - ? ((modelInfo as { apiFormat?: string }).apiFormat as string) - : undefined - : undefined; - // #2905: per-model wire-format override for custom models, injected by - // getModelInfo. Custom models are not in the static registry, so - // getModelTargetFormat() can't see this — use it before the provider default. - const customModelTargetFormat: string | undefined = - modelInfo && typeof modelInfo === "object" && "targetFormat" in modelInfo - ? typeof (modelInfo as { targetFormat?: unknown }).targetFormat === "string" - ? ((modelInfo as { targetFormat?: string }).targetFormat as string) - : undefined - : undefined; - const requestedModel = - typeof body?.model === "string" && body.model.trim().length > 0 ? body.model : model; + // Per-request model-routing metadata (first extracted slice of the request-setup phase). + const { apiFormat, customModelTargetFormat, requestedModel } = resolveChatCoreRequestSetup( + modelInfo, + body, + model + ); const isModelScope = () => isModelScopeProvider(provider, credentials?.providerSpecificData); const startTime = Date.now(); // Per-request trace id + checkpoint helper. Lets us see exactly which await @@ -750,42 +737,15 @@ export async function handleChatCore({ ); } - type EffectiveServiceTier = "standard" | CodexServiceTier; let effectiveServiceTier: EffectiveServiceTier = "standard"; - const resolveEffectiveServiceTier = (requestBody?: unknown): EffectiveServiceTier => { - if (provider !== "codex") return "standard"; - const requestRecord = - requestBody && typeof requestBody === "object" && !Array.isArray(requestBody) - ? (requestBody as Record) - : {}; - const rawServiceTier = requestRecord.service_tier; - if (typeof rawServiceTier === "string" && rawServiceTier.trim().length > 0) { - const normalizedServiceTier = normalizeCodexServiceTier(rawServiceTier); - if (normalizedServiceTier) return normalizedServiceTier; - } - return getCodexRequestDefaults(credentials?.providerSpecificData).serviceTier ?? "standard"; - }; + // Codex service-tier resolvers extracted to chatCore/serviceTier.ts (#3501); bind the per-request + // provider/credentials once and delegate so the existing call sites stay byte-identical. + const resolveEffectiveServiceTier = (requestBody?: unknown): EffectiveServiceTier => + resolveEffectiveServiceTierFor(provider, credentials?.providerSpecificData, requestBody); const resolveReportedServiceTier = ( payload?: unknown, maxDepth = 3 - ): EffectiveServiceTier | null => { - if ( - maxDepth <= 0 || - provider !== "codex" || - !payload || - typeof payload !== "object" || - Array.isArray(payload) - ) { - return null; - } - const record = payload as Record; - const rawServiceTier = record.service_tier; - if (typeof rawServiceTier === "string" && rawServiceTier.trim().length > 0) { - const normalizedServiceTier = normalizeCodexServiceTier(rawServiceTier); - if (normalizedServiceTier) return normalizedServiceTier; - } - return resolveReportedServiceTier(record.response, maxDepth - 1); - }; + ): EffectiveServiceTier | null => resolveReportedServiceTierFor(provider, payload, maxDepth); const persistFailureUsage = (statusCode: number, errorCode?: string | null) => { saveRequestUsage({ provider: provider || "unknown", @@ -1111,6 +1071,15 @@ export async function handleChatCore({ const noLogEnabled = apiKeyInfo?.noLog === true; // Consolidate settings reads — fetch once, reuse throughout the request const settings = cachedSettings ?? (await getCachedSettings()); + // #1311 (opt-in): echo the client-requested alias/combo name in the response `model` + // field instead of the upstream model, so strict clients (Claude Desktop) that validate + // response.model === request.model stop rejecting alias/combo requests with a 401. + const echoModel = + settings.echoRequestedModelName === true && + typeof requestedModel === "string" && + requestedModel + ? requestedModel + : null; const detailedLoggingEnabled = !noLogEnabled && (settings.call_log_pipeline_enabled === true || @@ -1457,8 +1426,13 @@ export async function handleChatCore({ // --- Modular Compression Pipeline (Phase 1 Lite + Phase 2 Standard/Caveman + Phase 3 Aggressive) --- // Runs BEFORE the existing reactive compressContext() to proactively reduce tokens. try { - const { selectCompressionStrategy, applyCompressionAsync, resolveCacheAwareConfig } = - await import("../services/compression/strategySelector.ts"); + const { + selectCompressionStrategy, + selectCompressionPlan, + enginesMapDerivesStackedPipeline, + applyCompressionAsync, + resolveCacheAwareConfig, + } = await import("../services/compression/strategySelector.ts"); const { trackCompressionStats } = await import("../services/compression/stats.ts"); let config: CompressionConfig = compressionSettings ?? { enabled: false, @@ -1636,7 +1610,12 @@ export async function handleChatCore({ modeBeforeOutputTransform === "stacked" && !compressionComboApplied && !config.compressionComboId && - isBuiltinStackedPipeline(config.stackedPipeline) + isBuiltinStackedPipeline(config.stackedPipeline) && + // Don't let the legacy default combo override a panel-configured engines map: when the + // operator's explicit engines derive their own stacked pipeline, that pipeline (applied + // below from compressionPlan.stackedPipeline) is authoritative. Legacy/backfilled + // installs (enginesExplicit false) still fall through to the seeded default combo. + !enginesMapDerivesStackedPipeline(config) ) { try { const { getDefaultCompressionCombo } = @@ -1686,13 +1665,29 @@ export async function handleChatCore({ } } const compressionInputBody = body as Record; - const mode = selectCompressionStrategy( + const compressionPlan = selectCompressionPlan( config, compressionComboKey, estimatedTokens, compressionInputBody, { provider, targetFormat, model: effectiveModel } ); + const mode = compressionPlan.mode as CompressionConfig["defaultMode"]; + // When the per-engine toggle map derives a stacked pipeline (and no named/routing + // combo already set config.stackedPipeline), feed that derived pipeline through so + // applyCompressionAsync (which reads config.stackedPipeline for stacked mode) runs the + // engines the operator actually toggled on instead of the built-in rtk+caveman default. + if ( + mode === "stacked" && + compressionPlan.stackedPipeline.length > 0 && + !compressionComboApplied && + !config.compressionComboId + ) { + config = { + ...config, + stackedPipeline: compressionPlan.stackedPipeline as CompressionConfig["stackedPipeline"], + }; + } let compressionAnalyticsRecorded = false; if (mode !== "off") { // #3890: in a caching context, never compress the system prompt (cacheable prefix) @@ -4651,6 +4646,8 @@ export async function handleChatCore({ costUsd: estimatedCost, requestId: skillRequestId, }); + // #1311: echo the requested alias/combo name in the non-streaming response model. + if (echoModel) echoModelInObject(translatedResponse, echoModel); return { success: true, response: new Response(JSON.stringify(translatedResponse), { @@ -5067,6 +5064,10 @@ export async function handleChatCore({ shape: shapeForClientFormat(clientResponseFormat), }) ); + // #1311: echo the requested alias/combo name in each streamed SSE chunk's model field. + if (echoModel) { + finalStream = finalStream.pipeThrough(createModelEchoTransform(echoModel)); + } // ── Gamification event (fire-and-forget) ── if (apiKeyInfo?.id) { diff --git a/open-sse/handlers/chatCore/nonStreamingSse.ts b/open-sse/handlers/chatCore/nonStreamingSse.ts index eb513cf7f2..23e3da9517 100644 --- a/open-sse/handlers/chatCore/nonStreamingSse.ts +++ b/open-sse/handlers/chatCore/nonStreamingSse.ts @@ -113,6 +113,16 @@ function processNonStreamingSseTerminalLine( if (data === "[DONE]") return true; if (!data) return false; + // Hot-path optimization: the terminal SSE events we look for (message_stop, + // response.completed, …) all carry a top-level "type" field, OR are signalled by a + // preceding `event:` line (Claude). OpenAI chat.completion chunks carry neither and + // terminate with `[DONE]` (handled above), so parsing every one of them here is pure + // waste that compounds into the CPU-runaway on large buffered responses. Skip the + // JSON.parse unless the line could actually be a typed terminal. + if (!data.includes('"type"')) { + return NON_STREAMING_SSE_TERMINAL_TYPES.has(state.currentEvent); + } + try { const parsed = JSON.parse(data); const eventType = diff --git a/open-sse/handlers/chatCore/requestSetup.ts b/open-sse/handlers/chatCore/requestSetup.ts new file mode 100644 index 0000000000..6c193cc4a9 --- /dev/null +++ b/open-sse/handlers/chatCore/requestSetup.ts @@ -0,0 +1,51 @@ +/** + * chatCore request-setup resolvers (Quality Gate v2 / Fase 9 — chatCore god-file decomposition). + * + * The first pure slice of handleChatCore's request-setup phase: the per-request model-routing + * metadata resolved at the very top of the handler. Side-effect-free; future increments grow this + * into the full ChatCoreContext carrier (setup / dispatch / streaming phases). + */ + +export type ChatCoreRequestSetup = { + /** + * Optional custom-model wire-format marker injected by getModelInfo for providers whose models + * can route to /chat/completions or /responses (Azure AI Foundry, OCI generic OpenAI). Not on + * the base ModelInfo shape, so read via structural narrowing (never `as any`). + */ + apiFormat: string | undefined; + /** + * #2905: per-model wire-format override for custom models, injected by getModelInfo. Custom + * models aren't in the static registry, so getModelTargetFormat() can't see this — used before + * the provider default. + */ + customModelTargetFormat: string | undefined; + /** The client-requested model string, falling back to the resolved model id when absent/blank. */ + requestedModel: string; +}; + +/** + * Resolve the per-request model-routing metadata at the top of handleChatCore. Pure: a function of + * the injected modelInfo, the request body, and the resolved model id. Behaviour is byte-identical + * to the previous inline code. + */ +export function resolveChatCoreRequestSetup( + modelInfo: unknown, + body: { model?: unknown } | null | undefined, + model: string +): ChatCoreRequestSetup { + const apiFormat: string | undefined = + modelInfo && typeof modelInfo === "object" && "apiFormat" in modelInfo + ? typeof (modelInfo as { apiFormat?: unknown }).apiFormat === "string" + ? ((modelInfo as { apiFormat?: string }).apiFormat as string) + : undefined + : undefined; + const customModelTargetFormat: string | undefined = + modelInfo && typeof modelInfo === "object" && "targetFormat" in modelInfo + ? typeof (modelInfo as { targetFormat?: unknown }).targetFormat === "string" + ? ((modelInfo as { targetFormat?: string }).targetFormat as string) + : undefined + : undefined; + const requestedModel = + typeof body?.model === "string" && body.model.trim().length > 0 ? body.model : model; + return { apiFormat, customModelTargetFormat, requestedModel }; +} diff --git a/open-sse/handlers/chatCore/serviceTier.ts b/open-sse/handlers/chatCore/serviceTier.ts new file mode 100644 index 0000000000..e79fb4a8bf --- /dev/null +++ b/open-sse/handlers/chatCore/serviceTier.ts @@ -0,0 +1,70 @@ +/** + * chatCore Codex service-tier resolvers (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * Pure, side-effect-free extraction of the two service-tier resolvers that used to be inline + * closures at the top of handleChatCore. Both are no-ops for non-Codex providers; for Codex they + * read the client-supplied `service_tier`, normalize it, and fall back to the per-connection + * request defaults. Behaviour is byte-identical to the previous inline code — the handler now binds + * `provider`/`providerSpecificData` once and delegates here. + */ + +import { + getCodexRequestDefaults, + normalizeCodexServiceTier, + type CodexServiceTier, +} from "@/lib/providers/requestDefaults"; + +/** The effective service tier carried through a request: "standard" or a normalized Codex tier. */ +export type EffectiveServiceTier = "standard" | CodexServiceTier; + +/** + * Resolve the effective service tier for the *outbound* request. Non-Codex providers are always + * "standard". For Codex, a valid client `service_tier` wins; otherwise the per-connection request + * default applies, falling back to "standard". + */ +export function resolveEffectiveServiceTier( + provider: string | null | undefined, + providerSpecificData: unknown, + requestBody?: unknown +): EffectiveServiceTier { + if (provider !== "codex") return "standard"; + const requestRecord = + requestBody && typeof requestBody === "object" && !Array.isArray(requestBody) + ? (requestBody as Record) + : {}; + const rawServiceTier = requestRecord.service_tier; + if (typeof rawServiceTier === "string" && rawServiceTier.trim().length > 0) { + const normalizedServiceTier = normalizeCodexServiceTier(rawServiceTier); + if (normalizedServiceTier) return normalizedServiceTier; + } + return getCodexRequestDefaults(providerSpecificData).serviceTier ?? "standard"; +} + +/** + * Resolve the service tier *reported by the upstream* response. Non-Codex providers and missing + * payloads return null (caller keeps the prior value). For Codex, reads a top-level `service_tier` + * and otherwise descends through nested `response` envelopes up to `maxDepth` levels. + */ +export function resolveReportedServiceTier( + provider: string | null | undefined, + payload?: unknown, + maxDepth = 3 +): EffectiveServiceTier | null { + if ( + maxDepth <= 0 || + provider !== "codex" || + !payload || + typeof payload !== "object" || + Array.isArray(payload) + ) { + return null; + } + const record = payload as Record; + const rawServiceTier = record.service_tier; + if (typeof rawServiceTier === "string" && rawServiceTier.trim().length > 0) { + const normalizedServiceTier = normalizeCodexServiceTier(rawServiceTier); + if (normalizedServiceTier) return normalizedServiceTier; + } + return resolveReportedServiceTier(provider, record.response, maxDepth - 1); +} diff --git a/open-sse/handlers/embeddings.ts b/open-sse/handlers/embeddings.ts index 99e06d2fc5..05ecda2d9f 100644 --- a/open-sse/handlers/embeddings.ts +++ b/open-sse/handlers/embeddings.ts @@ -144,6 +144,20 @@ export async function handleEmbedding({ } } + // Gemini embedding models (gemini-embedding-001 / -2-preview / text-embedding-004) + // default to 3072-dim vectors. Clients targeting pgvector-style schemas typically + // request a smaller size (e.g. 1536) via OpenAI's `dimensions` field, but Google's + // OpenAI-compatibility shim at /v1beta/openai/embeddings does not document the + // `dimensions` → `outputDimensionality` translation. Mirror the request value into + // the Gemini-native `outputDimensionality` field so the upstream actually returns + // the requested vector size. Ported from upstream decolua/9router#1366. + if (provider === "gemini" && upstreamBody.outputDimensionality === undefined) { + const outputDimensionality = Number(body.dimensions); + if (Number.isFinite(outputDimensionality) && outputDimensionality > 0) { + upstreamBody.outputDimensionality = outputDimensionality; + } + } + // Inject model-level default params (e.g. NVIDIA NIM asymmetric models require // `input_type`) only for keys the client did not already supply, so a // client-sent value is never overwritten. Symmetric models carry no defaults diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index ed2fd5b014..a809a88d1d 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -81,6 +81,7 @@ import { skillRegistry } from "../../src/lib/skills/registry.ts"; import { skillExecutor } from "../../src/lib/skills/executor.ts"; import { pluginTools } from "./tools/pluginTools.ts"; import { compressionTools } from "./tools/compressionTools.ts"; +import { poolTools } from "./tools/poolTools.ts"; import { gamificationTools } from "./tools/gamificationTools.ts"; import { notionTools } from "./tools/notionTools.ts"; import { obsidianTools } from "./tools/obsidianTools.ts"; @@ -114,6 +115,7 @@ const TOTAL_MCP_TOOL_COUNT = Object.keys(memoryTools).length + Object.keys(skillTools).length + Object.keys(agentSkillTools).length + + Object.keys(poolTools).length + gamificationTools.length + pluginTools.length + notionTools.length + @@ -855,6 +857,7 @@ export function createMcpServer(): McpServer { ...Object.keys(memoryTools), ...Object.keys(skillTools), ...Object.keys(compressionTools), + ...Object.keys(poolTools), ...pluginTools.map((t) => t.name), ...gamificationTools.map((t) => t.name), ...obsidianTools.map((t) => t.name), @@ -1290,6 +1293,44 @@ export function createMcpServer(): McpServer { ); }); + // ── Web-Session Pool Tools (#3368 observability) ─ + // Typed structurally (not `any`) — the shape is pinned by + // tests/unit/mcp-tool-collections-shape.test.ts, so the loop can stay strict. + Object.values(poolTools).forEach( + (toolDef: { + name: string; + description: string; + scopes: readonly string[]; + inputSchema: { parse: (input: unknown) => unknown }; + handler: (parsedArgs: unknown) => Promise; + }) => { + server.registerTool( + toolDef.name, + { + description: toolDef.description, + // @ts-ignore: dynamic zod access + inputSchema: toolDef.inputSchema, + }, + withScopeEnforcement( + toolDef.name, + async (args) => { + try { + const parsedArgs = toolDef.inputSchema.parse(args ?? {}); + const result = await toolDef.handler(parsedArgs); + return { + content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } + }, + toolDef.scopes + ) + ); + } + ); + // ── Gamification Tools ──────────────────────── gamificationTools.forEach((toolDef) => { server.registerTool( diff --git a/open-sse/mcp-server/tools/poolTools.ts b/open-sse/mcp-server/tools/poolTools.ts index 13b5184a11..4437e68f88 100644 --- a/open-sse/mcp-server/tools/poolTools.ts +++ b/open-sse/mcp-server/tools/poolTools.ts @@ -147,6 +147,7 @@ export const poolTools = { name: "omniroute_pool_status", description: "Returns session pool status for a specific provider or all providers. Includes session counts by state (active/cooldown/dead), request totals, success rate, and throughput.", + scopes: ["read:health"], inputSchema: poolStatusInput, handler: (args: z.infer) => handlePoolStatus(args), }, @@ -154,6 +155,7 @@ export const poolTools = { name: "omniroute_pool_sessions", description: "Lists all sessions in a provider's pool with per-session details: fingerprint, status, request counts, inflight, cooldown remaining, and age.", + scopes: ["read:health"], inputSchema: poolSessionsInput, handler: (args: z.infer) => handlePoolSessions(args), }, @@ -161,6 +163,7 @@ export const poolTools = { name: "omniroute_pool_reset", description: "Shuts down and removes all sessions for a provider's pool. A new pool will be created automatically on the next request.", + scopes: ["write:resilience"], inputSchema: poolResetInput, handler: (args: z.infer) => handlePoolReset(args), }, @@ -168,6 +171,7 @@ export const poolTools = { name: "omniroute_pool_warm", description: "Warms a session pool to the specified session count (1–50). Sessions beyond the current count are created with fresh browser fingerprints.", + scopes: ["write:resilience"], inputSchema: poolWarmInput, handler: (args: z.infer) => handlePoolWarm(args), }, @@ -175,6 +179,7 @@ export const poolTools = { name: "omniroute_pool_health", description: "Returns aggregated web-session pool health: pool stats + circuit breaker state + per-session details + health status (healthy/degraded/down) + issues list.", + scopes: ["read:health"], inputSchema: poolHealthInput, handler: (args: z.infer) => handlePoolHealth(args), }, diff --git a/open-sse/package.json b/open-sse/package.json index 519787213a..c75c1b9b4a 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "3.8.31", + "version": "3.8.32", "description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration", "type": "module", "main": "index.js", diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 545691bf34..aa5c29ef5f 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -479,6 +479,27 @@ export function lockModel( }); } +/** + * Pick the `exactCooldownMs` to apply to a model lockout (#1308). + * + * When the upstream response carried an explicit reset longer than the base + * cooldown — e.g. Antigravity "Resets in 160h", a `Retry-After` header, or a + * parseable reset text already extracted by `checkFallbackError`/`parseRetryFromErrorText` + * into `parsedCooldownMs` — honor it exactly so an exhausted model is not retried + * again within minutes. Otherwise preserve the previous behavior: return `0` to let + * `recordModelLockoutFailure` apply its exponential backoff, or the base cooldown when + * backoff is disabled. + */ +export function selectLockoutCooldownMs( + parsedCooldownMs: number, + settings: { baseCooldownMs: number; useExponentialBackoff: boolean } +): number { + if (typeof parsedCooldownMs === "number" && parsedCooldownMs > settings.baseCooldownMs) { + return parsedCooldownMs; + } + return settings.useExponentialBackoff ? 0 : settings.baseCooldownMs; +} + export function recordModelLockoutFailure( provider: string, connectionId: string, diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index d3535fe05b..f93b4c43cd 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -14,6 +14,7 @@ import { isModelLocked, recordModelLockoutFailure, recordProviderFailure, + selectLockoutCooldownMs, } from "./accountFallback.ts"; import { RateLimitReason } from "../config/constants.ts"; import { errorResponse, unavailableResponse } from "../utils/error.ts"; @@ -26,6 +27,7 @@ import { import { resolveComboConfig, getDefaultComboConfig, + resolveComboQueueDepth, } from "./comboConfig.ts"; import { maybeGenerateHandoff, @@ -1722,9 +1724,9 @@ export async function handleComboChat({ mlSettings.baseCooldownMs, profile, { - exactCooldownMs: mlSettings.useExponentialBackoff - ? 0 - : mlSettings.baseCooldownMs, + // #1308: honor a long upstream reset (e.g. "Resets in 160h") over + // the short base cooldown / exponential backoff when present. + exactCooldownMs: selectLockoutCooldownMs(cooldownMs, mlSettings), } ); lockoutRecorded = true; @@ -1764,7 +1766,8 @@ export async function handleComboChat({ mlSettings.baseCooldownMs, profile, { - exactCooldownMs: mlSettings.useExponentialBackoff ? 0 : mlSettings.baseCooldownMs, + // #1308: honor a long upstream reset over base/exponential cooldown. + exactCooldownMs: selectLockoutCooldownMs(cooldownMs, mlSettings), } ); } @@ -1942,6 +1945,9 @@ async function handleRoundRobinCombo({ : { ...getDefaultComboConfig(), ...(combo.config || {}) }; const concurrency = config.concurrencyPerModel ?? 3; const queueTimeout = config.queueTimeoutMs ?? 30000; + // #3872: pre-cascade queue depth — lower values fail over to the next combo member + // sooner under concurrency saturation (0 = never queue). Default 20 (backward-compat). + const queueDepth = resolveComboQueueDepth(config); const maxRetries = config.maxRetries ?? 1; const retryDelayMs = resolveDelayMs(config.retryDelayMs, 2000); const fallbackDelayMs = resolveDelayMs(config.fallbackDelayMs, 0); @@ -1980,11 +1986,17 @@ async function handleRoundRobinCombo({ log ); - // Sticky batch size at the combo level. Reuses the global `stickyRoundRobinLimit` - // setting so a single knob controls sticky batching for both account fallback and - // combo targets. Values <= 1 preserve the historical one-request-per-target rotation. + // Sticky batch size at the combo level. A per-combo `stickyRoundRobinLimit` (in + // combo.config, resolved through the cascade) overrides the global setting so one + // combo can batch differently from the default. When the per-combo value is unset, + // fall back to the global `stickyRoundRobinLimit` so the existing knob still controls + // sticky batching for both account fallback and combo targets. Values <= 1 preserve + // the historical one-request-per-target rotation. + const perComboStickyLimit = (config as Record).stickyRoundRobinLimit; const stickyLimit = clampStickyRoundRobinTargetLimit( - (settings as Record | null)?.stickyRoundRobinLimit + perComboStickyLimit !== undefined && perComboStickyLimit !== null + ? perComboStickyLimit + : (settings as Record | null)?.stickyRoundRobinLimit ); const stickyRoundRobinEnabled = stickyLimit > 1; if ( @@ -2084,6 +2096,7 @@ async function handleRoundRobinCombo({ release = await semaphore.acquire(semaphoreKey, { maxConcurrency: concurrency, timeoutMs: queueTimeout, + maxQueueSize: queueDepth, }); } catch (err) { const errCode = isRecord(err) && typeof err.code === "string" ? err.code : null; diff --git a/open-sse/services/combo/shadowRouting.ts b/open-sse/services/combo/shadowRouting.ts index 2fe214df77..5483b4320d 100644 --- a/open-sse/services/combo/shadowRouting.ts +++ b/open-sse/services/combo/shadowRouting.ts @@ -13,6 +13,7 @@ * `resolveShadowTargets`, never during module init. */ +import { secureRandomFloat } from "../../../src/shared/utils/secureRandom"; import { recordComboShadowRequest } from "../comboMetrics.ts"; import { isRecord } from "./comboData.ts"; import { resolveNestedComboTargets } from "./comboStructure.ts"; @@ -50,7 +51,7 @@ export function resolveShadowTargets( ): ResolvedComboTarget[] { const shadowConfig = normalizeShadowRoutingConfig(config); if (!shadowConfig.enabled || shadowConfig.targets.length === 0) return []; - if (shadowConfig.sampleRate <= 0 || Math.random() > shadowConfig.sampleRate) return []; + if (shadowConfig.sampleRate <= 0 || secureRandomFloat() > shadowConfig.sampleRate) return []; const shadowCombo: ComboLike = { ...combo, diff --git a/open-sse/services/combo/targetSorters.ts b/open-sse/services/combo/targetSorters.ts index 7829e7512b..3432b639fe 100644 --- a/open-sse/services/combo/targetSorters.ts +++ b/open-sse/services/combo/targetSorters.ts @@ -8,6 +8,7 @@ */ import { getCircuitBreaker } from "../../../src/shared/utils/circuitBreaker"; +import { secureRandomFloat, secureRandomInt } from "../../../src/shared/utils/secureRandom"; import { getComboStepTarget, getComboStepWeight } from "../../../src/lib/combos/steps.ts"; import { getComboMetrics } from "../comboMetrics.ts"; import { parseModel } from "../model.ts"; @@ -29,10 +30,10 @@ export function selectWeightedTarget(targets: T[] const totalWeight = targets.reduce((sum, target) => sum + (target.weight || 0), 0); if (totalWeight <= 0) { - return targets[Math.floor(Math.random() * targets.length)]; + return targets[secureRandomInt(targets.length)]; } - let random = Math.random() * totalWeight; + let random = secureRandomFloat() * totalWeight; for (const target of targets) { random -= target.weight || 0; if (random <= 0) return target; @@ -159,8 +160,8 @@ function getP2CTargetScore( export function orderTargetsByPowerOfTwoChoices(targets: ResolvedComboTarget[], comboName: string) { if (targets.length <= 1) return targets; const metrics = getComboMetrics(comboName); - const firstIndex = Math.floor(Math.random() * targets.length); - let secondIndex = Math.floor(Math.random() * (targets.length - 1)); + const firstIndex = secureRandomInt(targets.length); + let secondIndex = secureRandomInt(targets.length - 1); if (secondIndex >= firstIndex) secondIndex++; const first = targets[firstIndex]; diff --git a/open-sse/services/comboConfig.ts b/open-sse/services/comboConfig.ts index f8fc7e742c..932f67e1cb 100644 --- a/open-sse/services/comboConfig.ts +++ b/open-sse/services/comboConfig.ts @@ -25,6 +25,18 @@ export const PRE_SCREEN_CONCURRENCY = 5; */ export const DEFAULT_COMBO_TARGET_TIMEOUT_MS = 120_000; +/** + * Default pre-cascade semaphore queue depth for round-robin combos (#3872). When a + * combo member's concurrency slot is saturated, this many requests wait in the + * member's queue before `SEMAPHORE_QUEUE_FULL` triggers a cascade to the next member. + * Kept at 20 for backward compatibility; operators wanting faster failover can lower + * it (0 = never queue, fail over to the next member immediately). + */ +export const DEFAULT_COMBO_QUEUE_DEPTH = 20; + +/** Upper bound for the configurable combo queue depth (defensive clamp). */ +export const MAX_COMBO_QUEUE_DEPTH = 100; + const DEFAULT_COMBO_CONFIG = { strategy: "priority", maxRetries: 1, @@ -32,6 +44,7 @@ const DEFAULT_COMBO_CONFIG = { fallbackDelayMs: 0, concurrencyPerModel: 3, // max simultaneous requests per model (round-robin) queueTimeoutMs: 30000, // max wait time in semaphore queue (round-robin) + queueDepth: DEFAULT_COMBO_QUEUE_DEPTH, // pre-cascade semaphore queue depth (round-robin, #3872) handoffThreshold: 0.85, handoffModel: "", handoffProviders: ["codex"], @@ -147,6 +160,18 @@ export function resolveComboTargetTimeoutMs( return Math.min(fallbackDefaultMs, ceilingTimeoutMs); } +/** + * Resolve the effective pre-cascade semaphore queue depth for a round-robin combo + * (#3872). Falls back to `DEFAULT_COMBO_QUEUE_DEPTH` for missing/invalid/negative + * values and clamps to `MAX_COMBO_QUEUE_DEPTH`. `0` is valid and meaningful: it makes + * a saturated combo member fail over to the next member immediately instead of queueing. + */ +export function resolveComboQueueDepth(config: Record | null | undefined): number { + const raw = isRecord(config) ? Number(config.queueDepth) : Number.NaN; + if (!Number.isFinite(raw) || raw < 0) return DEFAULT_COMBO_QUEUE_DEPTH; + return Math.min(Math.floor(raw), MAX_COMBO_QUEUE_DEPTH); +} + /** * Resolve effective config for a combo, applying cascade: * DEFAULT_COMBO_CONFIG → settings.comboDefaults → settings.providerOverrides[provider] → combo.config diff --git a/open-sse/services/compression/deriveDefaultPlan.ts b/open-sse/services/compression/deriveDefaultPlan.ts new file mode 100644 index 0000000000..ee7960fe69 --- /dev/null +++ b/open-sse/services/compression/deriveDefaultPlan.ts @@ -0,0 +1,48 @@ +import { ENGINE_CATALOG, engineMeta } from "./engineCatalog.ts"; +import type { EngineToggle } from "./types.ts"; + +/** Maps single-mode engine ids to the effective CompressionMode name. */ +const SINGLE_MODE_OF: Record = { + lite: "lite", + caveman: "standard", + aggressive: "aggressive", + ultra: "ultra", + rtk: "rtk", +}; + +export interface DerivedPlan { + mode: string; + stackedPipeline: Array<{ engine: string; intensity?: string }>; +} + +/** + * Derives the effective compression plan from the per-engine toggle map. + * + * Rules (evaluated in order): + * 1. masterEnabled=false OR no engines on → { mode:"off", stackedPipeline:[] } + * 2. Exactly one engine on AND it is single-mode → that engine's standalone mode + * 3. Otherwise → { mode:"stacked", stackedPipeline: enabled engines sorted by stackPriority } + */ +export function deriveDefaultPlan( + engines: Record, + 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 }; +} diff --git a/open-sse/services/compression/engineCatalog.ts b/open-sse/services/compression/engineCatalog.ts new file mode 100644 index 0000000000..ff1c481b55 --- /dev/null +++ b/open-sse/services/compression/engineCatalog.ts @@ -0,0 +1,84 @@ +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 = { + "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]; +} diff --git a/open-sse/services/compression/engines/rtk/index.ts b/open-sse/services/compression/engines/rtk/index.ts index 9ea257e056..438d8aa405 100644 --- a/open-sse/services/compression/engines/rtk/index.ts +++ b/open-sse/services/compression/engines/rtk/index.ts @@ -11,6 +11,7 @@ import { normalizeCodeLanguage, stripCode } from "./codeStripper.ts"; import { maybePersistRtkRawOutput, type RtkRawOutputPointer } from "./rawOutput.ts"; import { isTextBlock } from "../../messageContent.ts"; import { adaptBodyForCompression } from "../../bodyAdapter.ts"; +import { isAnthropicToolResultBlock } from "../../toolResultCompressor.ts"; type Message = { role: string; @@ -18,6 +19,32 @@ type Message = { [key: string]: unknown; }; +type ToolMeta = { toolName: string; command: string | null }; + +// Same terminal-tool pattern as grok-web.ts isTerminalTool(): RTK's command-aware filters +// only apply to bash/shell tool results. Non-shell tools (read, glob, grep, edit, write…) +// skip filter matching to avoid content-based false positives (e.g. a .ts file matching +// the build-typescript filter). +const SHELL_TOOL_NAME_RE = /\b(bash|shell|terminal|run_command|execute_command|exec|command)\b/; + +/** + * Resolve the shell command + whether to skip RTK filters for a tool result, given the + * tool id (OpenAI `tool_call_id` / Anthropic `tool_use_id`) and the lookup built from the + * preceding assistant tool calls. A missing entry runs filters with text-based command + * detection; a non-shell tool name skips filters. + */ +function resolveToolMeta( + toolId: string | null, + lookup: Map +): { command: string | null; skipFilters: boolean } { + const meta = toolId ? lookup.get(toolId) : null; + if (!meta) return { command: null, skipFilters: false }; + if (SHELL_TOOL_NAME_RE.test(meta.toolName.toLowerCase())) { + return { command: meta.command, skipFilters: false }; + } + return { command: null, skipFilters: true }; +} + const RTK_SCHEMA: EngineConfigField[] = [ { key: "intensity", @@ -188,6 +215,14 @@ function mergeRtkConfig(base?: Partial, override?: Record +): { + content: Message["content"]; + compressed: boolean; + techniquesUsed: string[]; + rulesApplied: string[]; + rawOutputPointers: RtkRawOutputPointer[]; +} { + const techniquesUsed: string[] = []; + const rulesApplied: string[] = []; + const rawOutputPointers: RtkRawOutputPointer[] = []; + + if (!Array.isArray(content)) { + return { content, compressed: false, techniquesUsed, rulesApplied, rawOutputPointers }; + } + + const collect = (processed: RtkProcessResult) => { + techniquesUsed.push(...processed.techniquesUsed); + rulesApplied.push(...processed.rulesApplied); + if (processed.rawOutputPointers) rawOutputPointers.push(...processed.rawOutputPointers); + }; + + let compressed = false; + const nextContent = content.map((part) => { + if (!isAnthropicToolResultBlock(part)) return part; + const toolUseId = typeof part.tool_use_id === "string" ? part.tool_use_id : null; + const { command, skipFilters } = resolveToolMeta(toolUseId, toolCallLookup); + const inner = part.content; + + if (typeof inner === "string") { + if (!inner) return part; + const processed = processRtkText(inner, { config, command, skipFilters }); + collect(processed); + if (!processed.compressed) return part; + compressed = true; + return { ...part, content: processed.text }; + } + + if (Array.isArray(inner)) { + let blockChanged = false; + const nextInner = inner.map((sub) => { + if (!isTextBlock(sub) || !sub.text) return sub; + const processed = processRtkText(sub.text, { config, command, skipFilters }); + collect(processed); + if (!processed.compressed) return sub; + blockChanged = true; + compressed = true; + return { ...sub, text: processed.text }; + }); + return blockChanged ? { ...part, content: nextInner } : part; + } + + return part; + }); + + return { + content: compressed ? nextContent : content, + compressed, + techniquesUsed, + rulesApplied, + rawOutputPointers, + }; +} + function processRtkContent( content: Message["content"], config: RtkConfig, @@ -497,57 +604,79 @@ export function applyRtkCompression( // Build tool_call_id → tool metadata lookup from assistant messages. // This lets us distinguish bash tool results (which RTK filters are designed for) // from non-shell tool results (read, grep, glob, etc.) that should skip filters. - const toolCallLookup = new Map(); + const toolCallLookup = new Map(); for (const msg of messages) { if (msg.role !== "assistant") continue; + // OpenAI-shape: assistant.tool_calls[].function.{name, arguments(JSON).command} const toolCalls = msg.tool_calls; - if (!Array.isArray(toolCalls)) continue; - for (const tc of toolCalls as Array>) { - if (!tc || typeof tc !== "object") continue; - const id = typeof tc.id === "string" ? tc.id : null; - if (!id) continue; - const fn = tc.function as Record | undefined; - if (!fn || typeof fn !== "object") continue; - const toolName = typeof fn.name === "string" ? fn.name : ""; - let command: string | null = null; - if (typeof fn.arguments === "string") { - try { - const args = JSON.parse(fn.arguments); - command = - typeof args.command === "string" - ? args.command - : typeof args.cmd === "string" - ? args.cmd - : null; - } catch { - // non-JSON arguments + if (Array.isArray(toolCalls)) { + for (const tc of toolCalls as Array>) { + if (!tc || typeof tc !== "object") continue; + const id = typeof tc.id === "string" ? tc.id : null; + if (!id) continue; + const fn = tc.function as Record | undefined; + if (!fn || typeof fn !== "object") continue; + const toolName = typeof fn.name === "string" ? fn.name : ""; + let command: string | null = null; + if (typeof fn.arguments === "string") { + try { + const args = JSON.parse(fn.arguments); + command = + typeof args.command === "string" + ? args.command + : typeof args.cmd === "string" + ? args.cmd + : null; + } catch { + // non-JSON arguments + } } + toolCallLookup.set(id, { toolName, command }); + } + } + // Anthropic-shape: assistant.content[] holds { type:"tool_use", id, name, input.command }. + if (Array.isArray(msg.content)) { + for (const part of msg.content as Array>) { + if (!part || typeof part !== "object" || part.type !== "tool_use") continue; + const id = typeof part.id === "string" ? part.id : null; + if (!id) continue; + const toolName = typeof part.name === "string" ? part.name : ""; + const input = part.input as Record | undefined; + let command: string | null = null; + if (input && typeof input === "object") { + command = + typeof input.command === "string" + ? input.command + : typeof input.cmd === "string" + ? input.cmd + : null; + } + toolCallLookup.set(id, { toolName, command }); } - toolCallLookup.set(id, { toolName, command }); } } const compressedMessages = messages.map((message) => { if (!shouldCompressMessage(message, config)) return message; - // Resolve tool metadata from the preceding assistant tool_calls entry. + // Anthropic-shape tool results: `tool_result` content blocks inside a (typically + // role:"user") message. Compress each block's inner text, resolving the shell command + // per block from the matching assistant `tool_use` (mirrors the OpenAI tool path). + if (Array.isArray(message.content) && message.content.some(isAnthropicToolResultBlock)) { + const processed = processToolResultBlocks(message.content, config, toolCallLookup); + allTechniques.push(...processed.techniquesUsed); + allRules.push(...processed.rulesApplied); + rawOutputPointers.push(...processed.rawOutputPointers); + if (!processed.compressed) return message; + return { ...message, content: processed.content }; + } + + // OpenAI-shape tool message: resolve metadata from the preceding assistant tool_calls. let command: string | null = null; let skipFilters = false; if (message.role === "tool") { const callId = typeof message.tool_call_id === "string" ? message.tool_call_id : null; - const meta = callId ? toolCallLookup.get(callId) : null; - if (meta) { - const name = meta.toolName.toLowerCase(); - // Match the same terminal tool pattern used in grok-web.ts isTerminalTool(). - // Only apply RTK filters to bash/shell tool results. - // Non-shell tools (read, glob, grep, edit, write, etc.) skip filter matching - // to prevent content-based false positives (e.g. a TS file matching build-typescript). - if (/\b(bash|shell|terminal|run_command|execute_command|exec|command)\b/.test(name)) { - command = meta.command; - } else { - skipFilters = true; - } - } + ({ command, skipFilters } = resolveToolMeta(callId, toolCallLookup)); } const processed = processRtkContent(message.content, config, { command, skipFilters }); diff --git a/open-sse/services/compression/resolveCompressionPlan.ts b/open-sse/services/compression/resolveCompressionPlan.ts new file mode 100644 index 0000000000..a9b0543a9d --- /dev/null +++ b/open-sse/services/compression/resolveCompressionPlan.ts @@ -0,0 +1,47 @@ +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>; // 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; +} diff --git a/open-sse/services/compression/strategySelector.ts b/open-sse/services/compression/strategySelector.ts index a9810adf3d..973fdf1017 100644 --- a/open-sse/services/compression/strategySelector.ts +++ b/open-sse/services/compression/strategySelector.ts @@ -20,6 +20,8 @@ import { getCacheAwareStrategy, type CachingDetectionContext, } from "./cachingAware.ts"; +import { resolveCompressionPlan } from "./resolveCompressionPlan.ts"; +import { deriveDefaultPlan, type DerivedPlan } from "./deriveDefaultPlan.ts"; export function checkComboOverride( config: CompressionConfig, @@ -33,19 +35,122 @@ export function shouldAutoTrigger(config: CompressionConfig, estimatedTokens: nu return config.autoTriggerTokens > 0 && estimatedTokens >= config.autoTriggerTokens; } +/** + * Resolves the effective compression plan (mode + derived stacked pipeline) WITHOUT + * the caching-aware mode adjustment (that is layered on by {@link selectCompressionPlan}). + * + * Precedence — preserved from the historical {@link getEffectiveMode} ordering: + * 1. master off → off + * 2. routing-combo override (comboId)→ that mode (resolver honors it via ctx.comboId) + * 3. auto-trigger (large prompt) → autoTriggerMode, BEFORE the plain derived default + * 4. derived default → resolveCompressionPlan (engines map → mode/pipeline) + * + * Step 3 mirrors today's behaviour: auto-trigger takes precedence over the plain + * derived default but never over an explicit routing-combo override. + * + * `combos` is `{}` in Phase 1 — the active named-combo selection UI is Phase 2, and the + * resolver falls through to the derived default when no combo is supplied. chatCore still + * resolves named/default combos via its own DB path (mutating config.stackedPipeline). + */ +function resolveBasePlan( + config: CompressionConfig, + comboId: string | null, + estimatedTokens: number +): DerivedPlan { + if (!config.enabled) return { mode: "off", stackedPipeline: [] }; + + const comboMode = checkComboOverride(config, comboId); + if (comboMode) { + // A routing-combo "stacked" override still wants the configured stacked pipeline, + // so route it through the resolver (which reads config.stackedPipeline for stacked). + return resolveCompressionPlan(config, { comboId, combos: {} }); + } + + if (shouldAutoTrigger(config, estimatedTokens)) { + const mode = config.autoTriggerMode ?? "lite"; + return mode === "stacked" + ? { mode, stackedPipeline: config.stackedPipeline ?? [] } + : { mode, stackedPipeline: [] }; + } + + return deriveDefaultPlanFromConfig(config, comboId); +} + +/** + * Derived-default step. The per-engine toggle map drives the default ONLY when it was + * EXPLICITLY configured via the panel (a stored `engines` row — `config.enginesExplicit`). + * For legacy installs the map is backfilled for DISPLAY only (so the panel shows current + * state); dispatch falls back to the historical `config.defaultMode` so behaviour is + * byte-for-byte preserved until the operator opts into the panel by saving. This avoids a + * silent behaviour change for installs whose backfilled engine flags don't exactly match + * their old defaultMode. + */ +function deriveDefaultPlanFromConfig( + config: CompressionConfig, + comboId: string | null +): DerivedPlan { + if (config.enginesExplicit) { + // Panel-configured: the engines map (via the resolver, which stays header/active-combo + // aware for Phases 2-3) is authoritative — including an explicit "everything off". + return resolveCompressionPlan(config, { comboId, combos: {} }); + } + + // Legacy path: defaultMode carries the effective mode (the engines map is display-only here). + const legacyMode = config.defaultMode; + if (legacyMode && legacyMode !== "off") { + return legacyMode === "stacked" + ? { mode: legacyMode, stackedPipeline: config.stackedPipeline ?? [] } + : { mode: legacyMode, stackedPipeline: [] }; + } + + return { mode: "off", stackedPipeline: [] }; +} + +/** + * True when the EXPLICITLY-configured engines map (panel-saved) derives a multi-engine + * stacked pipeline. chatCore uses this to know the panel's derived pipeline is authoritative + * and the legacy default-combo fallback must NOT override it. Returns false for legacy + * (non-explicit) installs so their historical default-combo path is preserved untouched. + */ +export function enginesMapDerivesStackedPipeline(config: CompressionConfig): boolean { + if (!config.enginesExplicit) return false; + const plan = deriveDefaultPlan(config.engines ?? {}, config.enabled !== false); + return plan.mode === "stacked" && plan.stackedPipeline.length > 0; +} + export function getEffectiveMode( config: CompressionConfig, comboId: string | null, estimatedTokens: number ): CompressionMode { - if (!config.enabled) return "off"; + return resolveBasePlan(config, comboId, estimatedTokens).mode as CompressionMode; +} - const comboMode = checkComboOverride(config, comboId); - if (comboMode) return comboMode; +/** + * Like {@link selectCompressionStrategy} but returns the full derived plan + * (effective `mode` + `stackedPipeline`). When the resolver derives a `stacked` + * plan from the per-engine toggle map, the pipeline is exposed here so the caller + * can feed it to {@link applyCompressionAsync} (which reads config.stackedPipeline). + * The caching-aware mode adjustment is applied to `mode` exactly as in + * {@link selectCompressionStrategy}. + */ +export function selectCompressionPlan( + config: CompressionConfig, + comboId: string | null, + estimatedTokens: number, + body?: Record, + context?: CachingDetectionContext +): DerivedPlan { + const plan = resolveBasePlan(config, comboId, estimatedTokens); - if (shouldAutoTrigger(config, estimatedTokens)) return config.autoTriggerMode ?? "lite"; + // Apply caching-aware adjustments to the mode if body is provided + if (body) { + const ctx = detectCachingContext(body, context); + const cacheAware = getCacheAwareStrategy(plan.mode as CompressionMode, ctx); + return { ...plan, mode: cacheAware.strategy as CompressionMode }; + } - return config.defaultMode; + return plan; } export function selectCompressionStrategy( @@ -55,16 +160,7 @@ export function selectCompressionStrategy( body?: Record, context?: CachingDetectionContext ): CompressionMode { - const selectedMode = getEffectiveMode(config, comboId, estimatedTokens); - - // Apply caching-aware adjustments if body is provided - if (body) { - const ctx = detectCachingContext(body, context); - const cacheAware = getCacheAwareStrategy(selectedMode, ctx); - return cacheAware.strategy as CompressionMode; - } - - return selectedMode; + return selectCompressionPlan(config, comboId, estimatedTokens, body, context).mode as CompressionMode; } /** @@ -82,6 +178,10 @@ export function resolveCacheAwareConfig( ): CompressionConfig { if (!body) return config; const ctx = detectCachingContext(body, context); + // Only `skipSystemPrompt` is consumed here, and it depends solely on `ctx.isCachingProvider` + // (NOT on the strategy arg — see getCacheAwareStrategy), so the stored `defaultMode` is a safe + // input even though it may be "off" for a panel-configured install. If getCacheAwareStrategy is + // ever extended to key `skipSystemPrompt` on the mode, pass the resolved effective mode instead. const cacheAware = getCacheAwareStrategy(config.defaultMode, ctx); if (cacheAware.skipSystemPrompt && config.preserveSystemPrompt === false) { return { ...config, preserveSystemPrompt: true }; diff --git a/open-sse/services/compression/types.ts b/open-sse/services/compression/types.ts index 94db8751d0..e086cbfe50 100644 --- a/open-sse/services/compression/types.ts +++ b/open-sse/services/compression/types.ts @@ -9,6 +9,15 @@ * Phase 5: 'rtk' and 'stacked' modes (tool-output filters + multi-engine pipeline). */ +import { ENGINE_IDS } from "./engineCatalog.ts"; + +// Re-export so consumers that already import from this module (e.g. src/lib/db/compression.ts) +// can get ENGINE_IDS without a second bare `@omniroute/open-sse/...engineCatalog.ts` specifier. +// That bare alias resolves under tsc/tsx but NOT under vitest (Vite externalizes a brand-new +// open-sse module to Node, which then can't load the `.ts` subpath), whereas this module is +// already in Vite's graph and its relative `./engineCatalog.ts` import resolves in-pipeline. +export { ENGINE_IDS }; + export type CompressionMode = | "off" | "lite" @@ -108,6 +117,11 @@ export interface CompressionPipelineStep { config?: Record; } +export interface EngineToggle { + enabled: boolean; + level?: string; +} + export interface CompressionConfig { enabled: boolean; defaultMode: CompressionMode; @@ -127,6 +141,17 @@ export interface CompressionConfig { ultra?: UltraConfig; /** Provider-delegated context editing (Claude/Anthropic only). */ contextEditing?: ContextEditingConfig; + /** Per-engine opt-in toggles for the config panel. */ + engines: Record; + /** Active combo preset id, or null if none selected. */ + activeComboId: string | null; + /** + * Runtime-only (NOT persisted): true when a stored `engines` row exists, i.e. the operator + * configured engines via the panel. When false, the `engines` map is a display-only backfill + * and dispatch falls back to the legacy `defaultMode`/default-combo path (zero behaviour + * change for installs that predate the panel). Set by `getCompressionSettings`. + */ + enginesExplicit?: boolean; } export interface CompressionStats { @@ -193,6 +218,8 @@ export const DEFAULT_COMPRESSION_CONFIG: CompressionConfig = { { engine: "rtk", intensity: "standard" }, { engine: "caveman", intensity: "full" }, ], + engines: Object.fromEntries(ENGINE_IDS.map((id) => [id, { enabled: false }])), + activeComboId: null, }; export const DEFAULT_CAVEMAN_CONFIG: CavemanConfig = { diff --git a/open-sse/services/rateLimitSemaphore.ts b/open-sse/services/rateLimitSemaphore.ts index daf4c01b50..7ace90b18f 100644 --- a/open-sse/services/rateLimitSemaphore.ts +++ b/open-sse/services/rateLimitSemaphore.ts @@ -25,6 +25,14 @@ interface ModelGate { interface AcquireOptions { maxConcurrency?: number; timeoutMs?: number; + /** + * Maximum number of requests allowed to wait in the per-model queue (#3872). When + * the queue is already this deep, a new acquire rejects immediately with + * `SEMAPHORE_QUEUE_FULL` instead of waiting, so the round-robin combo loop cascades + * to the next member right away (0 = never queue → fail over immediately). Omitted / + * negative keeps the historical unbounded-queue behavior. + */ + maxQueueSize?: number; } interface RateLimitStatsEntry { @@ -124,12 +132,13 @@ function createReleaseFn(modelStr: string): () => void { * @param {Object} [options] * @param {number} [options.maxConcurrency=3] - Max concurrent requests for this model * @param {number} [options.timeoutMs=30000] - Max wait time in queue + * @param {number} [options.maxQueueSize] - Max queued waiters before SEMAPHORE_QUEUE_FULL (#3872) * @returns {Promise} Release function — MUST be called when done - * @throws {Error} If queue timeout expires ("SEMAPHORE_TIMEOUT") + * @throws {Error} If queue timeout expires ("SEMAPHORE_TIMEOUT") or the queue is full ("SEMAPHORE_QUEUE_FULL") */ export function acquire( modelStr: string, - { maxConcurrency = 3, timeoutMs = 30000 }: AcquireOptions = {} + { maxConcurrency = 3, timeoutMs = 30000, maxQueueSize }: AcquireOptions = {} ): Promise<() => void> { const gate = getGate(modelStr, maxConcurrency); @@ -139,6 +148,18 @@ export function acquire( return Promise.resolve(createReleaseFn(modelStr)); } + // #3872: bounded queue — once the queue is full, fail fast so the round-robin combo + // cascades to the next member immediately instead of deep-queueing for up to timeoutMs. + if (typeof maxQueueSize === "number" && maxQueueSize >= 0 && gate.queue.length >= maxQueueSize) { + const err = new Error(`Semaphore queue full (${maxQueueSize}) for ${modelStr}`) as Error & { + code?: string; + }; + err.code = "SEMAPHORE_QUEUE_FULL"; + // Drop a freshly-created idle gate so we don't leak an empty entry. + if (gate.running === 0 && gate.queue.length === 0) gates.delete(modelStr); + return Promise.reject(err); + } + // Slow path: enqueue and wait return new Promise((resolve, reject) => { const timer = setTimeout(() => { diff --git a/open-sse/services/responseModelEcho.ts b/open-sse/services/responseModelEcho.ts new file mode 100644 index 0000000000..0a920d2422 --- /dev/null +++ b/open-sse/services/responseModelEcho.ts @@ -0,0 +1,79 @@ +/** + * #1311: echo the client-requested model/alias name back in the response. + * + * When a request uses an alias or combo (e.g. `claude-sonnet-cx` → `cx/gpt-5.5`), + * OmniRoute forwards the upstream model name (`gpt-5.5`) in the response `model` + * field. Strict clients (e.g. Claude Desktop) validate that the response model + * matches the request and reject the mismatch with a 401. This opt-in helper + * rewrites the `model` field back to the name the client asked for. + * + * The behavior is gated by a global setting (`echoRequestedModelName`, default off), + * so the default response stays byte-for-byte unchanged. + */ + +/** + * Rewrite the top-level `model` field of a parsed response object (Chat Completions + * JSON or an OpenAI SSE chunk) to `echoModel`. Mutates and returns `obj`. No-op when + * `echoModel` is falsy or `obj` has no string `model` field. + */ +export function echoModelInObject(obj: unknown, echoModel: string | null | undefined): unknown { + if (!echoModel) return obj; + if (obj && typeof obj === "object" && !Array.isArray(obj)) { + const rec = obj as Record; + if (typeof rec.model === "string") { + rec.model = echoModel; + } + } + return obj; +} + +/** + * Rewrite the `model` field inside a single SSE line. Only `data: {json}` lines that + * carry a string `model` are rewritten; `data: [DONE]`, comments, event lines, and + * unparseable payloads pass through untouched. + */ +export function echoModelInSseLine(line: string, echoModel: string | null | undefined): string { + if (!echoModel) return line; + if (!line.startsWith("data:")) return line; + const payload = line.slice(5).trim(); + if (!payload || payload === "[DONE]" || payload[0] !== "{") return line; + try { + const parsed = JSON.parse(payload) as Record; + if (typeof parsed.model !== "string") return line; + parsed.model = echoModel; + return `data: ${JSON.stringify(parsed)}`; + } catch { + return line; + } +} + +/** + * A TransformStream that rewrites the `model` field in every SSE `data:` chunk of a + * UTF-8 byte stream to `echoModel`. Buffers across chunk boundaries so a `data:` frame + * split across two reads is still rewritten correctly. Used as the final pipe stage of + * the streaming response when the echo setting is on. + */ +export function createModelEchoTransform(echoModel: string | null | undefined): TransformStream { + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let buffer = ""; + return new TransformStream({ + transform(chunk, controller) { + buffer += decoder.decode(chunk, { stream: true }); + // Emit complete lines; keep the trailing partial line in the buffer. + const lastNewline = buffer.lastIndexOf("\n"); + if (lastNewline === -1) return; + const ready = buffer.slice(0, lastNewline + 1); + buffer = buffer.slice(lastNewline + 1); + const rewritten = ready + .split("\n") + .map((line) => echoModelInSseLine(line, echoModel)) + .join("\n"); + controller.enqueue(encoder.encode(rewritten)); + }, + flush(controller) { + const tail = buffer + decoder.decode(); + if (tail) controller.enqueue(encoder.encode(echoModelInSseLine(tail, echoModel))); + }, + }); +} diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index b8a52aecfe..4e8c338c24 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -319,6 +319,41 @@ function shouldDisplayGitHubQuota(quota: UsageQuota | null): quota is UsageQuota return quota.total > 0 || quota.remainingPercentage !== undefined; } +function isKiroOverageEnabled(data: JsonRecord): boolean { + const overageConfiguration = toRecord(data.overageConfiguration); + const overageStatus = String(overageConfiguration.overageStatus || "") + .trim() + .toUpperCase(); + + return ( + overageStatus === "ENABLED" || + data.overageEnabled === true || + overageConfiguration.overageEnabled === true + ); +} + +function buildKiroQuota( + used: number, + total: number, + resetAt: string | null, + overageEnabled: boolean +): UsageQuota { + const remaining = total - used; + + if (!overageEnabled) { + return { used, total, remaining, resetAt, unlimited: false }; + } + + return { + used, + total, + remaining, + remainingPercentage: 100, + resetAt, + unlimited: true, + }; +} + function pickFirstNonEmptyString(...values: unknown[]): string | undefined { for (const value of values) { if (typeof value !== "string") continue; @@ -1476,6 +1511,7 @@ export const USAGE_FETCHER_PROVIDERS = [ "kiro", "amazon-q", "kimi-coding", + "kimi-coding-apikey", "qwen", "qoder", "glm", @@ -1538,6 +1574,8 @@ export async function getUsageForProvider( return await getVertexUsage(id || "", provider); case "kimi-coding": return await getKimiUsage(accessToken); + case "kimi-coding-apikey": + return await getKimiUsage(undefined, apiKey); case "qwen": return await getQwenUsage(accessToken, providerSpecificData); case "qoder": @@ -1812,6 +1850,23 @@ function inferGitHubPlanName(data: JsonRecord, premiumQuota: UsageQuota | null): const _geminiCliSubCache = new Map(); const GEMINI_CLI_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes +/** + * Normalize a Cloud Code project value into a trimmed string (or null). + * The upstream `loadCodeAssist` endpoint returns the project either as a bare + * string or as an object of the form `{ id: "..." }`, and stored connection + * project ids can carry stray whitespace. Centralized here so the Gemini CLI + * usage path matches the executor/oauth normalization already shipped in + * `open-sse/executors/gemini-cli.ts` and `src/lib/oauth/services/gemini.ts`. + */ +function normalizeCloudCodeProjectId(project: unknown): string | null { + if (typeof project === "string") return project.trim() || null; + if (project && typeof project === "object") { + const candidate = (project as { id?: unknown }).id; + if (typeof candidate === "string") return candidate.trim() || null; + } + return null; +} + /** * Gemini CLI Usage — fetch per-model quota from Cloud Code Assist API. * Gemini CLI and Antigravity share the same upstream (cloudcode-pa.googleapis.com), @@ -1827,17 +1882,28 @@ async function getGeminiUsage( } try { - const subscriptionInfo = await getGeminiCliSubscriptionInfoCached(accessToken); - const projectId = - connectionProjectId || - providerSpecificData?.projectId || - toRecord(subscriptionInfo).cloudaicompanionProject || - null; - - const plan = getGeminiCliPlanLabel(subscriptionInfo); + // #1271: the OAuth save path stores `projectId` on the connection (not always in + // `providerSpecificData`), and `loadCodeAssist` may return the project either as a + // bare string or wrapped in `{ id: "..." }`. Normalize both so the quota lookup + // reuses the stored project id and skips a redundant `loadCodeAssist` round-trip + // when it is already known. + let projectId = + normalizeCloudCodeProjectId(connectionProjectId) || + normalizeCloudCodeProjectId(providerSpecificData?.projectId); + let plan = "Free"; if (!projectId) { - return { plan, message: "Gemini CLI project ID not available." }; + const subscriptionInfo = await getGeminiCliSubscriptionInfoCached(accessToken); + projectId = normalizeCloudCodeProjectId(toRecord(subscriptionInfo).cloudaicompanionProject); + plan = getGeminiCliPlanLabel(subscriptionInfo); + } + + if (!projectId) { + return { + plan, + message: + "Gemini CLI project ID not available. Reconnect Gemini CLI, or configure a Google Cloud project with Gemini Code Assist access before checking quota.", + }; } // Use retrieveUserQuota (same endpoint as Gemini CLI /stats command). @@ -2905,6 +2971,7 @@ export function buildKiroUsageResult( const usageList = Array.isArray(data.usageBreakdownList) ? data.usageBreakdownList : []; const quotaInfo: Record = {}; const resetAt = parseResetTime(data.nextDateReset || data.resetDate); + const overageEnabled = isKiroOverageEnabled(data); usageList.forEach((breakdownValue: unknown) => { const breakdown = toRecord(breakdownValue); @@ -2913,19 +2980,18 @@ export function buildKiroUsageResult( const used = toNumber(breakdown.currentUsageWithPrecision, 0); const total = toNumber(breakdown.usageLimitWithPrecision, 0); - quotaInfo[resourceType] = { used, total, remaining: total - used, resetAt, unlimited: false }; + quotaInfo[resourceType] = buildKiroQuota(used, total, resetAt, overageEnabled); const freeTrialInfo = toRecord(breakdown.freeTrialInfo); if (Object.keys(freeTrialInfo).length > 0) { const freeUsed = toNumber(freeTrialInfo.currentUsageWithPrecision, 0); const freeTotal = toNumber(freeTrialInfo.usageLimitWithPrecision, 0); - quotaInfo[`${resourceType}_freetrial`] = { - used: freeUsed, - total: freeTotal, - remaining: freeTotal - freeUsed, + quotaInfo[`${resourceType}_freetrial`] = buildKiroQuota( + freeUsed, + freeTotal, resetAt, - unlimited: false, - }; + overageEnabled + ); } }); @@ -3123,7 +3189,7 @@ function getKimiPlanName(level: unknown): string { * Kimi Coding Usage - Fetch quota from Kimi API * Uses the official /v1/usages endpoint with custom X-Msh-* headers */ -async function getKimiUsage(accessToken?: string) { +async function getKimiUsage(accessToken?: string, apiKey?: string) { // Generate device info for headers (same as OAuth flow) const deviceId = "kimi-usage-" + Date.now(); const platform = "omniroute"; @@ -3131,16 +3197,28 @@ async function getKimiUsage(accessToken?: string) { const deviceModel = typeof process !== "undefined" ? `${process.platform} ${process.arch}` : "unknown"; - try { - const response = await fetch(KIMI_CONFIG.usageUrl, { - method: "GET", - headers: { + // API key auth takes precedence — Kimi's /usages endpoint accepts the same + // API key used for /messages (verified live: responds with + // authentication.method = METHOD_API_KEY). OAuth flow falls through to the + // Bearer + device-headers shape used by Kimi Coding OAuth. + const useApiKey = typeof apiKey === "string" && apiKey.length > 0; + + const authHeaders: Record = useApiKey + ? { "x-api-key": apiKey as string } + : { Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", "X-Msh-Platform": platform, "X-Msh-Version": version, "X-Msh-Device-Model": deviceModel, "X-Msh-Device-Id": deviceId, + }; + + try { + const response = await fetch(KIMI_CONFIG.usageUrl, { + method: "GET", + headers: { + ...authHeaders, + "Content-Type": "application/json", }, }); diff --git a/open-sse/services/webSearchFallback.ts b/open-sse/services/webSearchFallback.ts index 61fe2355c3..f82b565630 100644 --- a/open-sse/services/webSearchFallback.ts +++ b/open-sse/services/webSearchFallback.ts @@ -129,7 +129,16 @@ function buildFallbackTool(tool: JsonRecord, targetFormat?: string | null): Json }; } +// Providers whose endpoint advertises Claude/Anthropic format but does NOT implement +// Anthropic's typed server tools (web_search_20250305, …). For these the Claude -> Claude +// bypass below must NOT apply: forwarding the native server tool makes the upstream 400 +// (MiniMax returns `invalid params, function name or parameters is empty (2013)`), so the +// built-in web-search tool has to be converted to the omniroute_web_search function +// fallback — which these models accept as a normal function tool (#4481). +const CLAUDE_FORMAT_PROVIDERS_WITHOUT_SERVER_TOOLS = new Set(["minimax"]); + export function supportsNativeWebSearchFallbackBypass({ + provider, sourceFormat, targetFormat, nativeCodexPassthrough, @@ -147,7 +156,11 @@ export function supportsNativeWebSearchFallbackBypass({ // subscription driven by Claude Code) natively runs web_search_20250305. Forward the // native tool untouched instead of rewriting it to omniroute_web_search. Mirrors the // Codex/Gemini bypasses so every native-web-search provider is treated symmetrically. - if (sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE) return true; + if (sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE) { + // …except Anthropic-compatible providers that don't actually implement server tools. + if (provider && CLAUDE_FORMAT_PROVIDERS_WITHOUT_SERVER_TOOLS.has(provider)) return false; + return true; + } return false; } diff --git a/open-sse/translator/helpers/claudeHelper.ts b/open-sse/translator/helpers/claudeHelper.ts index 088c354b67..43b1a73132 100644 --- a/open-sse/translator/helpers/claudeHelper.ts +++ b/open-sse/translator/helpers/claudeHelper.ts @@ -1,6 +1,18 @@ // Claude helper functions for translator import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; import { lookupReasoning, recordReplay } from "../../services/reasoningCache.ts"; +import { getModelTargetFormat } from "../../config/providerModels.ts"; + +// MiniMax exposes a Claude-compatible endpoint but rejects Anthropic's extended +// `output_config` parameter (used to steer reasoning effort and structured output) +// with a generic 400 "invalid params" response. Strip the entire field before +// dispatching Claude-shape requests to these providers. Anthropic Claude and +// other Claude-compatible upstreams that do accept it are unaffected. +// Ported from upstream decolua/9router#820 by @hiepau1231. +const CLAUDE_FORMAT_PROVIDERS_WITHOUT_OUTPUT_CONFIG = new Set([ + "minimax", + "minimax-cn", +]); // Placeholder thinking text used as last-resort fallback when: // - Target upstream is a non-Anthropic Claude-shape provider @@ -209,8 +221,16 @@ function markMessageCacheControl(msg: ClaudeMessage, ttl?: string): boolean { export function prepareClaudeRequest( body: ClaudeRequestBody, provider: string | null = null, - preserveCacheControl = false + preserveCacheControl = false, + model: string | null = null ): ClaudeRequestBody { + // 0. Strip Anthropic `output_config` for providers that reject it on their + // Claude-compatible endpoints (MiniMax). Must run before any downstream + // processing so the field never reaches translateRequest/the executor. + if (provider && CLAUDE_FORMAT_PROVIDERS_WITHOUT_OUTPUT_CONFIG.has(provider)) { + delete body.output_config; + } + // 1. System: remove all cache_control, add only to last block with ttl 1h // In passthrough mode, preserve existing cache_control markers const supportsPromptCaching = @@ -225,7 +245,13 @@ export function prepareClaudeRequest( // We use the same allowlist as prompt-caching: only Anthropic-native // upstreams get redacted_thinking. Everything else gets plain thinking blocks // backed by reasoningCache (real text) or a placeholder (cache miss). - const supportsRedactedThinking = supportsPromptCaching; + // Mixed-format providers (e.g. opencode-go) may have some models targeting + // Anthropic's Messages API (targetFormat=claude) and others targeting OpenAI. + // When the specific model targets Claude format, it's hitting a real Anthropic + // endpoint that validates signatures — so it needs redacted_thinking too. + const modelTargetsClaude = + !!provider && !!model && getModelTargetFormat(provider, model) === "claude"; + const supportsRedactedThinking = supportsPromptCaching || modelTargetsClaude; const systemBlocks = body.system; if (systemBlocks && Array.isArray(systemBlocks) && !preserveCacheControl) { diff --git a/open-sse/translator/helpers/geminiHelper.ts b/open-sse/translator/helpers/geminiHelper.ts index eb29936904..1efb572181 100644 --- a/open-sse/translator/helpers/geminiHelper.ts +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -102,6 +102,26 @@ export function convertOpenAIContentToParts(content: unknown): JsonRecord[] { if (rec.type === "text") { parts.push({ text: rec.text }); } else { + // 0. Handle OpenAI audio input parts → Gemini inlineData (#912). + // Chat Completions shape: {type:"input_audio", input_audio:{data, format}}. + // Some clients use {type:"audio", audio:{data, format}}. Gemini accepts + // audio as inlineData with an `audio/` mime type (wav, mp3, ...). + // Without this branch the part matches no handler below and is silently + // dropped on the OpenAI→Gemini/Antigravity path. + if (rec.type === "input_audio" || rec.type === "audio") { + const audioObj = toRecord(rec.input_audio || rec.audio); + if (typeof audioObj.data === "string") { + const fmt = String(audioObj.format || "wav").toLowerCase(); + parts.push({ + inlineData: { + mimeType: `audio/${fmt}`, + data: audioObj.data.replace(/^data:[a-zA-Z0-9/+-]+;base64,/, ""), + }, + }); + continue; + } + } + // 1. Handle Gemini native inline_data injected into OpenAI arrays (e.g. Cherry Studio) const geminiInline = toRecord(rec.inline_data || rec.inlineData); if (geminiInline?.data) { diff --git a/open-sse/translator/helpers/toolCallShim.ts b/open-sse/translator/helpers/toolCallShim.ts index cc92174386..43ca09481d 100644 --- a/open-sse/translator/helpers/toolCallShim.ts +++ b/open-sse/translator/helpers/toolCallShim.ts @@ -27,16 +27,51 @@ function coerceToArray(v: unknown): unknown[] { return []; } +// Claude Code's Read tool caps `limit` at 2000 lines per call. Non-Anthropic models +// (GPT-5.5, DeepSeek …) occasionally emit absurd values (e.g. `limit: 25999999999999999`) +// that Claude Code rejects, causing a retry loop that wastes tokens. Clamp here. +const READ_MAX_LIMIT = 2000; + +// `pages` is only meaningful for PDFs and only as `"N"` or `"N-M"` (1-based). +// Reference: claude-code-tools docs + upstream decolua/9router#1144. +function isValidPdfPagesArg(filePath: unknown, pages: unknown): boolean { + return ( + typeof filePath === "string" && + filePath.toLowerCase().endsWith(".pdf") && + typeof pages === "string" && + /^\d+(?:-\d+)?$/.test(pages) + ); +} + +function sanitizeReadArgs(args: Record): void { + // Coerce numeric-string limit/offset (some non-Anthropic models stringify everything). + if (typeof args.limit === "string" && /^\d+$/.test(args.limit)) { + args.limit = Number(args.limit); + } + if (typeof args.offset === "string" && /^-?\d+$/.test(args.offset)) { + args.offset = Number(args.offset); + } + + if (typeof args.limit === "number") { + if (args.limit > READ_MAX_LIMIT) args.limit = READ_MAX_LIMIT; + if (args.limit < 1) delete args.limit; + } + if (typeof args.offset === "number" && args.offset < 0) args.offset = 0; + + if ("pages" in args && !isValidPdfPagesArg(args.file_path, args.pages)) { + delete args.pages; + } +} + const TOOL_SHIMS: Record = { - // Claude Code Read accepts `pages` only for PDFs and rejects an empty string. - // Some non-Anthropic models emit optional `pages: ""` for ordinary files. - // Buffer and emit one cleaned JSON delta so the client never sees the bad field. + // Claude Code Read rejects bad params and retries — wasting tokens with non-Anthropic + // models that emit oversized limits, negative offsets, stringified numbers, or stray + // `pages` on non-PDF files. Buffer and emit one cleaned JSON delta so the client never + // sees the bad fields. See `sanitizeReadArgs` for the per-field rules. Read: (input) => { if (typeof input !== "object" || input === null || Array.isArray(input)) return input; const patched = { ...(input as Record) }; - if (patched.pages === "" || (Array.isArray(patched.pages) && patched.pages.length === 0)) { - delete patched.pages; - } + sanitizeReadArgs(patched); return patched; }, submit_pr_review: (input) => { diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 8920568278..d22ddbb3f8 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -256,7 +256,7 @@ export function translateRequest( if (targetFormat === FORMATS.CLAUDE) { const isClaudePassthrough = sourceFormat === FORMATS.CLAUDE; const preserveCache = isClaudePassthrough || options?.preserveCacheControl === true; - result = prepareClaudeRequest(result, provider, preserveCache); + result = prepareClaudeRequest(result, provider, preserveCache, model); } // Normalize openai-responses input shape for providers that require list input. diff --git a/open-sse/translator/request/claude-to-openai.ts b/open-sse/translator/request/claude-to-openai.ts index f426c99857..013fd77a4d 100644 --- a/open-sse/translator/request/claude-to-openai.ts +++ b/open-sse/translator/request/claude-to-openai.ts @@ -136,6 +136,30 @@ export function claudeToOpenAIRequest(model, body, stream, credentials: unknown // Fix missing tool responses - OpenAI requires every tool_call to have a response fixMissingToolResponses(result.messages); + // #4385: drop orphan tool results — a role:"tool" message whose tool_call_id has no + // matching assistant.tool_calls (e.g. history truncation / compression removed the + // assistant turn that issued the call but kept the tool_result). OpenAI-compatible + // upstreams reject these with 502 "Messages with role 'tool' must be a response to a + // preceding message with 'tool_calls'". Mirrors the filter already applied on the + // Responses->Chat path in openai-responses.ts (#2893). Run after fixMissingToolResponses + // so the inserted "[No response received]" placeholders (which DO match a tool_call) + // are kept, and only genuinely orphaned results are removed. + const assistantToolCallIds = new Set(); + for (const msg of result.messages) { + const calls = (msg as JsonRecord).tool_calls; + if (Array.isArray(calls)) { + for (const tc of calls as { id?: string }[]) { + if (tc.id) assistantToolCallIds.add(String(tc.id)); + } + } + } + result.messages = result.messages.filter((msg) => { + if ((msg as JsonRecord).role === "tool") { + return assistantToolCallIds.has(String((msg as JsonRecord).tool_call_id ?? "")); + } + return true; + }); + const useNativeResponsesWebSearch = shouldUseNativeResponsesWebSearch(credentials); // Tools diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 228ba673e3..bc2a36c8c7 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -163,7 +163,14 @@ export function openaiResponsesToOpenAIRequest( let currentAssistantMsg: JsonRecord | null = null; let pendingToolResults: JsonRecord[] = []; - const inputItems = toArray(root.input); + // Upstream providers reject messages:[] with "400: at least one message is required". + // When the client sends input:[] (empty), inject a placeholder user message — mirrors + // upstream 9router#419 (and the existing empty-string handling elsewhere in this file). + const rawInputItems = toArray(root.input); + const inputItems: unknown[] = + rawInputItems.length === 0 + ? [{ type: "message", role: "user", content: [{ type: "input_text", text: "..." }] }] + : rawInputItems; for (const itemValue of inputItems) { const item = toRecord(itemValue); diff --git a/open-sse/translator/response/openai-to-gemini-sse.ts b/open-sse/translator/response/openai-to-gemini-sse.ts new file mode 100644 index 0000000000..087af65ee8 --- /dev/null +++ b/open-sse/translator/response/openai-to-gemini-sse.ts @@ -0,0 +1,351 @@ +/** + * Convert an OpenAI Chat Completions stream/response into the Gemini + * `:streamGenerateContent` / `:generateContent` shape used by the + * `@google/genai` SDK (Gemini CLI). + * + * Why this exists + * --------------- + * The `/v1beta/models/{model}:streamGenerateContent` route delegates the + * actual LLM call to `handleChat`, which always returns OpenAI-format SSE: + * + * data: {"choices":[{"delta":{"content":"Hi"},"finish_reason":null}],...} + * data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{...},...} + * data: [DONE] + * + * `@google/genai` expects Gemini SSE, which has a different chunk shape + * AND no terminal sentinel — the stream simply closes: + * + * data: {"candidates":[{"content":{"role":"model","parts":[{"text":"Hi"}]},"index":0}]} + * data: {"candidates":[{"content":{"role":"model","parts":[{"text":""}]}, + * "finishReason":"STOP","index":0}],"usageMetadata":{...},"modelVersion":"..."} + * (stream closes — no [DONE]) + * + * Forwarding the raw OpenAI SSE to Gemini CLI made it crash with + * `SyntaxError: Unexpected token 'D', "[DONE]" is not valid JSON`, because + * the SDK tries to `JSON.parse("[DONE]")`. + * + * Ported from upstream decolua/9router#225 by @SteelMorgan. + */ + +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +/** Map OpenAI finish_reason → Gemini finishReason */ +export const OPENAI_TO_GEMINI_FINISH_REASON: Record = { + stop: "STOP", + length: "MAX_TOKENS", + tool_calls: "STOP", + content_filter: "SAFETY", +}; + +interface OpenAIChoiceDelta { + content?: string | null; + reasoning_content?: string | null; + role?: string; +} + +interface OpenAIChoice { + delta?: OpenAIChoiceDelta; + finish_reason?: string | null; +} + +interface OpenAIUsage { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + completion_tokens_details?: { + reasoning_tokens?: number; + }; +} + +interface OpenAIStreamChunk { + choices?: OpenAIChoice[]; + usage?: OpenAIUsage | null; + model?: string; +} + +interface GeminiPart { + text: string; + thought?: boolean; +} + +interface GeminiCandidate { + content: { role: "model"; parts: GeminiPart[] }; + index: number; + finishReason?: string; +} + +interface GeminiUsageMetadata { + promptTokenCount: number; + candidatesTokenCount: number; + totalTokenCount: number; + thoughtsTokenCount?: number; +} + +interface GeminiStreamChunk { + candidates: GeminiCandidate[]; + usageMetadata?: GeminiUsageMetadata; + modelVersion?: string; +} + +/** + * Build a Gemini-shape chunk from a single OpenAI delta event. + * + * Returns `null` when the event has nothing to forward (pure role-only + * delta with no content and no finish_reason) so the caller can skip it. + * + * Exported for unit testing the per-chunk mapping in isolation. + */ +export function openAIChunkToGeminiChunk( + parsed: OpenAIStreamChunk, + fallbackModel: string +): GeminiStreamChunk | null { + const choice = parsed.choices?.[0]; + if (!choice) return null; + + const delta: OpenAIChoiceDelta = choice.delta || {}; + + const parts: GeminiPart[] = []; + if (delta.reasoning_content) { + parts.push({ text: String(delta.reasoning_content), thought: true }); + } + if (delta.content) { + parts.push({ text: String(delta.content) }); + } + + // Skip pure role-only deltas with no content and no finish signal. + if (parts.length === 0 && !choice.finish_reason) return null; + + const candidate: GeminiCandidate = { + content: { + role: "model", + parts: parts.length > 0 ? parts : [{ text: "" }], + }, + index: 0, + }; + + if (choice.finish_reason) { + candidate.finishReason = + OPENAI_TO_GEMINI_FINISH_REASON[choice.finish_reason] ?? "STOP"; + } + + const out: GeminiStreamChunk = { candidates: [candidate] }; + + // Attach usage + modelVersion on the final chunk (when finish_reason is set). + if (choice.finish_reason && parsed.usage) { + const u = parsed.usage; + const usageMetadata: GeminiUsageMetadata = { + promptTokenCount: u.prompt_tokens || 0, + candidatesTokenCount: u.completion_tokens || 0, + totalTokenCount: u.total_tokens || 0, + }; + const reasoningTokens = u.completion_tokens_details?.reasoning_tokens; + if (reasoningTokens) { + usageMetadata.thoughtsTokenCount = reasoningTokens; + } + out.usageMetadata = usageMetadata; + out.modelVersion = parsed.model || fallbackModel; + } + + return out; +} + +/** + * Wrap an OpenAI-SSE upstream `Response` and return a new `Response` whose + * body is the equivalent Gemini SSE stream. + * + * Non-OK / no-body responses are passed through unchanged so that callers + * upstream of the route can surface the error to the client untouched. + */ +export function transformOpenAISSEToGeminiSSE( + upstreamResponse: Response, + model: string +): Response { + if (!upstreamResponse.ok || !upstreamResponse.body) { + return upstreamResponse; + } + + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + + // OpenAI SSE events are delimited by a blank line. A single `chunk` may + // contain partial lines; carry the trailing fragment over to the next + // chunk so we never JSON.parse a half-event. + let buffer = ""; + + const transform = new TransformStream({ + transform(chunk, controller) { + buffer += decoder.decode(chunk, { stream: true }); + const lines = buffer.split("\n"); + // Last entry may be a partial line — keep it for the next chunk. + buffer = lines.pop() ?? ""; + + for (const rawLine of lines) { + // Strip a trailing CR from CRLF-terminated upstreams. + const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + if (!line.startsWith("data:")) continue; + + const data = line.slice(5).trim(); + // Drop empty lines and the OpenAI `[DONE]` sentinel — Gemini SSE + // ends by stream close, no sentinel needed. + if (!data || data === "[DONE]") continue; + + let parsed: OpenAIStreamChunk; + try { + parsed = JSON.parse(data) as OpenAIStreamChunk; + } catch { + continue; + } + + const geminiChunk = openAIChunkToGeminiChunk(parsed, model); + if (!geminiChunk) continue; + + controller.enqueue(encoder.encode("data: " + JSON.stringify(geminiChunk) + "\r\n\r\n")); + } + }, + flush(controller) { + // Drain any final buffered line. Gemini SSE ends on stream close — + // no `[DONE]` sentinel is emitted. + const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer; + buffer = ""; + if (!line.startsWith("data:")) return; + const data = line.slice(5).trim(); + if (!data || data === "[DONE]") return; + let parsed: OpenAIStreamChunk; + try { + parsed = JSON.parse(data) as OpenAIStreamChunk; + } catch { + return; + } + const geminiChunk = openAIChunkToGeminiChunk(parsed, model); + if (!geminiChunk) return; + controller.enqueue(encoder.encode("data: " + JSON.stringify(geminiChunk) + "\r\n\r\n")); + }, + }); + + return new Response(upstreamResponse.body.pipeThrough(transform), { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Access-Control-Allow-Origin": "*", + }, + }); +} + +interface OpenAIMessage { + content?: string | null; + reasoning_content?: string | null; + role?: string; +} + +interface OpenAINonStreamChoice { + message?: OpenAIMessage; + finish_reason?: string | null; +} + +interface OpenAINonStreamResponse { + candidates?: unknown; + error?: unknown; + choices?: OpenAINonStreamChoice[]; + usage?: OpenAIUsage | null; + model?: string; +} + +interface GeminiNonStreamResponse { + candidates: Array<{ + content: { role: "model"; parts: GeminiPart[] }; + finishReason: string; + index: number; + }>; + modelVersion: string; + usageMetadata?: GeminiUsageMetadata; +} + +/** + * Convert an OpenAI Chat Completions JSON response into a Gemini + * `GenerateContentResponse` JSON. Used by the non-streaming + * `:generateContent` path. + */ +export async function convertOpenAIResponseToGemini( + response: Response, + model: string +): Promise { + if (!response.ok) return response; + + let body: OpenAINonStreamResponse; + try { + body = (await response.json()) as OpenAINonStreamResponse; + } catch (err) { + // Body wasn't JSON. Surface a Gemini-shape error so the SDK doesn't + // choke on an unexpected payload. + return Response.json( + { error: { message: sanitizeErrorMessage(err), code: response.status } }, + { + status: response.status, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + } + ); + } + + // Already Gemini-shape (some upstreams may pre-translate) — pass through. + if (body.candidates) { + return Response.json(body, { + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + }); + } + + // Surface upstream error objects untouched. + if (body.error) { + return Response.json(body, { + status: response.status, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + }); + } + + const choice = body.choices?.[0]; + if (!choice || !choice.message) { + return Response.json(body, { + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + }); + } + + const { message, finish_reason } = choice; + + const parts: GeminiPart[] = []; + if (message.reasoning_content) { + parts.push({ text: String(message.reasoning_content), thought: true }); + } + parts.push({ text: String(message.content ?? "") }); + + const finishReason = + OPENAI_TO_GEMINI_FINISH_REASON[finish_reason ?? "stop"] ?? "STOP"; + + const geminiResponse: GeminiNonStreamResponse = { + candidates: [ + { + content: { role: "model", parts }, + finishReason, + index: 0, + }, + ], + modelVersion: body.model || model, + }; + + if (body.usage) { + const u = body.usage; + const usageMetadata: GeminiUsageMetadata = { + promptTokenCount: u.prompt_tokens || 0, + candidatesTokenCount: u.completion_tokens || 0, + totalTokenCount: u.total_tokens || 0, + }; + const reasoningTokens = u.completion_tokens_details?.reasoning_tokens; + if (reasoningTokens) { + usageMetadata.thoughtsTokenCount = reasoningTokens; + } + geminiResponse.usageMetadata = usageMetadata; + } + + return Response.json(geminiResponse, { + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + }); +} diff --git a/open-sse/utils/streamHandler.ts b/open-sse/utils/streamHandler.ts index 634eb61592..0de178772a 100644 --- a/open-sse/utils/streamHandler.ts +++ b/open-sse/utils/streamHandler.ts @@ -1,4 +1,5 @@ import { trackPendingRequest } from "@/lib/usageDb"; +import { STREAM_IDLE_TIMEOUT_MS } from "../config/constants.ts"; import { FORMATS } from "../translator/formats.ts"; import { PENDING_REQUEST_CLEARED_MARKER } from "./stream.ts"; @@ -6,6 +7,16 @@ import { PENDING_REQUEST_CLEARED_MARKER } from "./stream.ts"; const DISCONNECT_ABORT_DELAY_MS = 2_000; +// Default budget for the pipeWithDisconnect raw-upstream stall watchdog. +// Inherits STREAM_IDLE_TIMEOUT_MS so a single env knob still governs the +// max time we tolerate silence from upstream. Reasoning models (Claude +// thinking, Kiro EventStream binary frames) emit zero post-transform +// output for long stretches while raw bytes keep arriving — measuring +// stall on the transform output false-positives on those streams, so +// the watchdog must track upstream byte activity instead. Ported from +// decolua/9router#1243. +const DEFAULT_STREAM_STALL_TIMEOUT_MS = STREAM_IDLE_TIMEOUT_MS; + type StreamDisconnectEvent = { reason: string; duration: number; @@ -391,19 +402,131 @@ export function createDisconnectAwareStream(transformStream, streamController) { } /** - * Pipe provider response through transform with disconnect detection - * @param {Response} providerResponse - Response from provider - * @param {TransformStream} transformStream - Transform stream for SSE - * @param {object} streamController - Stream controller from createStreamController + * Pipe provider response through transform with disconnect detection. + * + * Stall watchdog tracks raw upstream byte activity, not transform output. + * Reasoning models (Claude thinking via Kiro, etc.) can produce zero SSE + * output for long stretches while partial EventStream frames keep arriving; + * measuring stall on the transform output caused false stalls. Any upstream + * chunk resets the timer. If no bytes arrive for `stallTimeoutMs`, the + * stream surfaces a "stream stall timeout" error and aborts. + * + * Ported from decolua/9router#1243 by @zakirkun. + * + * @param providerResponse - Response from provider + * @param transformStream - Transform stream for SSE + * @param streamController - Stream controller from createStreamController + * @param opts.stallTimeoutMs - Override the stall budget (defaults to + * STREAM_IDLE_TIMEOUT_MS / DEFAULT_STREAM_STALL_TIMEOUT_MS). `0` disables + * the watchdog. */ export function pipeWithDisconnect( providerResponse: Response, transformStream: TransformStream, - streamController: StreamController + streamController: StreamController, + opts: { stallTimeoutMs?: number } = {} ) { - const transformedBody = providerResponse.body.pipeThrough(transformStream); + const stallTimeoutMs = opts.stallTimeoutMs ?? DEFAULT_STREAM_STALL_TIMEOUT_MS; + + // Watchdog disabled — preserve legacy behavior verbatim. + if (!stallTimeoutMs || stallTimeoutMs <= 0) { + const transformedBody = providerResponse.body.pipeThrough(transformStream); + return createDisconnectAwareStream( + { readable: transformedBody, writable: { getWriter: () => ({ abort: () => {} }) } }, + streamController + ); + } + + let stallTimer: ReturnType | null = null; + // Captured on the upstream tap's `start`, used by the watchdog to error the + // pipeline so the downstream reader unblocks and emits a clean SSE error + // event. Without this, aborting the AbortController alone does not unblock + // a `reader.read()` already suspended on the transform pipe — the request + // would hang until the upstream finally closed the socket. + let upstreamTapController: TransformStreamDefaultController | null = null; + // Set when the watchdog fires so the downstream pull() catch (which sees + // the same error propagated through the pipeline) does not call + // handleError a second time — pending-cleanup is idempotent but onError + // callbacks should fire once per error. + let stallFired = false; + + const clearStall = () => { + if (stallTimer) { + clearTimeout(stallTimer); + stallTimer = null; + } + }; + const armStall = () => { + clearStall(); + stallTimer = setTimeout(() => { + stallTimer = null; + stallFired = true; + const stallError = new Error("stream stall timeout"); + // Notify the controller (onError callback + pending-request cleanup). + try { + streamController.handleError?.(stallError); + } catch {} + // Error the pipeline so the downstream reader unblocks. createDisconnect- + // AwareStream's catch block translates this into buildStreamErrorChunks + // (sanitized SSE error event with finish_reason:"error", per the format). + try { + upstreamTapController?.error(stallError); + } catch {} + // Abort the underlying fetch so upstream releases the connection. + try { + streamController.abort?.(); + } catch {} + }, stallTimeoutMs); + }; + + // Wrap controller so every termination path clears the stall timer. + // Without this, abort/complete/error/disconnect paths leave the timer armed + // and a stale abort could fire after the request has already ended. + const wrappedController: StreamController = { + ...streamController, + handleComplete: () => { + clearStall(); + streamController.handleComplete(); + }, + handleError: (e: unknown) => { + clearStall(); + // Watchdog already fired its own handleError — the inner pull() catch + // sees the same error propagated through the pipeline; suppress the + // duplicate to keep onError callbacks single-fire. + if (stallFired) return; + streamController.handleError(e); + }, + handleDisconnect: (reason?: string) => { + clearStall(); + streamController.handleDisconnect(reason); + }, + abort: () => { + clearStall(); + streamController.abort(); + }, + }; + + // Inert tap that resets the stall timer on every raw upstream byte chunk. + // Sits between the provider body and the SSE transform so reasoning models + // that buffer many raw bytes into a single emitted event do not look + // stalled to the watchdog. + const upstreamTap = new TransformStream({ + start(controller) { + upstreamTapController = controller; + armStall(); + }, + transform(chunk, controller) { + armStall(); + controller.enqueue(chunk); + }, + flush() { + clearStall(); + }, + }); + + const transformedBody = providerResponse.body.pipeThrough(upstreamTap).pipeThrough(transformStream); return createDisconnectAwareStream( { readable: transformedBody, writable: { getWriter: () => ({ abort: () => {} }) } }, - streamController + wrappedController ); } diff --git a/package-lock.json b/package-lock.json index a20d01714c..98373d6c40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.8.31", + "version": "3.8.32", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.8.31", + "version": "3.8.32", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -28241,7 +28241,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.8.31" + "version": "3.8.32" } } } diff --git a/package.json b/package.json index eddadd204c..b7259034e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.8.31", + "version": "3.8.32", "description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { diff --git a/src/app/(dashboard)/dashboard/cli-code/components/ClaudeToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/ClaudeToolCard.tsx index 8d85109303..832ff024b2 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/ClaudeToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/ClaudeToolCard.tsx @@ -335,16 +335,34 @@ export default function ClaudeToolCard({ : t("installCliPrompt", { tool: "Claude" })}

- +
+ {/* + Always surface Manual Config even when the CLI is not + detected locally — typical of remote OmniRoute + deployments where the CLI lives on the user's machine, + not on the server. Upstream report: #589. + */} + + +
{showInstallGuide && (
diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index f6ec545a96..a2d1669661 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -157,6 +157,8 @@ const ADVANCED_FIELD_HELP_FALLBACK = { "Round-robin combo/model limit: max simultaneous requests sent to each model target. This is separate from any provider account-only cap.", queueTimeout: "How long a request can wait for a round-robin model slot before timing out. This queue is separate from any account-only concurrency cap.", + stickyLimit: + "Round-robin sticky batch size: consecutive successful requests sent to one target before rotating to the next. Empty inherits the global Sticky Limit setting; 1 disables batching (pure one-request rotation).", failoverBeforeRetry: "When enabled, a 429 from the upstream triggers immediate target failover instead of retrying the same URL first.", targetTimeoutMs: @@ -2699,6 +2701,8 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo if (config.concurrencyPerModel !== undefined) configToSave.concurrencyPerModel = config.concurrencyPerModel; if (config.queueTimeoutMs !== undefined) configToSave.queueTimeoutMs = config.queueTimeoutMs; + if (config.stickyRoundRobinLimit !== undefined) + configToSave.stickyRoundRobinLimit = config.stickyRoundRobinLimit; } const hasConfigToSave = Object.keys(configToSave).length > 0; const hadExistingConfig = Object.keys(sanitizeComboRuntimeConfig(combo?.config)).length > 0; @@ -3789,6 +3793,33 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none" />
+
+ + + setConfig({ + ...config, + stickyRoundRobinLimit: e.target.value + ? Number(e.target.value) + : undefined, + }) + } + className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none" + /> +
)} {strategy === "context-relay" && ( diff --git a/src/app/(dashboard)/dashboard/context/caveman/CavemanContextPageClient.tsx b/src/app/(dashboard)/dashboard/context/caveman/CavemanContextPageClient.tsx index 0bb8d85086..3c2353e94e 100644 --- a/src/app/(dashboard)/dashboard/context/caveman/CavemanContextPageClient.tsx +++ b/src/app/(dashboard)/dashboard/context/caveman/CavemanContextPageClient.tsx @@ -84,10 +84,6 @@ export default function CavemanContextPageClient() { intensity: "lite", autoClarity: true, }; - const inputMode: InputModeConfig = { - enabled: settings?.cavemanConfig?.enabled ?? false, - intensity: (settings?.cavemanConfig?.intensity as InputModeConfig["intensity"]) ?? "lite", - }; const masterEnabled = settings?.enabled ?? false; const saveSettings = async (patch: Partial) => { @@ -108,11 +104,6 @@ export default function CavemanContextPageClient() { saveSettings({ languageConfig: { ...languageConfig, ...patch } }); }; - const updateInputMode = (patch: Partial) => { - const current = settings?.cavemanConfig ?? {}; - saveSettings({ cavemanConfig: { ...current, ...inputMode, ...patch } }); - }; - const updateOutputMode = (patch: Partial) => { saveSettings({ cavemanOutputMode: { ...outputMode, ...patch } }); }; @@ -232,34 +223,6 @@ export default function CavemanContextPageClient() { )} -
-

{t("inputCompressionTitle")}

-

{t("inputCompressionDesc")}

-
- - -
-
-

{t("analyticsTitle")}

@@ -282,16 +245,9 @@ export default function CavemanContextPageClient() {

{t("outputModeTitle")}

{t("outputModeDesc")}

+ {/* On/off + intensity for caveman output mode live in the panel + (/dashboard/context/settings). This page keeps the detailed knobs only. */}
- -
             {previewPrompt}
diff --git a/src/app/(dashboard)/dashboard/context/combos/CompressionHub.tsx b/src/app/(dashboard)/dashboard/context/combos/CompressionHub.tsx
index 1c19b52372..882e685969 100644
--- a/src/app/(dashboard)/dashboard/context/combos/CompressionHub.tsx
+++ b/src/app/(dashboard)/dashboard/context/combos/CompressionHub.tsx
@@ -172,64 +172,10 @@ export default function CompressionHub() {
     [settings]
   );
 
-  // ── Toggle a layer (enable/disable) ───────────────────────────────────────────
-  // Routed through the dedicated `/default` endpoint (setEngineInDefaultCombo): it
-  // accepts an empty pipeline (disabling the last layer) and inserts at the
-  // stackPriority-correct position — the [id] route requires `pipeline.min(1)`.
-  const toggleEngine = useCallback(
-    async (engineId: string) => {
-      if (!combo) return;
-      const existingIndex = combo.pipeline.findIndex((s) => s.engine === engineId);
-      const existingStep = existingIndex >= 0 ? combo.pipeline[existingIndex] : null;
-      const enabledNow = Boolean(existingStep && existingStep.config?.enabled !== false);
-      const prev = combo;
-
-      // Optimistic update (mirrors the server's insert-at-priority / remove logic).
-      let optimistic: PipelineStep[];
-      if (enabledNow) {
-        optimistic = combo.pipeline.filter((s) => s.engine !== engineId);
-      } else if (existingStep) {
-        optimistic = combo.pipeline.map((step, index) =>
-          index === existingIndex
-            ? { ...step, config: { ...(step.config ?? {}), enabled: true } }
-            : step
-        );
-      } else {
-        const priorityOf = (eid: string) => engines.find((e) => e.id === eid)?.stackPriority ?? 50;
-        optimistic = [...combo.pipeline];
-        let insertAt = optimistic.findIndex((s) => priorityOf(s.engine) > priorityOf(engineId));
-        if (insertAt < 0) insertAt = optimistic.length;
-        optimistic.splice(insertAt, 0, { engine: engineId });
-      }
-      setCombo({ ...combo, pipeline: optimistic });
-      setError(null);
-
-      try {
-        const res = await fetch("/api/context/combos/default", {
-          method: "PUT",
-          headers: { "Content-Type": "application/json" },
-          body: JSON.stringify({
-            engineId,
-            enabled: !enabledNow,
-            config: { ...(existingStep?.config ?? {}), enabled: !enabledNow },
-          }),
-        });
-        if (!res.ok) {
-          setCombo(prev);
-          setError("Failed to update layer.");
-          return;
-        }
-        const updated = await res.json();
-        if (Array.isArray(updated?.pipeline)) {
-          setCombo({ ...prev, pipeline: updated.pipeline });
-        }
-      } catch {
-        setCombo(prev);
-        setError("Failed to update layer.");
-      }
-    },
-    [combo, engines]
-  );
+  // Layer enable/disable moved to the single-source panel (/dashboard/context/settings,
+  // the `engines` map). The old per-layer toggle here wrote the now-deprecated
+  // /api/context/combos/default route (a 410 shim) — it has been removed. This Hub keeps
+  // the read-only derived view + the reorder control (which uses the named-combo [id] route).
 
   // ── Reorder an active layer ───────────────────────────────────────────────────
   // Persisted via the [id] route so the custom order survives (the `/default` route
@@ -423,9 +369,16 @@ export default function CompressionHub() {
           
           {activeSteps.length} layer(s)
         
+

+ Turn layers on/off and set their level in{" "} + + Compression Settings + + . You can reorder the active layers here. +

{activeSteps.length === 0 ? (

- No active layers. Enable a layer below to build the pipeline. + No active layers. Enable a layer in Compression Settings to build the pipeline.

) : (
    @@ -485,11 +438,6 @@ export default function CompressionHub() { > settings - toggleEngine(step.engine)} - ariaLabel={`Disable ${engine?.name ?? step.engine}`} - /> ); })} @@ -534,11 +482,6 @@ export default function CompressionHub() { > settings - toggleEngine(engine.id)} - ariaLabel={`Enable ${engine.name}`} - /> ))}
diff --git a/src/app/(dashboard)/dashboard/context/rtk/RtkContextPageClient.tsx b/src/app/(dashboard)/dashboard/context/rtk/RtkContextPageClient.tsx index 65f9bdc4ba..13df110d24 100644 --- a/src/app/(dashboard)/dashboard/context/rtk/RtkContextPageClient.tsx +++ b/src/app/(dashboard)/dashboard/context/rtk/RtkContextPageClient.tsx @@ -188,31 +188,9 @@ export default function RtkContextPageClient() { {config && (
-
- - + {/* On/off + intensity now live in the panel (/dashboard/context/settings). This + page edits RTK's detailed configuration only. */} +
+
+ + +
{t("supportedEndpointsLabel")} @@ -388,6 +432,14 @@ export default function CustomModelsSection({ {t("responses")} )} + {model.targetFormat && ( + + {`→ ${targetFormatLabel(model.targetFormat, t)}`} + + )} {model.supportedEndpoints?.includes("embeddings") && ( {`📐 ${t("supportedEndpointEmbeddings")}`} @@ -445,13 +497,32 @@ export default function CustomModelsSection({ - +
+
+ + +
{t("supportedEndpointsLabel")} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts index 69b15c665f..75d99fe04d 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts @@ -78,12 +78,32 @@ export type CompatModelRow = { isHidden?: boolean; upstreamHeaders?: Record; compatByProtocol?: CompatByProtocolMap; + /** #2905: per-model upstream wire-format override. */ targetFormat?: string; }; export type CompatModelMap = Map; export type HeaderDraftRow = { id: string; name: string; value: string }; +// --------------------------------------------------------------------------- +// #2905 — per-model targetFormat badge label mapping (pure, so it can be unit-tested +// outside the .tsx). Returns the i18n key for a targetFormat value, or null when the +// value is unknown (the caller then renders the raw value verbatim). +// --------------------------------------------------------------------------- + +const TARGET_FORMAT_BADGE_I18N_KEYS: Record = { + openai: "compatProtocolOpenAI", + "openai-responses": "compatProtocolOpenAIResponses", + claude: "compatProtocolClaude", + gemini: "targetFormatGemini", + "gemini-cli": "targetFormatGeminiCli", + antigravity: "targetFormatAntigravity", +}; + +export function targetFormatBadgeI18nKey(value: string): string | null { + return TARGET_FORMAT_BADGE_I18N_KEYS[value] ?? null; +} + // --------------------------------------------------------------------------- // Utility — message translation with fallback // --------------------------------------------------------------------------- diff --git a/src/app/(dashboard)/dashboard/settings/components/CliproxyapiSettingsTab.tsx b/src/app/(dashboard)/dashboard/settings/components/CliproxyapiSettingsTab.tsx index 2508989f02..6c431afaf1 100644 --- a/src/app/(dashboard)/dashboard/settings/components/CliproxyapiSettingsTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/CliproxyapiSettingsTab.tsx @@ -37,6 +37,29 @@ export default function CliproxyapiSettingsTab() { const [message, setMessage] = useState<{ type: string; text: string } | null>(null); const [toolState, setToolState] = useState(null); const [toolStateError, setToolStateError] = useState(null); + // #1934: import CLIProxyAPI auth files (~/.cli-proxy-api/) as OmniRoute connections. + const [importing, setImporting] = useState(false); + const [importResult, setImportResult] = useState(null); + + const handleImportAuth = useCallback(async () => { + setImporting(true); + setImportResult(null); + try { + const res = await fetch("/api/oauth/cliproxy-import", { method: "POST" }); + const data = await res.json(); + if (res.ok) { + setImportResult( + `Imported ${data.imported ?? 0} account(s) (scanned ${data.scanned ?? 0}, skipped ${data.skipped ?? 0}).` + ); + } else { + setImportResult(data.error || "Import failed."); + } + } catch { + setImportResult("Import failed."); + } finally { + setImporting(false); + } + }, []); useEffect(() => { fetch("/api/settings") @@ -260,6 +283,19 @@ export default function CliproxyapiSettingsTab() {

{t("cliproxyapiNotDetected")}

)} + + +

{t("cliproxyapiImportAuthTitle")}

+

{t("cliproxyapiImportAuthDesc")}

+ + {importResult ? ( +

+ {importResult} +

+ ) : null} +
); } diff --git a/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx index 8baf01bef5..9ee7c5f14b 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx @@ -482,6 +482,21 @@ export default function ComboDefaultsTab() { } className="text-sm" /> + + setComboDefaults((prev) => ({ + ...prev, + queueDepth: parseInt(e.target.value) || 0, + })) + } + className="text-sm" + />
)} diff --git a/src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx b/src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx index 1950e2e947..60e3ef473e 100644 --- a/src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx @@ -5,7 +5,6 @@ import { Card, Button } from "@/shared/components"; import { useTranslations } from "next-intl"; import CompressionTokenSaverCard, { type CompressionTokenSaverConfig, - type CompressionTokenSaverPatch, } from "./CompressionTokenSaverCard"; type CompressionMode = "off" | "lite" | "standard" | "aggressive" | "ultra" | "rtk" | "stacked"; @@ -227,48 +226,6 @@ export default function CompressionSettingsTab() { } }; - const saveTokenSaver = async (updates: CompressionTokenSaverPatch) => { - const nextUpdates: Partial = {}; - - if (typeof updates.enabled === "boolean") { - nextUpdates.enabled = updates.enabled; - } - - if (updates.cavemanConfig) { - nextUpdates.cavemanConfig = { - ...(config.cavemanConfig ?? { - enabled: true, - compressRoles: ["user"], - skipRules: [], - minMessageLength: 50, - preservePatterns: [], - intensity: "full", - }), - ...updates.cavemanConfig, - }; - } - - if (updates.cavemanOutputMode) { - nextUpdates.cavemanOutputMode = { - ...(config.cavemanOutputMode ?? { - enabled: false, - intensity: "full", - autoClarity: true, - }), - ...updates.cavemanOutputMode, - }; - } - - if (updates.rtkConfig) { - nextUpdates.rtkConfig = { - ...(config.rtkConfig ?? { enabled: true, intensity: "standard" }), - ...updates.rtkConfig, - }; - } - - await save(nextUpdates); - }; - const toggleCavemanRole = (role: "user" | "assistant" | "system") => { const currentRoles = config.cavemanConfig?.compressRoles ?? ["user"]; const newRoles = currentRoles.includes(role) @@ -322,7 +279,7 @@ export default function CompressionSettingsTab() {
- + {config.enabled && (
@@ -529,27 +486,9 @@ export default function CompressionSettingsTab() { /> - + {/* Caveman intensity (level) is set in the panel + (/dashboard/context/settings); kept out of this tab to avoid a + duplicate level control. */}

{t("compressionSkipRules")}

@@ -602,58 +541,16 @@ export default function CompressionSettingsTab() { {config.enabled && config.cavemanOutputMode && (
-
-
-

- {t("compressionSettingsCavemanOutputMode")} -

-

- Injects terse response instructions without rewriting provider output. -

-
- +
+

+ {t("compressionSettingsCavemanOutputMode")} +

+

+ Injects terse response instructions without rewriting provider output. Its on/off + and level are set in the panel (/dashboard/context/settings). +

- -