diff --git a/.env.example b/.env.example index 4f6e691e37..3941efae2e 100644 --- a/.env.example +++ b/.env.example @@ -278,6 +278,15 @@ ALLOW_API_KEY_REVEAL=false # PII_RESPONSE_SANITIZATION=false # PII_RESPONSE_SANITIZATION_MODE=redact # redact = mask PII | warn = log only | block = drop response +# ── VS Code Tokenized-Route Context Sanitizer ── +# Strips implicit active-editor context (editorContext/activeEditor/currentFile/ +# selection/openTabs…) from requests on the /v1/vscode/[token]/* routes before +# forwarding upstream, and redacts the content of explicitly-attached sensitive +# files (.env, private keys, kubeconfig, credentials/secrets). Explicit +# attachments otherwise pass through. Secure-by-default: ON unless set to 0. +# Used by: src/app/api/v1/vscode/contextSanitizer.ts +# OMNIROUTE_VSCODE_SANITIZE_CONTEXT=1 # set to 0 to disable + # ═══════════════════════════════════════════════════════════════════════════════ # 6. TOOL & ROUTING POLICIES # ═══════════════════════════════════════════════════════════════════════════════ @@ -465,6 +474,11 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # Legacy alias for OMNIROUTE_API_KEY. # ROUTER_API_KEY= +# CLI remote-mode context/profile for `omniroute` commands (overrides the active +# context in the local contexts store). Equivalent to the `--context ` flag. +# Used by: bin/cli/program.mjs, bin/cli/api.mjs (remote mode). +# OMNIROUTE_CONTEXT= + # Enforce scope-based access control on MCP tool calls. # Used by: open-sse/mcp-server/server.ts — rejects calls outside allowed scopes. # OMNIROUTE_MCP_ENFORCE_SCOPES=false @@ -1250,6 +1264,25 @@ APP_LOG_TO_FILE=true # Accepted values: true|1|on (enable). Unset or anything else = disabled (default). # PROVIDER_COOLDOWN_ENABLED=true +# Transparent stream recovery (free-claude-code port). When enabled, the opening SSE +# window is briefly held (up to STREAM_RECOVERY.HOLDBACK_MS) so an upstream truncation +# before any byte reaches the client can be retried invisibly. Opt-in: holding the +# window adds up to that much time-to-first-token latency on every stream, so it is +# OFF by default. Seeds ResilienceSettings.streamRecovery.enabled. +# Used by: open-sse/services/streamRecovery.ts, open-sse/handlers/chatCore.ts +# Accepted values: true|1|on (enable). Unset or anything else = disabled (default). +# STREAM_RECOVERY_ENABLED=true + +# Mid-stream continuation (Fase 4.4): when an upstream stream truncates AFTER bytes +# already reached the client, re-request with the partial text as an assistant prefill +# and stitch the missing suffix (plain-text OpenAI-compatible streams only; never with a +# tool call in flight). OFF by default — the recovered tail arrives as one burst, not +# token-by-token. Independent of STREAM_RECOVERY_ENABLED (different risk profile). +# Seeds ResilienceSettings.streamRecovery.continueMidStream. +# Used by: open-sse/services/streamRecovery.ts, open-sse/handlers/chatCore.ts +# Accepted values: true|1|on (enable). Unset or anything else = disabled (default). +# STREAM_RECOVERY_MIDSTREAM_ENABLED=true + # Stagger interval (ms) between provider token healthchecks at startup. # Used by: src/lib/tokenHealthCheck.ts. Default: 3000. # HEALTHCHECK_STAGGER_MS=3000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9f5ad4e37..50a318a0da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,6 +122,8 @@ jobs: run: npm run check:cognitive-complexity - name: Type coverage ratchet run: npm run check:type-coverage + - name: Compression budget ratchet (F2.4 / N4) + run: npm run check:compression-budget # CodeQL alerts ratchet — BLOQUEANTE (promovido de advisory na v3.8.26). # Lê metrics.codeqlAlerts.value de quality-baseline.json e sai 1 SOMENTE numa # regressão real (alertas abertos > baseline). Falha de medição (gh/auth/api) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5a795d6a11..72616d5f9b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -22,10 +22,10 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: persist-credentials: false - - uses: github/codeql-action/init@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3 + - uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: languages: javascript-typescript queries: security-extended - - uses: github/codeql-action/analyze@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3 + - uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: category: "/language:javascript-typescript" diff --git a/.github/workflows/dast-smoke.yml b/.github/workflows/dast-smoke.yml index b14548f57d..4c1548aad3 100644 --- a/.github/workflows/dast-smoke.yml +++ b/.github/workflows/dast-smoke.yml @@ -36,7 +36,7 @@ jobs: if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo up; break; fi sleep 2 done - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.12" - run: pip install schemathesis diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index c70628a60f..e5e1fc757d 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -377,7 +377,7 @@ jobs: - name: Upload Trivy SARIF to Security tab if: needs.prepare.outputs.version != 'main' continue-on-error: true - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@v4 with: sarif_file: trivy-results.sarif category: trivy-image diff --git a/.github/workflows/nightly-llm-security.yml b/.github/workflows/nightly-llm-security.yml index cb1e6d5d92..60e3541332 100644 --- a/.github/workflows/nightly-llm-security.yml +++ b/.github/workflows/nightly-llm-security.yml @@ -86,7 +86,7 @@ jobs: if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo up; break; fi sleep 2 done - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 if: steps.gate.outputs.run == 'true' with: { python-version: "3.12" } - run: pip install garak diff --git a/.github/workflows/nightly-mutation.yml b/.github/workflows/nightly-mutation.yml index 644ad5e9eb..c57fb51501 100644 --- a/.github/workflows/nightly-mutation.yml +++ b/.github/workflows/nightly-mutation.yml @@ -9,18 +9,77 @@ permissions: jobs: stryker: - name: Stryker mutation testing (8 critical modules — advisory) + name: Stryker mutation (batch ${{ matrix.batch.name }} — advisory) runs-on: ubuntu-latest - # Mutation testing is expensive (~15k mutants across 8 modules). It runs only - # on the nightly schedule / manual dispatch, never on PRs. The score is NOT - # yet enforced as a ratchet (wired in a later INT phase) — for now the job - # just produces the HTML/JSON report and uploads it as an artifact. - # - # Runs at stryker concurrency=4 (was 1) now that ./tests/_setup/isolateDataDir.ts - # gives each test process its own DATA_DIR — at concurrency=1 the full phase did - # not finish inside the timeout (the cancelled 2026-06-16/17 runs). 180 min leaves - # headroom for the first cold run before the incremental cache is seeded. - timeout-minutes: 180 + # Mutation testing is expensive. History of the budget: + # - Full 8-module set TIMED OUT at the 180min cap (run 27705123780 = exactly 180min). + # The two god-files chatCore.ts/combo.ts dominated ~2/3 of the mutants and were + # removed from stryker.conf.json `mutate`. + # - The remaining 6 modules in 3 batches: auth.ts and accountFallback.ts are large; the + # a (auth+publicCreds) and b (accountFallback+error) batches still ran near the 180min + # cap (the perTest dry-run over ~130 covering test files is itself costly), so the two + # big modules are now ISOLATED into their own batches (a=auth, b=accountFallback). + # - Onda 3 / Fase 9 T5 re-add: combo.ts was split into 11 leaves; the 8 well-covered + # combo/* leaves are back in `mutate` (covered by the 24 combo-*.test.ts), grouped into + # 2 batches (d=heavy, e=light). After #4204 (D7b) merged, the reset-aware quota pair + # quotaScoring/quotaStrategies was added as batch f. A covering-test audit then added the + # 6 chatCore/* leaves with direct unit coverage as batch g. A follow-up then wrote dedicated + # unit tests for 6 more leaves and added them as batch h. A final follow-up added dedicated + # tests (no mock.module — fetch-override + crafted inputs + temp-DATA_DIR) for telemetryHelpers + # + memorySkillsInjection + semanticCache (its cache-HIT block now has a setCachedResponse + # fixture) as batch i — ALL 15/15 chatCore leaves are now mutated. See + # _mutate_godfiles_excluded_comment in stryker.conf.json. + # 9 PARALLEL batches, each overriding the mutate set via `--mutate` (Stryker 9 CLI: + # `-m, --mutate `; the conf's `mutate[]` remains the local-run default/union). + # - Cold-seeding budget (per-batch `timeout-minutes: ${{ matrix.batch.timeout || 180 }}`): + # a COLD run must COMPLETE once to write stryker-incremental.json (Stryker writes it only on + # a successful finish); a job cancelled at the cap writes nothing, so the next run is cold + # again — an infinite never-seeds loop. actions/cache is also branch-scoped, so each branch + # (incl. release) must seed its OWN cache via a run with enough headroom. Measured cold runs + # (run 27801802713): a/b never finished even isolated; c=180min CANCELLED (4 modules), + # d=180min CANCELLED (3 combo modules), g=142min, h=132min, e=66, f=45, i=33. So a/b/c/d get + # 350min (job max 360) and g/h a 240min variance buffer; once seeded, later nightlies only + # re-test changed mutants and fit well under the default. NOTE: batch c's old "2 modules ~2h" + # assumption went stale when c grew to 4 modules — keep this budget in sync with the matrix. + # Full coverage every night in parallel; wall-clock = the slowest batch's cold run until seeded. + # Runs at stryker concurrency=4 with per-process DATA_DIR isolation + # (tests/_setup/isolateDataDir.ts) — see _concurrency_comment in stryker.conf.json. + strategy: + fail-fast: false + matrix: + batch: + # Per-batch `timeout` (minutes) tiers the cold-seeding budget by measured cost — see the + # cold-run measurements in the comment above. Batches without the key default to 180. + # a/b (large isolated modules) + c/d (4 and 3 modules, observed to exceed 180min cold) get + # 350 (GitHub-hosted job max is 360); g/h (142/132min cold — within runner-variance distance + # of the 180 cap) get a 240 buffer. e/f/i (33-66min) stay at the 180 default. + - name: a + mutate: "src/sse/services/auth.ts" + timeout: 350 + - name: b + mutate: "open-sse/services/accountFallback.ts" + timeout: 350 + - name: c + mutate: "src/server/authz/routeGuard.ts,src/shared/utils/circuitBreaker.ts,open-sse/utils/error.ts,open-sse/utils/publicCreds.ts" + timeout: 350 + - name: d + mutate: "open-sse/services/combo/comboStructure.ts,open-sse/services/combo/autoStrategy.ts,open-sse/services/combo/validateQuality.ts" + timeout: 350 + - name: e + mutate: "open-sse/services/combo/shadowRouting.ts,open-sse/services/combo/targetSorters.ts,open-sse/services/combo/comboPredicates.ts,open-sse/services/combo/rrState.ts,open-sse/services/combo/comboData.ts" + - name: f + mutate: "open-sse/services/combo/quotaScoring.ts,open-sse/services/combo/quotaStrategies.ts" + - name: g + mutate: "open-sse/handlers/chatCore/comboContextCache.ts,open-sse/handlers/chatCore/idempotency.ts,open-sse/handlers/chatCore/passthroughHelpers.ts,open-sse/handlers/chatCore/responseHeaders.ts,open-sse/handlers/chatCore/sanitization.ts,open-sse/handlers/chatCore/upstreamTimeouts.ts" + timeout: 240 + - name: h + mutate: "open-sse/handlers/chatCore/headers.ts,open-sse/handlers/chatCore/logTruncation.ts,open-sse/handlers/chatCore/memoryExtraction.ts,open-sse/handlers/chatCore/nonStreamingSse.ts,open-sse/handlers/chatCore/passthroughToolNames.ts,open-sse/handlers/chatCore/executorHelpers.ts" + timeout: 240 + - name: i + mutate: "open-sse/handlers/chatCore/telemetryHelpers.ts,open-sse/handlers/chatCore/memorySkillsInjection.ts,open-sse/handlers/chatCore/semanticCache.ts" + # Per-batch budget: a/b/c/d override to 350min, g/h to 240min; the rest default to 180min. + # `matrix.batch.timeout` is null for batches without the key -> `|| 180`. + timeout-minutes: ${{ matrix.batch.timeout || 180 }} steps: - uses: actions/checkout@v6 with: @@ -34,17 +93,19 @@ jobs: uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: reports/mutation/stryker-incremental.json - key: stryker-incremental-${{ github.run_id }} - restore-keys: stryker-incremental- + key: stryker-incremental-${{ matrix.batch.name }}-${{ github.run_id }} + restore-keys: stryker-incremental-${{ matrix.batch.name }}- - name: Run Stryker (advisory) id: stryker continue-on-error: true - run: npx stryker run + env: + BATCH_MUTATE: ${{ matrix.batch.mutate }} + run: npx stryker run --mutate "$BATCH_MUTATE" - name: Upload mutation report if: always() uses: actions/upload-artifact@v7 with: - name: mutation-report + name: mutation-report-${{ matrix.batch.name }} path: reports/mutation/ if-no-files-found: warn retention-days: 14 diff --git a/.github/workflows/nightly-schemathesis.yml b/.github/workflows/nightly-schemathesis.yml index 3f40365074..c249c158a1 100644 --- a/.github/workflows/nightly-schemathesis.yml +++ b/.github/workflows/nightly-schemathesis.yml @@ -33,7 +33,7 @@ jobs: if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo "server up"; break; fi sleep 2 done - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: { python-version: "3.12" } - name: Install schemathesis run: pip install schemathesis diff --git a/CHANGELOG.md b/CHANGELOG.md index d87ab29187..442f581904 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,144 @@ --- +## [3.8.29] — 2026-06-19 + +### ✨ New Features + +- **feat(cloud-agent): Cursor Cloud Agent via the official API-key REST API (no IDE-OAuth ban risk)** — adds a `cursor-cloud` cloud agent that drives Cursor's Background / Cloud Agents through the official REST API (`api.cursor.com`) authenticated with a user or service-account API key — the safer, first-party alternative to re-using the Cursor IDE's OAuth session (the existing `cursor` provider, which carries a ban-risk warning). Implemented as a plain REST adapter mirroring the Devin/Jules agents (`createTask`/`getStatus`/`sendMessage`/`listSources`), so it does **not** pull in the `@cursor/sdk` package and its per-platform native binaries (Cursor's SDK is itself a thin wrapper over this REST API). Cursor's UPPERCASE status enums (`CREATING`/`RUNNING`/`FINISHED`/`ERROR`) are mapped explicitly to the shared `CloudAgentStatus`, and `baseUrl` is overridable per-credential. Credentials are stored encrypted via the existing `cloud_agent_credentials` table; no schema change. ([#4227](https://github.com/diegosouzapw/OmniRoute/issues/4227) — thanks @MRDGH2821) +- **feat(routing): OpenRouter-style `auto/:` combos** — auto-routing now understands suffixed combos that separate the _category_ (what kind of route) from the _tier_ (how to optimize): `auto/coding:fast`, `auto/coding:cheap` (alias `:floor`), `auto/coding:free`, `auto/coding:pro`, `auto/coding:reliable`, plus the new category roots `auto/reasoning`, `auto/vision`, `auto/multimodal`. The **tier** picks the scoring weights — `:fast` → ship-fast, `:cheap`/`:floor` → cost-saver, `:reliable` → a new reliability-first pack (circuit-breaker health + latency stability) — while `:free`/`:pro` filter the candidate pool by model tier (`classifyTier`: free-tier vs. premium models). The **category** filters the pool by capability (`vision`/`multimodal` → vision-capable models, `reasoning` → reasoning/thinking models). Any valid `auto/:` resolves on demand; a curated set is advertised in `/v1/models` and the dashboard. Filtering is fail-open — if a constraint matches no connected models the full pool is used so routing never breaks. All composition lives in the new `open-sse/services/autoCombo/suffixComposition.ts`; the core combo scorer (`combo.ts`) is untouched. Second slice of #4235 (premium account-tier weighting is a later follow-up). ([#4235](https://github.com/diegosouzapw/OmniRoute/issues/4235) — thanks @MRDGH2821) +- **feat(routing): advertise the `auto/cheap`, `auto/offline`, `auto/smart` combos (catalog ↔ README sync)** — the README lists `auto/cheap` (cheapest-per-token first), `auto/offline` (most quota/rate-limit headroom first) and `auto/smart` (quality-first + 10% exploration), and they already resolved at request time via `parseAutoPrefix` → `createVirtualAutoCombo`. But they were missing from `AUTO_TEMPLATE_VARIANTS`, so `/v1/models` and the dashboard combos list (which iterate that catalog) never showed them — the catalog drifted from the docs (visible in the issue's screenshots). Added the three entries so they're advertised everywhere alongside the other built-in `auto/*` combos. First slice of #4235 (OpenRouter-style `auto/:` suffixes + new categories follow). ([#4235](https://github.com/diegosouzapw/OmniRoute/issues/4235) — thanks @MRDGH2821) +- **feat(cli): remote mode — drive a remote OmniRoute with scoped access tokens** — a new CLI mode that connects to a remote OmniRoute instance using scoped access tokens, so a local CLI can drive a server you don't own a session on. ([#4256](https://github.com/diegosouzapw/OmniRoute/pull/4256)) +- **feat(api): cost-telemetry parity — `X-OmniRoute-*` headers on every endpoint + a non-token cost engine** — every endpoint now emits the `X-OmniRoute-*` cost/usage headers, backed by a cost engine that also prices non-token (media/request-based) usage. ([#4247](https://github.com/diegosouzapw/OmniRoute/pull/4247)) +- **feat(api): register Kimi K2.7 Code models (`kimi-k2.7-code` + `-highspeed`)** — the new Moonshot thinking-only coding models are registered (fixed sampling; `temperature`/`top_p` marked unsupported). ([#4183](https://github.com/diegosouzapw/OmniRoute/pull/4183)) +- **feat(catalog): add `kimi-k2.7-code` to the kmca catalog + qwen-web models discovery** — surfaces the new Kimi coding model in the kmca catalog and wires qwen-web into model discovery. ([#4185](https://github.com/diegosouzapw/OmniRoute/pull/4185)) +- **feat(api): expand the `zai` provider catalog with GLM-5.2 / GLM-4.7** — adds the real GLM-5.2, GLM-4.7 and GLM-4.7-flash model ids to the Anthropic-direct `zai` provider. ([#4201](https://github.com/diegosouzapw/OmniRoute/pull/4201)) +- **feat(api): no-thinking gateway model IDs (FCC port, Fase 8.1)** — gateway model id variants that force thinking off, ported from free-claude-code. ([#4145](https://github.com/diegosouzapw/OmniRoute/pull/4145)) +- **feat(sse): mid-stream continuation for truncated streams (FCC port, Task 4.4)** — when a stream is cut short, OmniRoute can transparently continue it, ported from free-claude-code. ([#4147](https://github.com/diegosouzapw/OmniRoute/pull/4147)) +- **feat(sse): per-provider sliding-window rate-limit fallback (FCC port, Fase 8.2)** — a per-provider sliding-window rate limiter as a fallback path, ported from free-claude-code. ([#4146](https://github.com/diegosouzapw/OmniRoute/pull/4146)) +- **feat(sse): transparent stream recovery (FCC port, Fase 4, opt-in)** — opt-in transparent recovery of interrupted upstream streams, ported from free-claude-code. ([#4131](https://github.com/diegosouzapw/OmniRoute/pull/4131)) +- **feat(search): free DuckDuckGo web search as a last-resort provider (FCC port, Fase 6)** — adds a no-key DuckDuckGo web-search provider used as a last resort, ported from free-claude-code. ([#4136](https://github.com/diegosouzapw/OmniRoute/pull/4136)) +- **feat(logging): credential-redaction safety net in the pino logger (FCC port, Fase 8.3)** — a logger-level redaction pass that scrubs credentials from log output, ported from free-claude-code. ([#4140](https://github.com/diegosouzapw/OmniRoute/pull/4140)) +- **feat(memory): opt-in Qdrant scalar int8 quantization (F4.4 Q1)** — opt-in int8 scalar quantization for Qdrant-backed memory vectors. ([#4187](https://github.com/diegosouzapw/OmniRoute/pull/4187)) +- **feat(memory): opt-in sqlite-vec int8 vector quantization (F4.4 Q2)** — opt-in int8 quantization for the sqlite-vec memory backend. ([#4190](https://github.com/diegosouzapw/OmniRoute/pull/4190)) +- **feat(deploy): keep optional deps on `update` (`--include=optional`)** — the in-place update path now passes `--include=optional` so native/optional packages aren't dropped on update. ([#4260](https://github.com/diegosouzapw/OmniRoute/pull/4260)) +- **feat(dashboard): unified visual identity — grid, primitives, tables, form controls (design phases 1-4)** — a sweeping design pass aligning the dashboard with the site: grid wallpaper, button/card/input primitives, theme-aware tables and form controls. ([#4122](https://github.com/diegosouzapw/OmniRoute/pull/4122)) +- **feat(dashboard): grid wallpaper on all standalone screens + fluid 4K layout** — the identity grid now backs every standalone screen and the layout scales fluidly to 4K. ([#4158](https://github.com/diegosouzapw/OmniRoute/pull/4158)) +- **feat(dashboard): make the identity grid visible + unify the focus ring on accent** — design follow-up making the grid actually visible and standardizing focus rings on the accent color. ([#4141](https://github.com/diegosouzapw/OmniRoute/pull/4141)) +- **feat(dashboard): import only free models + free-model list controls** — the model-import page can import just the free models, with controls to manage the free-model list. ([#4176](https://github.com/diegosouzapw/OmniRoute/pull/4176) — thanks @felipesartori) +- **feat(dashboard): compact grid layout for no-auth provider accounts** — a denser grid layout for provider accounts when auth is disabled. ([#4137](https://github.com/diegosouzapw/OmniRoute/pull/4137) — thanks @felipesartori) +- **feat(dashboard): derive media `serviceKinds` from the registries (surface MiniMax + the media catalog)** — `/media-providers/[kind]` now derives its service kinds from the registries instead of a hand-maintained list, surfacing ~48 previously-invisible media providers (incl. MiniMax TTS/video/music). ([#4212](https://github.com/diegosouzapw/OmniRoute/pull/4212)) +- **feat(traffic-inspector): live (in-flight) request filter (Gap 5)** — the Traffic Inspector can filter to in-flight requests as they happen. ([#4130](https://github.com/diegosouzapw/OmniRoute/pull/4130)) +- **feat(agent-bridge): maintenance & diagnostics dashboard controls** — adds maintenance and diagnostics controls for the Agent Bridge to the dashboard. ([#4127](https://github.com/diegosouzapw/OmniRoute/pull/4127)) +- **feat(mitm): TPROXY IP_TRANSPARENT native addon + conditional loader (Epic A)** — a native `IP_TRANSPARENT` addon with a conditional loader, the foundation for TPROXY capture. ([#4148](https://github.com/diegosouzapw/OmniRoute/pull/4148)) +- **feat(mitm): Fase 3 Epic A spike — TPROXY command builder** — a transactional builder for the iptables/TPROXY command set. ([#4139](https://github.com/diegosouzapw/OmniRoute/pull/4139)) +- **feat(mitm): TPROXY setup layer — transactional apply/revert (Epic A)** — applies and reverts the TPROXY routing setup transactionally. ([#4144](https://github.com/diegosouzapw/OmniRoute/pull/4144)) +- **feat(mitm): add `setSocketMark` to the TPROXY addon (anti-loop primitive)** — exposes `setSocketMark` so OmniRoute's own egress can be marked and skipped (anti-loop). ([#4160](https://github.com/diegosouzapw/OmniRoute/pull/4160)) +- **feat(mitm): TPROXY capture-mode listener + `connectMarked` (Epic A)** — the capture-mode listener plus a marked-connect primitive. ([#4169](https://github.com/diegosouzapw/OmniRoute/pull/4169)) +- **feat(mitm): dynamic per-SNI cert authority for TPROXY (TLS decrypt 1/N)** — a per-SNI on-the-fly certificate authority, the first slice of TLS decrypt. ([#4173](https://github.com/diegosouzapw/OmniRoute/pull/4173)) +- **feat(mitm): TLS-terminating capture for TPROXY (decrypt 2/N)** — terminates TLS to capture decrypted traffic. ([#4179](https://github.com/diegosouzapw/OmniRoute/pull/4179)) +- **feat(mitm): wire the TLS decrypt engine into TPROXY capture mode (decrypt 3/N)** — connects the decrypt engine to the capture-mode pipeline. ([#4200](https://github.com/diegosouzapw/OmniRoute/pull/4200)) +- **feat(mitm): TPROXY capture-mode manager (decrypt 4a/N)** — a manager coordinating the TPROXY capture lifecycle. ([#4208](https://github.com/diegosouzapw/OmniRoute/pull/4208)) +- **feat(mitm): local-only route + trust-store installer for TPROXY decrypt (4b/N)** — a loopback-only management route plus a CA trust-store installer for the decrypt CA. ([#4211](https://github.com/diegosouzapw/OmniRoute/pull/4211)) +- **feat(dashboard): TPROXY decrypt capture toggle in the Traffic Inspector (4c/N)** — a UI toggle to enable/disable decrypted capture. ([#4216](https://github.com/diegosouzapw/OmniRoute/pull/4216)) +- **feat(compression): replace the headroom tabular encoder with a vendored GCF** — swaps the tabular encoder for a vendored GCF implementation. ([#4167](https://github.com/diegosouzapw/OmniRoute/pull/4167) — thanks @blackwell-systems) +- **feat(compression): live per-engine streaming via `compression.step` (F3.3)** — streams per-engine compression progress through a `compression.step` event. ([#4217](https://github.com/diegosouzapw/OmniRoute/pull/4217)) +- **feat(compression): show an engine node for single-engine runs in the studio** — the Compression Studio now renders an engine node even when only one engine runs. ([#4210](https://github.com/diegosouzapw/OmniRoute/pull/4210)) +- **feat(compression): expose the WaterfallInspector via a Canvas/Waterfall toggle** — adds a Canvas/Waterfall view toggle that surfaces the WaterfallInspector. ([#4238](https://github.com/diegosouzapw/OmniRoute/pull/4238)) +- **feat(compression): make `mcpAccessibility` config reachable via a settings sub-route** — exposes the `mcpAccessibility` config under a dedicated settings sub-route. ([#4237](https://github.com/diegosouzapw/OmniRoute/pull/4237)) +- **feat(compression): runnable A/B benchmark CLI (F2.4)** — a CLI to run A/B compression benchmarks. ([#4220](https://github.com/diegosouzapw/OmniRoute/pull/4220)) +- **feat(compression): add a transcript loader to the replay harness** — the replay harness can now load real transcripts. ([#4246](https://github.com/diegosouzapw/OmniRoute/pull/4246)) +- **feat(compression): wire MCP tool-cardinality reduction (F4.3, opt-in)** — opt-in reduction of MCP tool-set cardinality to shrink prompts. ([#4221](https://github.com/diegosouzapw/OmniRoute/pull/4221)) +- **feat(compression): wire RTK comment-stripping config + honor `preserveDocstrings`** — RTK comment-stripping is now configurable and honors a `preserveDocstrings` flag. ([#4242](https://github.com/diegosouzapw/OmniRoute/pull/4242)) +- **feat(compression): honor the per-filter RTK `deduplicate` flag** — RTK filters now respect a per-filter `deduplicate` flag. ([#4231](https://github.com/diegosouzapw/OmniRoute/pull/4231)) +- **feat(compression): honor the registry `enabled` flag in the stacked loop** — the stacked compression loop now skips engines disabled in the registry. ([#4244](https://github.com/diegosouzapw/OmniRoute/pull/4244)) +- **feat(compression): persist RTK grouping config (unlock R5 `enableGrouping`)** — persists the RTK grouping configuration, unlocking the R5 `enableGrouping` rule. ([#4207](https://github.com/diegosouzapw/OmniRoute/pull/4207)) +- **feat(compression): wire ultra's `modelPath`/`slmFallbackToAggressive` to the LLMLingua SLM tier** — connects the ultra tier's small-language-model knobs to the LLMLingua SLM path. ([#4257](https://github.com/diegosouzapw/OmniRoute/pull/4257)) +- **feat(quality): Onda 2 mutation-gate tooling — radiography classifier (T1) + `mutationScore` ratchet (T3)** — new mutation-testing tooling: a survivor-radiography classifier and a `mutationScore` ratchet. ([#4234](https://github.com/diegosouzapw/OmniRoute/pull/4234)) +- **feat(ci): wire the F2.4 compression budget-gate ratchet** — adds a CI ratchet that gates compression budget regressions. ([#4232](https://github.com/diegosouzapw/OmniRoute/pull/4232)) + +### 🐛 Fixed + +- **fix(providers): qwen-web model discovery now lists the live catalog instead of nothing** — the `qwen-web` cookie provider had no entry in `PROVIDER_MODELS_CONFIG`, so its model-discovery page returned an empty/stale local catalog (the OAuth fallback at the top of the route only fires for `provider === "qwen"`, leaving `qwen-web` to fall through to the no-config branch). Added a `qwen-web` entry that fetches the **public** `https://chat.qwen.ai/api/v2/models` endpoint (no auth header) and parses the `{ data: { data: [{ id, name, owned_by }] } }` shape (with a flatter `{ data: [] }` fallback). This is Problem #3 of #3931 (diagnosed by @thezukiru); Problem #1 — validator bare-token false-positive — shipped earlier in #3958, and Problem #2 — empty stream from Qwen WAF bot-detection on the streaming endpoint — remains a separate upstream/stealth concern. ([#3931](https://github.com/diegosouzapw/OmniRoute/issues/3931) — thanks @thezukiru) +- **fix(providers): ZenMux model discovery now lists the live catalog (incl. the free models) instead of the stale 9-entry hardcoded list** — adding a ZenMux key validated fine, but the connection then showed `API unavailable — using local catalog` and was missing the free models ZenMux advertises (`z-ai/glm-5.2-free`, `moonshotai/kimi-k2.7-code-free`). Root cause: `zenmux` carries a correct `modelsUrl` in the registry, but — like `llm7`/`byteplus` before #3976 — it was not classified by any live-fetch branch of the model-import route (not `openai-compatible-*`, not self-hosted, not in `NAMED_OPENAI_STYLE_PROVIDERS`), so the route never probed the upstream `/models` and fell through to the registry's hardcoded `models[]`. Added `zenmux` to `NAMED_OPENAI_STYLE_PROVIDERS`, so the route probes `https://zenmux.ai/api/v1/models` (the `/chat/completions`-stripped `/models` candidate) and serves the live list, falling back to the local catalog only when the upstream fetch fails — import never breaks. ([#4202](https://github.com/diegosouzapw/OmniRoute/issues/4202) — thanks @mikmaneggahommie) +- **fix(providers): Vercel AI Gateway "import models" now loads the live catalog instead of nothing** — adding a Vercel AI Gateway key worked, but clicking **import** on the models page loaded nothing usable (manually adding the same models worked). Same class as #4202 (zenmux) / #3976 (llm7/byteplus): `vercel-ai-gateway` carries a real `baseUrl` (`https://ai-gateway.vercel.sh/v1/chat/completions`, format `openai`) in the registry, but was not classified by any live-fetch branch of the model-import route (not `openai-compatible-*`, not self-hosted, not in `NAMED_OPENAI_STYLE_PROVIDERS`), so the route never probed the upstream `/models` and fell through to the registry's tiny 5-entry hardcoded `models[]`. Added `vercel-ai-gateway` to `NAMED_OPENAI_STYLE_PROVIDERS`, so the route probes `https://ai-gateway.vercel.sh/v1/models` (the `/chat/completions`-stripped `/models` candidate) and serves the live list, falling back to the local catalog only when the upstream fetch fails — import never breaks. ([#4249](https://github.com/diegosouzapw/OmniRoute/issues/4249) — thanks @FerLuisxd) +- **fix(sse): clear error when the request queue drops a job (no more fake-upstream "This job timed out after Nms")** — under concurrent load, requests that exceed the per-connection rate-limit queue budget (`resilienceSettings.requestQueue.maxWaitMs`) were dropped by Bottleneck with its raw `This job timed out after ms.` message. That string is indistinguishable from an upstream gateway timeout, so the 502 body and call-log `last_error` looked like a provider outage across unrelated providers (TI:0\|TO:0) — an operator spent ~3h misdiagnosing local queue saturation as upstream failures. `withRateLimit` now rewrites that specific Bottleneck error into a clear, OmniRoute-owned message that names the knob (`requestQueue.maxWaitMs`, tunable in Settings → Resilience), explicitly disclaims an upstream timeout, preserves the original as `cause`, and tags `code: "RATE_LIMIT_QUEUE_TIMEOUT"`. Behavior is unchanged — the job is still dropped so combo falls back to the next target. ([#4165](https://github.com/diegosouzapw/OmniRoute/issues/4165) — thanks @KooshaPari) +- **fix(api): advertise the built-in `auto/*` combos in `/v1/models`** — OmniRoute ships a zero-setup `auto/*` catalog (`auto/best-coding`, `auto/pro-reasoning`, …, 16 variants) that the dashboard advertises and that resolve on demand, but the `/v1/models` listing only emitted persisted DB combos + provider models. Clients that build their model picker from `/v1/models` (e.g. Hermes Agent) never saw any `auto/*` option. The catalog now emits every `AUTO_TEMPLATE_VARIANTS` id (as `owned_by: "combo"`) at the top of the list, deduped against persisted combos. (Showing each `auto/*`'s dynamically-selected members is a separate enhancement.) ([#4164](https://github.com/diegosouzapw/OmniRoute/issues/4164) — thanks @MRDGH2821) +- **fix(sse): restore MCP / third-party tool names on the native Claude path (MCP dispatch broken in Claude Code)** — since 3.8.27, every MCP tool call routed through OmniRoute to a native Claude OAuth provider failed client-side with `Error: No such tool available: `: tool schemas arrived fine but the streamed `tool_use.name` reached Claude Code in its cloaked form (e.g. `McpN8nMcpSearchWorkflows` instead of the registered `mcp__n8n-mcp__search_workflows`). The native-Claude tool-name cloak stashes its per-request alias→original map as a **non-enumerable** `_toolNameMap` on the request body; the request-inspector capture added in 3.8.27 rebuilds the captured body from its serialized form (`JSON.parse(JSON.stringify(...))`), which drops non-enumerable properties, so `finalBody._toolNameMap` was empty and the response-side un-cloak silently fell back to the static built-in map — never restoring dynamic MCP / snake_case names. Built-in tools (Bash/Read/…) were unaffected (static map); cross-format paths were unaffected (they attach the map enumerably). The provider-request capture now re-attaches the per-request map (kept non-enumerable, so it still never re-serializes upstream) when the captured copy lost it, restoring MCP tool dispatch. ([#4091](https://github.com/diegosouzapw/OmniRoute/issues/4091) — thanks @pedrotecinf, @NakHalal) +- **fix(dashboard): Logs auto-refresh self-heals in embedded/proxied hosts that pin or mis-fire visibility** — a follow-up to #4054: the Request Logger still froze auto-refresh on some hosts (reported on 3.8.28 Docker, works on 3.8.24). #4054 made the initial visibility fail-open, but the pause is event-driven — a host that fires a one-shot `visibilitychange` → hidden and then keeps reporting `"hidden"` (or recovers without firing the event again) left the cached visibility flag stuck `false`, so the interval ticked but never polled (only the manual Refresh button worked). The poll tick now also re-checks the **live** `document.visibilityState`, and a **window `focus`** listener re-arms polling (a focused window is a reliable signal the page is actively viewed). A genuinely backgrounded browser tab still pauses (it reports `"hidden"` and never receives focus), preserving the #3109 network-saturation optimization. ([#4133](https://github.com/diegosouzapw/OmniRoute/issues/4133) — thanks @tjengbudi) +- **fix(capabilities): unify vision model-id detection into one shared source** — three code paths kept independent, drifting vision-model lists, so the same model id could get up to three different verdicts. Two concrete bugs: lite compression's gate was missing pixtral / llava / qwen-vl / glm-4v / kimi-vl / mistral-medium-3, so it **stripped images for those real vision models and blinded them** (same class as #4071 / #4012); and the `/v1/models` list was too broad, flagging text models (`gemma`, bare `kimi` like `kimi-k2`) as vision. All three (`modelCapabilities` routing fallback, `/v1/models` listing, lite image-strip gate) now delegate to a single conservative source `src/shared/constants/visionModels.ts`, which also restores `glm-4v` / `gemini-3` coverage and keeps the #3328 MiniMax M3 carve-out. ([#4072](https://github.com/diegosouzapw/OmniRoute/issues/4072) — thanks @diego-anselmo) +- **fix(sse): surface mid-stream Gemini errors instead of returning a truncated 200** — when an upstream Gemini SSE stream emitted some partial content and then a JSON error object (`{"error":{"code":503,"message":"…high demand…","status":"UNAVAILABLE"}}`) instead of a `candidates` payload, OmniRoute silently dropped it: the gemini→openai translator's no-candidate branch only handled `promptFeedback` (content-filter) and returned `null` for anything else, so the stream simply ended and the client got HTTP 200 with a truncated body and `finish_reason: "stop"` — masking the failure and skipping combo fallback. `geminiToOpenAIResponse` now detects an `error` object (optionally wrapped in `response`), records it as `state.upstreamError` (preserving the real status — 503/`UNAVAILABLE`, or 429 for `RESOURCE_EXHAUSTED`), and lets `stream.ts` error the stream out through the existing `onFailure`/`buildErrorBody`/`controller.error` path — the same mechanism the openai-responses translator already uses. ([#4177](https://github.com/diegosouzapw/OmniRoute/issues/4177) — thanks @hartmark) +- **fix(capabilities): resolve models.dev-synced vision metadata for Mistral `-latest` aliases** — root cause behind the #4071 heuristic: `getResolvedModelCapabilities("mistral/pixtral-12b-latest").supportsVision` resolved `null` (vision came only from the #4071 model-id heuristic, with `attachment` still `null`) even though models.dev exposes the model as multimodal. Confirmed against the live models.dev API: it catalogs Pixtral 12B under the **short** id `pixtral-12b` (with `attachment: true`, `modalities.input: ["text","image"]`), while requests use the Mistral API alias `pixtral-12b-latest`. The synced lookup tried the exact / raw / static-spec-canonical ids — all of which miss the short form — so it fell through to the heuristic. `getSyncedCapabilityForResolved` now adds a last-resort fallback that retries with a trailing `-latest` stripped, so synced metadata (`attachment` / image modalities) wins for these aliases; models whose `-latest` id is stored verbatim (e.g. `pixtral-large-latest`) keep resolving directly. Note: the models.dev sync is currently manual-only (Settings → models.dev) with no scheduled refresh, so a fresh instance still relies on the #4071 heuristic until that sync runs — a periodic-refresh cadence is left as a separate follow-up. ([#4073](https://github.com/diegosouzapw/OmniRoute/issues/4073) — thanks @diego-anselmo) +- **fix(sse): map Xiaomi MiMo reasoning control to its native `thinking:{type}` shape** — MiMo (`api.xiaomimimo.com`) controls chain-of-thought **only** via top-level `thinking:{type:"enabled"|"disabled"}` and does not understand OpenAI's `reasoning_effort`/`reasoning`, while its request validator is strict (`400 Param Incorrect`). OmniRoute's OpenAI path carried reasoning intent as `reasoning_effort`, and the claude→openai translator can leave a Claude-shaped `thinking:{type, budget_tokens}` — so the client's on/off choice was silently dropped and `budget_tokens`/`reasoning_effort` rode along as extra params the validator can reject. New `open-sse/services/mimoThinking.ts::normalizeMimoThinking` (wired in `chatCore` for `provider==="xiaomi-mimo"`) reduces any thinking object to just `{type}` (`disabled` stays; `enabled`/`adaptive`/other → `enabled`) and drops `reasoning_effort`/`reasoning`. It deliberately does **not** synthesize thinking from a bare `reasoning_effort` — `mimo-v2-omni` is non-thinking, so that could turn a silently-ignored param into a hard error. ([#4224](https://github.com/diegosouzapw/OmniRoute/pull/4224)) +- **fix(capabilities): Xiaomi MiMo `*-pro` chat models are text-only (no vision)** — only `mimo-v2.5` and `mimo-v2-omni` accept images per Xiaomi's docs; `mimo-v2.5-pro`/`mimo-v2-pro` are text-only, but `modelSpecs` marked them vision-capable and models.dev mislabels them ([hermes-agent#18884](https://github.com/NousResearch/hermes-agent/issues/18884)). Since `resolveVisionCapability` lets a synced `attachment:true` win first, an image request could be routed to a blind model (the #4071 failure mode). Corrected the specs **and** added a hard override in `resolveVisionCapability` (checked before the synced branch, anchored so `mimo-v2.5-pro` never matches the multimodal `mimo-v2.5`) that beats the wrong synced attachment. Also registered the missing native `mimo-v2-pro` chat model and the missing `mimo-v2-tts` speech model. ([#4224](https://github.com/diegosouzapw/OmniRoute/pull/4224)) +- **fix(sse): Claude Opus 4.7+/Fable 5 use adaptive thinking only (no more manual-budget 400s)** — Opus 4.7 and later (Opus 4.7/4.8, Fable 5) removed manual extended thinking: `thinking.type:"enabled"` or **any** `thinking.budget_tokens` now returns `400` ("Any request that tries to set a fixed thinking budget gets a 400" — Anthropic migration guide). Reasoning is adaptive-only, steered by `output_config.effort`. OmniRoute's OpenAI→Claude translator mapped `reasoning_effort` low/medium/high to a manual `thinking:{type:"enabled", budget_tokens}`, so those requests hard-400'd on the most-used provider (and a Claude-native passthrough client sending the legacy shape did too). A new `adaptiveThinkingOnly` model flag now drives two fixes: the translator maps `reasoning_effort` of **every** level to `{type:"adaptive"}` + `output_config.effort` (preserving the requested level, never a budget) for these models, and a `normalizeClaudeAdaptiveThinking` catch-all at the existing post-translation thinking-normalization chokepoint collapses any residual manual thinking (passthrough legacy shape, per-model defaults) to `{type:"adaptive"}`, keyed on the resolved upstream model so it covers every routing mode. Pre-4.7 models (Opus 4.6/4.5, Sonnet, Haiku) keep manual budgets unchanged. ([#4230](https://github.com/diegosouzapw/OmniRoute/pull/4230)) +- **fix(providers): strip non-default temperature/top_p/top_k for Claude Opus 4.7+/Fable 5 (fixed sampling → no 400)** — Opus 4.7 and later reject non-default `temperature`/`top_p`/`top_k` with a `400` (sampling is fixed; reasoning moved to `output_config.effort`). The translator forwarded client-supplied `temperature`/`top_p` unconditionally and the Claude registry models carried no `unsupportedParams`, so a plain OpenAI-format request with `temperature: 0.7` to `claude-opus-4-8` hard-400'd. Added `unsupportedParams: ["temperature","top_p","top_k"]` to the Opus 4.7+/Fable 5 ids in both the `claude` (dashed `claude-opus-4-8`) and `anthropic` (dotted `claude-opus-4.7`) registries, so they're stripped at the existing `getUnsupportedParams` dispatch chokepoint. Pre-4.7 Claude models still accept sampling params. ([#4230](https://github.com/diegosouzapw/OmniRoute/pull/4230)) +- **fix(providers): conditionally strip temperature/top_p for GPT-5 reasoning on the `openai` Chat Completions path (no 400 when an effort is active)** — GPT-5 reasoning models reject non-default `temperature`/`top_p` with a `400` whenever a reasoning effort is active, yet accept them again under `reasoning_effort:"none"` (the GPT-5.1+ default, i.e. non-reasoning mode). On the `openai` provider only `o3` carried `REASONING_UNSUPPORTED`; `gpt-5.5`/`gpt-5.4`/`gpt-5.4-mini`/`gpt-5.4-nano` carried no sampling guard, so a `temperature` + active-effort request hard-400'd. A static `unsupportedParams` list can't express the `none`-mode carve-out (it would over-strip the legitimate case), so the new `gpt5SamplingGuard` drops `temperature`/`top_p` only when the resolved effort is active — wired at the existing `getUnsupportedParams` chokepoint and scoped to the `openai` Chat Completions surface (the `codex` Responses path is already covered by the CodexExecutor allowlist; other providers are untouched). ([#4245](https://github.com/diegosouzapw/OmniRoute/pull/4245)) +- **fix(codex): stop silently dropping GPT-5 output verbosity (`verbosity` / `text.verbosity`)** — the GPT-5 series added an output-verbosity control: `verbosity` (low/medium/high) on Chat Completions, nested as `text.verbosity` on the Responses API. The CodexExecutor gates translated requests through an allowlist that had no `text` entry, so for the `codex` provider the hint was dropped before reaching upstream (the `openai` Chat path already forwarded it). `normalizeCodexVerbosity` now folds whichever shape arrived into a single validated `text:{verbosity}` before the allowlist (which now permits `text`), and the OpenAI Chat↔Responses request translators map `verbosity` across formats so the hint survives a format crossing for non-codex Responses backends too. Invalid/absent verbosity collapses to no `text` (status quo). ([#4245](https://github.com/diegosouzapw/OmniRoute/pull/4245)) +- **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) +- **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) +- **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) +- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) +- **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) +- **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) +- **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) +- **fix(compression): show engine preview output** — the Compression Studio preview now renders the engine's output. ([#4128](https://github.com/diegosouzapw/OmniRoute/pull/4128) — thanks @megamen32) +- **fix(compression): harden engines against I/O failures and misconfig (F5.3)** — compression engines degrade gracefully on I/O errors and bad configuration instead of throwing. ([#4198](https://github.com/diegosouzapw/OmniRoute/pull/4198)) +- **fix(compression): harden RTK raw-output redaction + ReDoS guard for custom filters (F5.3)** — broadens RTK raw-output redaction and adds a ReDoS guard for user-supplied filter patterns. ([#4203](https://github.com/diegosouzapw/OmniRoute/pull/4203)) +- **fix(compression): bound `mcpAccessibility` `maxTextChars` on the live read path** — the live read path now clamps `maxTextChars` so a small value can't make tools disappear. ([#4206](https://github.com/diegosouzapw/OmniRoute/pull/4206)) +- **fix(dashboard): data tables paint an opaque surface so the grid doesn't bleed through** — data tables now render on an opaque surface, fixing the grid wallpaper showing through. ([#4233](https://github.com/diegosouzapw/OmniRoute/pull/4233)) +- **fix(dashboard): make the provider card hover visible (was ~1% opacity)** — the provider-card hover state was effectively invisible; it now has a visible surface. ([#4214](https://github.com/diegosouzapw/OmniRoute/pull/4214)) +- **fix(vscode): sanitize implicit editor context** — redacts sensitive filenames/keywords from the implicit VS Code editor context before it's sent upstream. ([#4124](https://github.com/diegosouzapw/OmniRoute/pull/4124) — thanks @zhiru) +- **fix(build): raise the Node heap for the local `next build` to stop OOM/stall** — bumps the build-time heap so the local production build no longer OOMs or stalls. ([#4171](https://github.com/diegosouzapw/OmniRoute/pull/4171)) +- **fix(mitm): TPROXY OUTPUT-based recipe for local traffic (validated e2e on VPS)** — switches the TPROXY rules to an OUTPUT-chain recipe so locally-originated traffic is captured; validated end-to-end on the VPS. ([#4156](https://github.com/diegosouzapw/OmniRoute/pull/4156)) +- **fix(mitm): forward anti-loop — put the bypass-marked socket on the Agent (decrypt 4d)** — places the bypass-marked socket on the HTTP Agent so OmniRoute's own forwarded traffic never re-enters the capture loop; VPS-validated. ([#4229](https://github.com/diegosouzapw/OmniRoute/pull/4229)) +- **fix(free-tiers): retire dead-tier `hasFree`, round the headline to ~1.6B, regenerate the per-provider table** — drops dead free tiers from the headline math and regenerates the per-provider free-tier table. ([#4142](https://github.com/diegosouzapw/OmniRoute/pull/4142)) +- **fix(free-tiers): retire 4 re-verified-dead free tiers, flag iflytek/sparkdesk ToS, clarify monsterapi one-time** — removes four confirmed-dead free tiers and annotates ToS/one-time caveats. ([#4152](https://github.com/diegosouzapw/OmniRoute/pull/4152)) + +### 🧪 Tests + +- **test(sse): guard the Antigravity `_toolNameMap` cloak map through the request-capture round-trip** — follow-up to #4091: the generic capture fix in `createPreparedRequestLogger().body()` (#4153) re-attaches the non-enumerable `_toolNameMap` that the request-inspector drops when it rebuilds the upstream body via `JSON.parse(JSON.stringify(...))`, but the only regression test covered the native-Claude OAuth cloak (PascalCase aliases). The Antigravity cloak differs — `cloakAntigravityToolPayload` suffixes custom tools with `_ide` (`workspace_read` → `workspace_read_ide`), leaves native tools untouched, and returns the reverse map separately — so a refactor of `providerRequestLogging.ts` or the executor could silently re-break Antigravity tool dispatch without tripping the Claude test. Adds a dedicated regression test driving the real `cloakAntigravityToolPayload` through the capture round-trip and asserting the `_ide` reverse map survives, stays non-enumerable (never re-serializes upstream), and that all-native traffic produces no spurious map (verified failing with the #4153 re-attach removed). No production change. ([#4181](https://github.com/diegosouzapw/OmniRoute/issues/4181) — thanks @hertznsk) +- **test(chatcore): dedicated unit tests for 6 leaves + wire into stryker mutate (QG v2 Fase 9 T5 Fase 3)** — adds focused unit tests for 6 chatCore leaf helpers and enrolls them in mutation testing. ([#4218](https://github.com/diegosouzapw/OmniRoute/pull/4218)) +- **test(chatcore): telemetry / memory-skills / semantic-cache tests + wire 2 into stryker (QG v2 Fase 9 T5 Fase 3)** — new tests for the telemetry, memory-skills and semantic-cache leaves, two of which are added to the mutation set. ([#4222](https://github.com/diegosouzapw/OmniRoute/pull/4222)) +- **test+ci(chatcore): semanticCache HIT-path fixture (15/15 mutate) + 350min budget headroom** — closes the semantic-cache HIT path to a full 15/15 mutation score and gives the nightly auth/accountFallback batches more budget headroom. ([#4225](https://github.com/diegosouzapw/OmniRoute/pull/4225)) +- **test(compression): close F5.1 coverage gaps (replay reducer, live accumulator, StatusDot)** — fills the remaining F5.1 compression coverage gaps. ([#4192](https://github.com/diegosouzapw/OmniRoute/pull/4192)) +- **test(db,sse): de-flake db-backup + chatcore streaming timing assertions** — stabilizes two timing-sensitive tests (fire-and-forget backup completion + a streaming race). ([#4132](https://github.com/diegosouzapw/OmniRoute/pull/4132)) +- **test: align stale integration tests surfaced post-v3.8.28 on main** — realigns integration tests that drifted after the v3.8.28 merge. ([#4129](https://github.com/diegosouzapw/OmniRoute/pull/4129)) + +### 📝 Maintenance + +- **refactor(sse): split chatCore.ts pure helpers into chatCore/ modules (−561 LOC)** — extracts pure helpers out of the chatCore god-file into dedicated modules (Onda 3). ([#4159](https://github.com/diegosouzapw/OmniRoute/pull/4159)) +- **refactor(chatcore): extract passthrough/header/telemetry helpers (QG v2 Fase 9 T5 C2-C3-C5)** — further chatCore decomposition. ([#4188](https://github.com/diegosouzapw/OmniRoute/pull/4188)) +- **refactor(chatcore): extract combo/proxy context cache + semaphore helpers (QG v2 Fase 9 T5 C6-C7)** — continues the chatCore split. ([#4193](https://github.com/diegosouzapw/OmniRoute/pull/4193)) +- **refactor(combo): god-file split pilot — types + validateQuality + predicates (QG v2 Fase 9 T5 D1-D3)** — first slice of the combo.ts decomposition. ([#4162](https://github.com/diegosouzapw/OmniRoute/pull/4162)) +- **refactor(combo): god-file split part 2 — shadow + sorters + structure (QG v2 Fase 9 T5 D4-D6)** — continues the combo.ts split. ([#4175](https://github.com/diegosouzapw/OmniRoute/pull/4175)) +- **refactor(combo): god-file split part 3 — auto strategy (QG v2 Fase 9 T5 D8)** — extracts the auto strategy from combo.ts. ([#4186](https://github.com/diegosouzapw/OmniRoute/pull/4186)) +- **refactor(combo): extract round-robin sticky state to `combo/rrState.ts` (D7a)** — moves round-robin sticky state into its own module. ([#4196](https://github.com/diegosouzapw/OmniRoute/pull/4196)) +- **refactor(combo): extract the reset-aware quota block to `combo/quotaStrategies.ts` (D7b)** — moves the reset-aware quota strategies into their own module. ([#4204](https://github.com/diegosouzapw/OmniRoute/pull/4204)) +- **refactor(compression): remove vestigial SLM seam + dead deprecated alias** — drops dead compression code. ([#4253](https://github.com/diegosouzapw/OmniRoute/pull/4253)) +- **chore(compression): remove vestigial reconstructCcr/SessionDedup round-trip helpers** — removes unused round-trip helpers. ([#4226](https://github.com/diegosouzapw/OmniRoute/pull/4226)) +- **chore(compression): remove dead exports + fix stale llmlingua docs** — prunes dead exports and corrects stale LLMLingua docs. ([#4223](https://github.com/diegosouzapw/OmniRoute/pull/4223)) +- **chore(build): build + ship the TPROXY native addon in the standalone (prebuilds 4e)** — bundles the native TPROXY addon prebuilds into the standalone build. ([#4236](https://github.com/diegosouzapw/OmniRoute/pull/4236)) +- **chore(ci): add quota + 6 covered chatCore leaves to stryker mutate (QG v2 Fase 9 T5 Fase 3 follow-up)** — enrolls more covered leaves into mutation testing. ([#4209](https://github.com/diegosouzapw/OmniRoute/pull/4209)) +- **chore(ci): re-add 8 combo split leaves to stryker mutate + expand nightly batch-matrix 3→5 (QG v2 Fase 9 T5 Fase 3)** — restores mutation coverage for the split combo leaves and widens the nightly matrix. ([#4205](https://github.com/diegosouzapw/OmniRoute/pull/4205)) +- **chore(quality): close v3.8.28 cycle gate drift (re-baseline + nightly-mutation scope)** — reconciles quality-gate baselines after the v3.8.28 cycle. ([#4135](https://github.com/diegosouzapw/OmniRoute/pull/4135)) +- **ci(mutation): split nightly into 3 parallel batches to fit the 180min budget (QG v2 Fase 9 T0)** — parallelizes the nightly mutation run. ([#4150](https://github.com/diegosouzapw/OmniRoute/pull/4150)) +- **ci(mutation): restore cold-seed timeout headroom (a/b lost in #4225 squash) + extend to c/d/g/h** — restores and extends per-batch cold-seed timeouts. ([#4258](https://github.com/diegosouzapw/OmniRoute/pull/4258)) +- **ci(docs): harden the fabricated-docs checker + enforce `--strict` (QG v2 Fase 9 T9)** — tightens the anti-hallucination docs checker. ([#4149](https://github.com/diegosouzapw/OmniRoute/pull/4149)) +- **ci: derive the oasdiff base-ref from the package version + flag the mutation-toolchain regression** — fixes the OpenAPI-diff base-ref and surfaces a mutation-toolchain regression. ([#4134](https://github.com/diegosouzapw/OmniRoute/pull/4134)) +- **docs(ci): correct the mutation-gate note (no regression — `stryker -c` is `--concurrency`); record Task 12 GO** — corrects a misread of the stryker flag and records the spike GO. ([#4138](https://github.com/diegosouzapw/OmniRoute/pull/4138)) +- **docs(api): document the `/api/v1/ws` chat WebSocket endpoint in openapi.yaml** — adds the WebSocket chat endpoint to the OpenAPI spec. ([#4215](https://github.com/diegosouzapw/OmniRoute/pull/4215)) +- **docs(readme): expand Acknowledgments into a themed, star-counted credits hall** — reworks the README acknowledgments section. ([#4195](https://github.com/diegosouzapw/OmniRoute/pull/4195)) +- **style(dashboard): shrink the identity grid cell 46px → 32px (~30% smaller)** — tightens the identity grid density. ([#4143](https://github.com/diegosouzapw/OmniRoute/pull/4143)) + +### 🔧 Dependencies + +- **deps: bump the production group with 5 updates** — routine production-dependency bumps. ([#4121](https://github.com/diegosouzapw/OmniRoute/pull/4121)) +- **chore(deps): bump github/codeql-action from 3 to 4** — CI action update. ([#4120](https://github.com/diegosouzapw/OmniRoute/pull/4120)) +- **chore(deps): bump actions/setup-python from 5 to 6** — CI action update. ([#4119](https://github.com/diegosouzapw/OmniRoute/pull/4119)) + +--- + ## [3.8.28] — 2026-06-17 ### ✨ New Features diff --git a/README.md b/README.md index 97ae2ad898..423b31168c 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,13 @@
-**~1.5B documented free tokens/month** — up to **~2.1B in your first month** with signup credits — aggregated across the free tiers, plus a long tail of permanently-free, no-cap providers, and the compression above stretches every one further. ([how we count →](docs/reference/FREE_TIERS.md#tldr--how-much-free-inference-does-omniroute-actually-aggregate)) +**~1.6B documented free tokens/month** — up to **~2.1B in your first month** with signup credits — aggregated across the free tiers, plus a long tail of permanently-free, no-cap providers, and the compression above stretches every one further. ([how we count →](docs/reference/FREE_TIERS.md#tldr--how-much-free-inference-does-omniroute-actually-aggregate))
[![227 AI Providers](https://img.shields.io/badge/227-AI_Providers-6C5CE7?style=for-the-badge)](#-227-ai-providers--50-free) [![50+ Free](https://img.shields.io/badge/50%2B-Free_Tiers-00B894?style=for-the-badge)](#-227-ai-providers--50-free) -[![1.5B Free Tokens/mo](https://img.shields.io/badge/1.5B-Free_Tokens%2Fmo-00B894?style=for-the-badge)](docs/reference/FREE_TIERS.md) +[![1.6B Free Tokens/mo](https://img.shields.io/badge/1.6B-Free_Tokens%2Fmo-00B894?style=for-the-badge)](docs/reference/FREE_TIERS.md) [![Token Savings](https://img.shields.io/badge/up_to_95%25-Token_Savings-E17055?style=for-the-badge)](#%EF%B8%8F-save-1595-tokens--automatically) [![15 Strategies](https://img.shields.io/badge/15-Routing_Strategies-0984E3?style=for-the-badge)](#-combos--the-flagship) [![$0 to start](https://img.shields.io/badge/%240-To_Start-FDCB6E?style=for-the-badge&logoColor=black)](#-quick-start) @@ -114,13 +114,13 @@
-# 💰 ~1.5B Free Tokens / Month +# 💰 ~1.6B Free Tokens / Month
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute aggregates the **documented** free tiers of **40+ provider pools / 500+ models** into one honest number and shows it live on the dashboard (`/dashboard/free-tiers`). -- **~1.5B free tokens / month** (steady) — and **up to ~2.1B in your first month** with signup credits. +- **~1.6B free tokens / month** (steady) — and **up to ~2.1B in your first month** with signup credits. - **Pool-deduped, honest** — we count each shared free pool **once**, so the headline isn't inflated by rate-limit ceilings the way multi-billion competitor claims are. (Counting every rate limit 24/7 would read ~10B; we don't publish that.) - **Plus the un-countable** — permanently-free, no-token-cap providers (SiliconFlow, Z.AI GLM-Flash, Kilo, OpenCode Zen…) and a **$10 OpenRouter top-up** that unlocks **+24M/mo**, both surfaced separately so they never inflate the headline. - **Per-model breakdown**, **live used / remaining** for the current month, and a transparent **terms flag** per provider. @@ -408,6 +408,21 @@ omniroute setup # guided first-run wizard omniroute doctor # diagnose providers, ports, native deps ``` +### 🛰️ Remote mode — run the CLI here, OmniRoute on a VPS + +OmniRoute on a server? Drive it from your laptop with the **same CLI**. Log in once +with a scoped access token; every command then targets the remote. + +```bash +omniroute connect 192.168.0.15 # password → scoped token, saved as a context +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 +``` + +Tokens are scoped `read` / `write` / `admin`; process-spawning routes stay loopback-only. +📖 [Remote Mode](docs/guides/REMOTE-MODE.md) +
`providers` · `oauth` · `keys` · `combo` · `nodes` · `models` · `cache` · `compression` · `cost` · `usage` · `quota` · `health` · `resilience` · `telemetry` · `logs` · `audit` · `mcp` · `a2a` · `cloud` · `memory` · `skills` · `eval` · `tunnel` · `backup` · `sync` · `webhooks` · `policy` · `pricing` · `translator` · `simulate` … @@ -845,6 +860,7 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo | [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 | | [Quick Start](README.md#-quick-start) | 3-step install → connect → configure | ### 🔧 Operations & Deployment @@ -1023,31 +1039,76 @@ gh release create v3.8.2 --title "v3.8.2" --generate-notes
-Special thanks to **[9router](https://github.com/decolua/9router)** by **[decolua](https://github.com/decolua)** — the original project that inspired this fork. OmniRoute builds upon that incredible foundation with additional features, multi-modal APIs, and a full TypeScript rewrite. +OmniRoute stands on the shoulders of giants. It started as a fork of **[9router](https://github.com/decolua/9router)** and a TypeScript port of the Go project **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — and from there, every subsystem below was inspired by an open-source project that got there first. Each one shaped a concrete piece of OmniRoute. This is our thank-you to all of them. 🙏 -Special thanks to **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** by **[router-for-me](https://github.com/router-for-me)** — the original Go implementation that inspired this JavaScript port. +> ⭐ star counts as of June 2026 — go give these projects a star. -Special thanks to **[Caveman](https://github.com/JuliusBrussee/caveman)** by **[JuliusBrussee](https://github.com/JuliusBrussee)** (⭐ 51K+) — the viral "why use many token when few token do trick" project whose caveman-speak compression philosophy inspired OmniRoute's standard compression mode and 30+ filler/condensation regex rules. +### 🧬 Lineage & gateway -Special thanks to **[RTK - Rust Token Killer](https://github.com/rtk-ai/rtk)** by **[RTK AI](https://github.com/rtk-ai)** — the high-performance command-output compression project whose terminal, build, test, git, and tool-output filtering model inspired OmniRoute's RTK engine, JSON filter DSL, raw-output recovery, and stacked RTK → Caveman compression pipeline. +| Project | ⭐ | How it inspired OmniRoute | +| ------------------------------------------------------------------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------- | +| **[9router](https://github.com/decolua/9router)** · decolua | 17.9k | The original project this fork is built on — extended here with multi-modal APIs and a full TypeScript rewrite. | +| **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** · router-for-me | 37.8k | The Go implementation that inspired this JavaScript / TypeScript port. | +| **[LiteLLM](https://github.com/BerriAI/litellm)** · BerriAI | 50.8k | The AI gateway whose public pricing dataset feeds our cost-tracking sync and whose provider-normalization model informed our routing. | -Special thanks to **[Troglodita](https://github.com/leninejunior/troglodita)** by **[Lenine Júnior](https://github.com/leninejunior)** — the PT-BR token compression project ("por que gastar muitos tokens quando poucos resolve?") whose Portuguese-native rules power OmniRoute's pt-BR language pack: pleonasm reduction, filler removal tuned for Brazilian Portuguese grammar, and technical abbreviations for the dev BR community. +### 🗜️ Context & token compression — engines -Special thanks to **[headroom](https://github.com/chopratejas/headroom)** by **[chopratejas](https://github.com/chopratejas)** — the reversible context-compression project whose SmartCrusher (per-type routing, reversible block compaction, internal hash cache) directly inspired OmniRoute's `headroom` engine and the `ccr` retrieve-marker pattern. +| Project | ⭐ | How it inspired OmniRoute | +| ---------------------------------------------------------------------------- | ----: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **[Caveman](https://github.com/JuliusBrussee/caveman)** · JuliusBrussee | 74.5k | The viral "why use many token when few token do trick" project — its caveman-speak philosophy powers our standard compression mode and 30+ filler/condensation rules. | +| **[RTK – Rust Token Killer](https://github.com/rtk-ai/rtk)** · rtk-ai | 63.6k | High-performance command-output compression — inspired our RTK engine, JSON filter DSL, raw-output recovery and the stacked RTK → Caveman pipeline. | +| **[headroom](https://github.com/chopratejas/headroom)** · chopratejas | 33.6k | Reversible context-compression (SmartCrusher) — inspired our `headroom` engine and the `ccr` retrieve-marker pattern. | +| **[LLMLingua](https://github.com/microsoft/LLMLingua)** · Microsoft | 6.3k | Prompt-compression research (LLMLingua / LLMLingua-2) — inspired our async, code-safe, fail-open `llmlingua` engine. | +| **[llmlingua-2-js](https://github.com/atjsh/llmlingua-2-js)** · atjsh | 27 | The JS/ONNX port (MobileBERT / XLM-RoBERTa) used as the worker-thread backend for our LLMLingua engine. | +| **[Troglodita](https://github.com/leninejunior/troglodita)** · Lenine Júnior | 15 | PT-BR token compression — powers our pt-BR language pack: pleonasm reduction and filler removal tuned for Brazilian-Portuguese grammar. | -Special thanks to **[TOON](https://github.com/toon-format/toon)** by **[toon-format](https://github.com/toon-format)** and **[GCF — Graph Compact Format](https://github.com/blackwell-systems/gcf)** by **[Dayna Blackwell / Blackwell Systems](https://github.com/blackwell-systems)** — the compact, schema-aware "JSON for LLMs" notations whose columnar, header-plus-rows model shaped OmniRoute's `headroom`/SmartCrusher tabular stage: a dependency-free, lossless compaction of homogeneous JSON arrays with an explicit `[N rows]` marker. +### 🧩 Compact formats, token research & code-aware tooling -Special thanks to **[token-optimizer-mcp](https://github.com/ooples/token-optimizer-mcp)** by **[ooples](https://github.com/ooples)** — the Brotli/SQLite cache + per-session context-delta project whose content-addressed delta model inspired OmniRoute's `session-dedup` engine (cross-turn block deduplication with reversible references). +| Project | ⭐ | How it inspired OmniRoute | +| ---------------------------------------------------------------------------------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------ | +| **[TOON](https://github.com/toon-format/toon)** · toon-format | 24.6k | Token-Oriented Object Notation — its columnar, header-plus-rows model shaped our tabular compaction stage. | +| **[GCF – Graph Compact Format](https://github.com/blackwell-systems/gcf)** · Blackwell Systems | 11 | Schema-aware "JSON for LLMs" notation — co-inspired our lossless homogeneous-array compaction with `[N rows]` markers. | +| **[token-optimizer-mcp](https://github.com/ooples/token-optimizer-mcp)** · ooples | 409 | Brotli/SQLite cache + per-session context-delta — inspired our `session-dedup` engine. | +| **[token-savior](https://github.com/Mibayy/token-savior)** · Mibayy | 993 | Bash-output compaction + MCP profiles — inspired our compression bail-out discipline and MCP tool-manifest reduction. | +| **[token-saver](https://github.com/ppgranger/token-saver)** · ppgranger | 103 | Content-aware, per-file-type output compression with failure-aware bail-out — validated our per-type dispatch and minimum-gain skip. | +| **[token-optimizer](https://github.com/alexgreensh/token-optimizer)** · alexgreensh | 1.4k | "Find the ghost tokens" — its offload + recoverable-handle pattern informed our CCR offload thinking. | +| **[TokenMizer](https://github.com/Shweta-Mishra-ai/tokenmizer)** · Shweta-Mishra-ai | 1 | A session-graph + cross-turn line-dedup blueprint that informed our session-dedup design. | +| **[mcp-compressor](https://github.com/atlassian-labs/mcp-compressor)** · Atlassian Labs | 80 | MCP tool-schema/description compression — informed our MCP tool-manifest cardinality reduction. | +| **[RepoMapper](https://github.com/pdavis68/RepoMapper)** · pdavis68 | 182 | Aider-style repo-map ranking — informed our repo-map / retrieval-ranking exploration. | +| **[quiet-shell-mcp](https://github.com/mrsimpson/quiet-shell-mcp)** · mrsimpson | 4 | Declarative shell-output reduction over MCP — validated our declarative bash-output compaction. | +| **[ts-morph](https://github.com/dsherret/ts-morph)** · David Sherret | 6.1k | TypeScript Compiler API toolkit — inspired our parser-based comment removal that preserves string, template and regex literals. | -Special thanks to **[token-savior](https://github.com/Mibayy/token-savior)** by **[Mibayy](https://github.com/Mibayy)** — the Bash-output compaction + MCP-profiles project whose failure-aware bail-out and tool-profile model inspired OmniRoute's compression bail-out discipline and MCP tool-manifest cardinality reduction. +### 🧠 Memory & RAG -Special thanks to **[LLMLingua](https://github.com/microsoft/LLMLingua)** by **[Microsoft](https://github.com/microsoft)** — the prompt-compression research (LLMLingua / LLMLingua-2) whose token-level semantic pruning inspired OmniRoute's async `llmlingua` engine (prose-only, code-safe, fail-open), together with the JS/ONNX port **[llmlingua-2-js](https://github.com/atjsh/llmlingua-2-js)** by **[atjsh](https://github.com/atjsh)** (MobileBERT / XLM-RoBERTa ONNX models) as its intended worker-thread backend. +| Project | ⭐ | How it inspired OmniRoute | +| ------------------------------------------------------------------ | ----: | ------------------------------------------------------------------------------------------------------------------- | +| **[Mem0](https://github.com/mem0ai/mem0)** · mem0ai | 58.9k | Universal memory layer — its proxy-as-write/read-boundary model shaped our memory architecture. | +| **[Letta (MemGPT)](https://github.com/letta-ai/letta)** · letta-ai | 23.4k | Stateful agents with tiered memory — inspired our Context Control & Recovery (CCR) tiered model. | +| **[WFGY](https://github.com/onestardao/WFGY)** · onestardao | 1.8k | The ProblemMap taxonomy of 16 recurring RAG/LLM failure modes — the shared vocabulary in our troubleshooting guide. | -Special thanks to **[ts-morph](https://github.com/dsherret/ts-morph)** by **[David Sherret](https://github.com/dsherret)** — the TypeScript Compiler API toolkit whose AST approach inspired OmniRoute's parser-based code-comment removal, which correctly preserves string, template, and regex literals where naïve regex stripping corrupts them. +### 🛰️ Traffic inspection, MITM & transparent proxy -Special thanks to **[React Flow / xyflow](https://github.com/xyflow/xyflow)** by **[xyflow](https://github.com/xyflow)** — the node-based graph library that powers OmniRoute's real-time **Compression Studio** and **Combo/Routing Studio** dashboards. +| Project | ⭐ | How it inspired OmniRoute | +| --------------------------------------------------------------------------------- | ---: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **[llm-interceptor](https://github.com/chouzz/llm-interceptor)** · chouzz | 46 | MITM interception/analysis of coding-assistant ↔ LLM traffic — our Traffic Inspector ports its SSE merge, conversation normalization, host passthrough and secret masking (MIT). | +| **[ProxyBridge](https://github.com/InterceptSuite/ProxyBridge)** · InterceptSuite | 5.1k | Transparent per-process proxy routing — inspired our crash-safe MITM teardown, socket idle-timeouts, `/proc` process attribution and TPROXY capture. | -Special thanks to **[LangGraph](https://github.com/langchain-ai/langgraph)** by **[LangChain](https://github.com/langchain-ai)** — the agent-graph framework whose LangGraph Studio live workflow-graph visualization inspired OmniRoute's Compression and Combo Studios: watching compression engines and combo fallbacks cascade in real time. +### 📚 Model data, observability & UI + +| Project | ⭐ | How it inspired OmniRoute | +| -------------------------------------------------------------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------- | +| **[models.dev](https://github.com/anomalyco/models.dev)** · SST / OpenCode | 5.1k | Open database of AI model specs, pricing and capabilities — synced natively into our model catalog. | +| **[React Flow / xyflow](https://github.com/xyflow/xyflow)** · xyflow | 37.1k | The node-based graph library powering our real-time Compression Studio and Combo/Routing Studio. | +| **[LangGraph](https://github.com/langchain-ai/langgraph)** · LangChain | 35.1k | LangGraph Studio's live workflow-graph visualization inspired our Studios' real-time cascade view. | +| **[Langfuse](https://github.com/langfuse/langfuse)** · Langfuse | 29.3k | Its trace → span → generation observability model shaped our Compression Studio waterfall. | +| **[Kiali](https://github.com/kiali/kiali)** · Kiali | 3.6k | Istio service-mesh observability — inspired our circuit-breaker badges and error-edge visuals in the Routing/Combo Studio. | +| **[lobe-icons](https://github.com/lobehub/lobe-icons)** · LobeHub | 2.1k | AI/LLM brand logos that render the provider icons across our dashboard. | + +### 🛡️ Security + +| Project | ⭐ | How it inspired OmniRoute | +| ------------------------------------------------------------------------------------------- | --: | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| **[awesome-secure-defaults](https://github.com/tldrsec/awesome-secure-defaults)** · tldrsec | 708 | A curated list of secure-by-default libraries that guides our security choices (Helmet.js, DOMPurify, ssrf-req-filter, safe-regex, Google Tink). | ## ❤️ Support diff --git a/bin/cli/api.mjs b/bin/cli/api.mjs index 30a7feec60..ca2e09fa37 100644 --- a/bin/cli/api.mjs +++ b/bin/cli/api.mjs @@ -1,8 +1,6 @@ import { setTimeout as sleep } from "node:timers/promises"; -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { resolveDataDir } from "./data-dir.mjs"; import { getCliToken, CLI_TOKEN_HEADER } from "./utils/cliToken.mjs"; +import { resolveActiveContext } from "./contexts.mjs"; export const RETRY_DEFAULTS = Object.freeze({ maxAttempts: 3, @@ -28,14 +26,12 @@ export function getBaseUrl(opts = {}) { const envUrl = process.env.OMNIROUTE_BASE_URL; if (envUrl) return stripTrailingSlash(envUrl); + // Resolve from the active context (canonical store + legacy profile fallback). + // This is what makes "remote mode" work: `omniroute contexts use ` + // routes every command at the remote server's baseUrl. try { - const configPath = join(resolveDataDir(), "config.json"); - if (existsSync(configPath)) { - const cfg = JSON.parse(readFileSync(configPath, "utf8")); - const profile = cfg.activeProfile && cfg.profiles?.[cfg.activeProfile]; - if (profile?.baseUrl) return stripTrailingSlash(profile.baseUrl); - if (cfg.baseUrl) return stripTrailingSlash(cfg.baseUrl); - } + const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT); + if (ctx?.baseUrl) return stripTrailingSlash(ctx.baseUrl); } catch { // Config read failures are not fatal — fall through to default. } @@ -56,15 +52,26 @@ function resolveUrl(path, opts) { return `${getBaseUrl(opts)}${path.startsWith("/") ? path : `/${path}`}`; } -async function buildHeaders(opts) { +export async function buildHeaders(opts) { const headers = new Headers(opts.headers || {}); if (!headers.has("accept")) headers.set("accept", "application/json"); if (opts.body && !headers.has("content-type") && typeof opts.body !== "string") { headers.set("content-type", "application/json"); } - const apiKey = opts.apiKey ?? process.env.OMNIROUTE_API_KEY; - if (apiKey && !headers.has("authorization")) { - headers.set("authorization", `Bearer ${apiKey}`); + // Auth precedence: explicit opts/env → active context. Within a context the + // scoped accessToken wins over the legacy apiKey. This routes the active + // context's credential to the (possibly remote) server automatically. + let auth = opts.apiKey ?? process.env.OMNIROUTE_API_KEY; + if (!auth) { + try { + const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT); + auth = ctx?.accessToken || ctx?.apiKey || null; + } catch { + // No context credential available — continue unauthenticated. + } + } + if (auth && !headers.has("authorization")) { + headers.set("authorization", `Bearer ${auth}`); } // Inject machine-id derived CLI token; env var override for testing. const cliToken = opts.cliToken ?? process.env.OMNIROUTE_CLI_TOKEN ?? (await getCliToken()); diff --git a/bin/cli/commands/configure.mjs b/bin/cli/commands/configure.mjs new file mode 100644 index 0000000000..2a92cd25a2 --- /dev/null +++ b/bin/cli/commands/configure.mjs @@ -0,0 +1,180 @@ +import os from "node:os"; +import path from "node:path"; +import { existsSync, mkdirSync, writeFileSync, copyFileSync } from "node:fs"; +import { apiFetch } from "../api.mjs"; +import { createPrompt, printSuccess, printError, printInfo, printHeading } from "../io.mjs"; +import { t } from "../i18n.mjs"; + +/** + * `omniroute configure ` — interactive provider+model picker that writes a + * local CLI config pointed at the ACTIVE OmniRoute context (local or remote). + * + * The model catalog comes from the active context's GET /v1/models, so when you + * are in remote mode (`omniroute connect ...`) you pick from the remote server's + * live models and the profile is written on THIS machine. + * + * v1 targets the Codex CLI (writes ~/.codex/.config.toml). The credential + * is referenced by env var (OMNIROUTE_API_KEY) — never written to disk. + */ + +const SUPPORTED = ["codex"]; + +/** Derive a short, filesystem-safe profile name from a model id. */ +export function profileNameFromModel(modelId) { + const afterProvider = String(modelId).includes("/") + ? String(modelId).split("/").slice(1).join("/") + : String(modelId); + return afterProvider.replace(/[^a-zA-Z0-9]+/g, "").toLowerCase() || "model"; +} + +/** Provider id for a catalog entry: explicit owned_by, else the id prefix. */ +function providerOf(entry) { + if (entry && typeof entry.owned_by === "string" && entry.owned_by) return entry.owned_by; + const id = typeof entry === "string" ? entry : entry?.id || ""; + return id.includes("/") ? id.split("/")[0] : "(none)"; +} + +function contextWindowOf(entry) { + for (const c of [entry?.context_length, entry?.max_context_window_tokens]) { + if (typeof c === "number" && Number.isFinite(c) && c > 0) return c; + } + return null; +} + +async function fetchModels(globalOpts) { + const res = await apiFetch("/v1/models", { ...globalOpts, acceptNotOk: true }); + if (!res.ok) { + let msg = `HTTP ${res.status}`; + try { + const b = await res.json(); + msg = b?.error?.message || b?.error || msg; + } catch { + /* ignore */ + } + throw new Error(`Could not fetch models: ${msg}`); + } + const body = await res.json(); + const list = Array.isArray(body) ? body : body.data || body.models || []; + return list.filter((m) => (typeof m === "string" ? m : m?.id)); +} + +function buildCodexProfile(modelId, ctx) { + const lines = [ + `# codex --profile ${profileNameFromModel(modelId)}`, + `# ${modelId} — generated by 'omniroute configure codex'`, + `model = "${modelId}"`, + `model_provider = "omniroute"`, + ]; + if (ctx && ctx > 0) { + const compact = Math.floor(ctx * 0.85); + lines.push(`model_context_window = ${ctx}`); + lines.push(`model_auto_compact_token_limit = ${compact}`); + } + return lines.join("\n") + "\n"; +} + +async function configureCodex(modelId, ctxWindow, opts) { + const codexHome = opts.codexHome || path.join(os.homedir(), ".codex"); + if (!existsSync(codexHome)) mkdirSync(codexHome, { recursive: true }); + const profile = opts.name || profileNameFromModel(modelId); + const filePath = path.join(codexHome, `${profile}.config.toml`); + if (existsSync(filePath)) { + copyFileSync(filePath, `${filePath}.bak`); + } + writeFileSync(filePath, buildCodexProfile(modelId, ctxWindow), "utf8"); + printSuccess(`Wrote ${filePath}`); + printInfo(`Use it: codex --profile ${profile}`); + printInfo("Prereq: ~/.codex/config.toml must define the [model_providers.omniroute] block"); + printInfo(" (run the Codex setup once — see docs/guides/CODEX-CLI-CONFIGURATION.md)."); +} + +export async function runConfigureCommand(cli, opts = {}, cmd) { + const target = String(cli || "").toLowerCase(); + if (!SUPPORTED.includes(target)) { + printError(`Unsupported CLI '${cli}'. Supported: ${SUPPORTED.join(", ")}.`); + return 2; + } + const globalOpts = cmd ? cmd.optsWithGlobals() : {}; + + let models; + try { + models = await fetchModels(globalOpts); + } catch (e) { + printError(e instanceof Error ? e.message : String(e)); + return 1; + } + if (!models.length) { + printError("The server returned no models."); + return 1; + } + + // Resolve model: explicit flags or interactive pick. + let chosenId = opts.model; + if (chosenId && opts.provider && !chosenId.includes("/")) { + chosenId = `${opts.provider}/${chosenId}`; + } + + if (!chosenId) { + const ids = models.map((m) => (typeof m === "string" ? m : m.id)); + const providers = [...new Set(models.map(providerOf))].sort(); + const prompt = createPrompt(); + try { + printHeading("Configure Codex CLI"); + let providerList = providers; + if (opts.provider) { + providerList = providers.filter((p) => p === opts.provider); + } else { + printInfo(`Providers: ${providers.join(", ")}`); + const p = await prompt.ask("Provider"); + if (p) providerList = providers.filter((x) => x === p); + } + const inProvider = ids.filter((id) => providerList.includes(providerOf(byId(models, id)))); + const candidates = inProvider.length ? inProvider : ids; + printInfo(`Models: ${candidates.slice(0, 40).join(", ")}${candidates.length > 40 ? " …" : ""}`); + chosenId = await prompt.ask("Model id"); + } finally { + prompt.close(); + } + } + + if (!chosenId) { + printError("No model selected."); + return 2; + } + const entry = byId(models, chosenId); + if (!entry) { + printError(`Model '${chosenId}' is not in the catalog.`); + return 2; + } + const ctxWindow = contextWindowOf(entry); + + if (target === "codex") { + await configureCodex(chosenId, ctxWindow, opts); + } + return 0; +} + +function byId(models, id) { + for (const m of models) { + const mid = typeof m === "string" ? m : m.id; + if (mid === id) return m; + } + return null; +} + +export function registerConfigure(program) { + program + .command("configure ") + .description( + t("configure.description") || + "Pick a provider+model from the active server and write a local CLI config (v1: codex)" + ) + .option("--provider ", "Provider id (skips the interactive provider prompt)") + .option("--model ", "Model id (skips the interactive model prompt)") + .option("--name ", "Profile name to write (default: derived from model)") + .option("--codex-home ", "Codex home dir (default: ~/.codex)") + .action(async (cli, opts, cmd) => { + const code = await runConfigureCommand(cli, opts, cmd); + if (code !== 0) process.exit(code); + }); +} diff --git a/bin/cli/commands/connect.mjs b/bin/cli/commands/connect.mjs new file mode 100644 index 0000000000..b7ec71ae97 --- /dev/null +++ b/bin/cli/commands/connect.mjs @@ -0,0 +1,132 @@ +import { apiFetch } from "../api.mjs"; +import { loadContexts, saveContexts } from "../contexts.mjs"; +import { createPrompt, printSuccess, printError, printInfo } from "../io.mjs"; +import { t } from "../i18n.mjs"; + +/** + * `omniroute connect ` — remote mode. + * + * Logs into a remote OmniRoute server and saves the result as the active context + * so every subsequent command targets that server. Two flows: + * - password: prompts for the management password → POST /api/cli/connect → + * server mints a scoped access token (default scope: admin). + * - token: `--key ` validates via GET /api/cli/whoami and saves it. + */ + +/** Normalize a host/URL into a server root baseUrl (no trailing path). */ +export function normalizeBaseUrl(host, port) { + let value = String(host || "").trim(); + if (!value) return ""; + const hadScheme = /^https?:\/\//i.test(value); + if (!hadScheme) value = `http://${value}`; + try { + const u = new URL(value); + // Only apply the default port to a bare host; a full URL is taken as-is. + if (!hadScheme && !u.port && port) u.port = String(port); + return u.origin; + } catch { + return value; + } +} + +/** Derive a clean context name from a host (strip scheme/port). */ +export function hostLabel(host) { + let value = String(host || "").trim().replace(/^https?:\/\//i, ""); + value = value.split("/")[0].split(":")[0]; + return value || "remote"; +} + +async function readErrorMessage(res) { + try { + const body = await res.json(); + return body?.error?.message || body?.error || `HTTP ${res.status}`; + } catch { + return `HTTP ${res.status}`; + } +} + +export async function runConnectCommand(host, opts = {}) { + const baseUrl = normalizeBaseUrl(host, opts.port || "20128"); + if (!baseUrl) { + printError("A host is required, e.g. omniroute connect 192.168.0.15"); + return 2; + } + const name = opts.name || hostLabel(host); + + let accessToken; + let scope; + + if (opts.key) { + // Validate the pasted token against the remote. + const res = await apiFetch("/api/cli/whoami", { + baseUrl, + apiKey: opts.key, + acceptNotOk: true, + }); + if (!res.ok) { + printError(`Token rejected by ${baseUrl}: ${await readErrorMessage(res)}`); + return res.exitCode || 1; + } + const body = await res.json(); + accessToken = opts.key; + scope = body.scope || "unknown"; + } else { + const prompt = createPrompt(); + let password; + try { + password = await prompt.askSecret(`Management password for ${baseUrl}`); + } finally { + prompt.close(); + } + if (!password) { + printError("Password is required (or use --key )."); + return 2; + } + const res = await apiFetch("/api/cli/connect", { + baseUrl, + method: "POST", + body: { password, name, scope: opts.scope }, + acceptNotOk: true, + retry: false, + }); + if (!res.ok) { + printError(`Connect failed (${res.status}): ${await readErrorMessage(res)}`); + return res.exitCode || 1; + } + const body = await res.json(); + accessToken = body.token; + scope = body.scope; + } + + const cfg = loadContexts(); + cfg.contexts = cfg.contexts || {}; + cfg.contexts[name] = { + baseUrl, + accessToken, + scope, + description: `Remote OmniRoute (${host})`, + }; + cfg.currentContext = name; + saveContexts(cfg); + + printSuccess(`Connected to ${baseUrl} — context '${name}' (scope: ${scope})`); + printInfo("All commands now target this server."); + printInfo("Switch back to local with: omniroute contexts use default"); + return 0; +} + +export function registerConnect(program) { + program + .command("connect ") + .description( + t("connect.description") || "Connect to a remote OmniRoute server and enter remote mode" + ) + .option("--port ", "Server port when the host has none", "20128") + .option("--key ", "Use a pre-generated scoped access token (skips the password prompt)") + .option("--name ", "Context name to save (default: derived from host)") + .option("--scope ", "Requested scope for the password flow (read|write|admin)") + .action(async (host, opts) => { + const code = await runConnectCommand(host, opts); + if (code !== 0) process.exit(code); + }); +} diff --git a/bin/cli/commands/contexts.mjs b/bin/cli/commands/contexts.mjs index 63a3dde925..00f565ed96 100644 --- a/bin/cli/commands/contexts.mjs +++ b/bin/cli/commands/contexts.mjs @@ -1,6 +1,13 @@ import { t } from "../i18n.mjs"; import { emit } from "../output.mjs"; -import { loadContexts, saveContexts, configPath } from "../contexts.mjs"; +import { loadContexts, saveContexts, resolveActiveContext } from "../contexts.mjs"; + +/** Auth label for a context: prefers the scoped accessToken over the legacy apiKey. */ +function authLabel(c) { + if (c?.accessToken) return "token"; + if (c?.apiKey) return "key"; + return "✗"; +} async function confirm(msg) { const readline = await import("node:readline"); @@ -31,7 +38,8 @@ export function registerContexts(program) { active: name === (cfg.currentContext || "default") ? "●" : "", name, baseUrl: c.baseUrl || "", - auth: c.apiKey ? "✓" : "✗", + auth: authLabel(c), + scope: c.scope || "", description: c.description || "", })); emit(rows, globalOpts, [ @@ -39,6 +47,7 @@ export function registerContexts(program) { { key: "name", header: "Name" }, { key: "baseUrl", header: "Base URL" }, { key: "auth", header: "Auth" }, + { key: "scope", header: "Scope" }, { key: "description", header: "Description" }, ]); }); @@ -47,8 +56,11 @@ export function registerContexts(program) { .command("add ") .description("Add a new context") .requiredOption("--url ", "Base URL") - .option("--api-key ", "API key") + .option("--api-key ", "Legacy inference API key") .option("--api-key-stdin", "Read API key from stdin") + .option("--access-token ", "Scoped CLI access token (preferred over --api-key)") + .option("--access-token-stdin", "Read access token from stdin") + .option("--scope ", "Token scope hint for display (read|write|admin)") .option("--description ", "Context description") .action(async (name, opts) => { const cfg = loadContexts(); @@ -57,15 +69,20 @@ export function registerContexts(program) { process.exit(2); } let apiKey = opts.apiKey || null; - if (opts.apiKeyStdin) { + let accessToken = opts.accessToken || null; + if (opts.apiKeyStdin || opts.accessTokenStdin) { const chunks = []; for await (const c of process.stdin) chunks.push(c); - apiKey = chunks.join("").trim() || null; + const value = chunks.join("").trim() || null; + if (opts.accessTokenStdin) accessToken = value; + else apiKey = value; } cfg.contexts = cfg.contexts || {}; cfg.contexts[name] = { baseUrl: opts.url, + accessToken: accessToken || undefined, apiKey, + scope: opts.scope || undefined, description: opts.description || undefined, }; saveContexts(cfg); @@ -88,10 +105,27 @@ export function registerContexts(program) { ctx .command("current") - .description("Show current active context name") - .action(() => { + .description("Show the active context (server, auth, scope)") + .option("--name-only", "Print just the context name (legacy behavior)") + .action((opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); const cfg = loadContexts(); - process.stdout.write(`${cfg.currentContext || "default"}\n`); + const name = cfg.currentContext || cfg.activeProfile || "default"; + if (opts.nameOnly) { + process.stdout.write(`${name}\n`); + return; + } + const c = resolveActiveContext(name); + emit( + { + name, + baseUrl: c.baseUrl || "", + auth: authLabel(c), + scope: c.scope || "", + description: c.description || "", + }, + globalOpts + ); }); ctx @@ -108,7 +142,9 @@ export function registerContexts(program) { const display = { name, baseUrl: c.baseUrl, + accessToken: maskKey(c.accessToken), apiKey: maskKey(c.apiKey), + scope: c.scope, description: c.description, }; emit(display, globalOpts); @@ -172,6 +208,7 @@ export function registerContexts(program) { if (opts.noSecrets) { for (const c of Object.values(out.contexts || {})) { c.apiKey = null; + delete c.accessToken; } } const json = JSON.stringify(out, null, 2); @@ -209,7 +246,9 @@ export function registerContexts(program) { const c = raw && typeof raw === "object" ? /** @type {Record} */ (raw) : {}; cfg.contexts[name] = { baseUrl: typeof c.baseUrl === "string" ? c.baseUrl : "http://localhost:20128", + accessToken: typeof c.accessToken === "string" ? c.accessToken : undefined, apiKey: typeof c.apiKey === "string" ? c.apiKey : null, + scope: typeof c.scope === "string" ? c.scope : undefined, description: typeof c.description === "string" ? c.description : undefined, }; count++; diff --git a/bin/cli/commands/registry.mjs b/bin/cli/commands/registry.mjs index a99f64a730..f3d4f197a1 100644 --- a/bin/cli/commands/registry.mjs +++ b/bin/cli/commands/registry.mjs @@ -56,6 +56,9 @@ import { registerTray } from "./tray.mjs"; import { registerAutostart } from "./autostart.mjs"; import { registerRepl } from "./repl.mjs"; import { registerLaunch } from "./launch.mjs"; +import { registerConnect } from "./connect.mjs"; +import { registerTokens } from "./tokens.mjs"; +import { registerConfigure } from "./configure.mjs"; import { registerApiCommands } from "../api-commands/registry.mjs"; import { registerPlugin } from "./plugin.mjs"; @@ -119,6 +122,9 @@ export function registerCommands(program) { registerAutostart(program); registerRepl(program); registerLaunch(program); + registerConnect(program); + registerTokens(program); + registerConfigure(program); registerApiCommands(program); registerPlugin(program); } diff --git a/bin/cli/commands/tokens.mjs b/bin/cli/commands/tokens.mjs new file mode 100644 index 0000000000..31327cf1d6 --- /dev/null +++ b/bin/cli/commands/tokens.mjs @@ -0,0 +1,118 @@ +import { apiFetch } from "../api.mjs"; +import { emit } from "../output.mjs"; +import { printSuccess, printError, printInfo } from "../io.mjs"; +import { t } from "../i18n.mjs"; + +/** + * `omniroute tokens` — manage scoped CLI access tokens on the active (usually + * remote) server. Requires an `admin` credential — the commands hit + * /api/cli/tokens which is admin-only. Uses the active context's auth via + * apiFetch automatically. + */ + +async function readErrorMessage(res) { + try { + const body = await res.json(); + return body?.error?.message || body?.error || `HTTP ${res.status}`; + } catch { + return `HTTP ${res.status}`; + } +} + +export function registerTokens(program) { + const tokens = program + .command("tokens") + .description(t("tokens.description") || "Manage scoped CLI access tokens (remote mode)"); + + tokens + .command("create") + .description("Create a new access token (requires admin scope)") + .requiredOption("--name ", "Human-readable token name") + .option("--scope ", "Scope: read | write | admin", "read") + .option("--expires ", "Expire after N days (default: never)") + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const body = { name: opts.name, scope: opts.scope }; + if (opts.expires) { + const days = Number(opts.expires); + if (!Number.isFinite(days) || days <= 0) { + printError("--expires must be a positive number of days."); + process.exit(2); + } + body.expiresInDays = days; + } + const res = await apiFetch("/api/cli/tokens", { + ...globalOpts, + method: "POST", + body, + acceptNotOk: true, + }); + if (!res.ok) { + printError(`Could not create token: ${await readErrorMessage(res)}`); + process.exit(res.exitCode || 1); + } + const b = await res.json(); + printSuccess(`Token '${b.name}' created (scope: ${b.scope}).`); + printInfo("Copy it now — it will NOT be shown again:"); + process.stdout.write(`${b.token}\n`); + }); + + tokens + .command("list") + .description("List access tokens (masked)") + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const res = await apiFetch("/api/cli/tokens", { ...globalOpts, acceptNotOk: true }); + if (!res.ok) { + printError(`Could not list tokens: ${await readErrorMessage(res)}`); + process.exit(res.exitCode || 1); + } + const b = await res.json(); + const rows = (b.tokens || []).map((tk) => ({ + id: tk.id, + name: tk.name, + scope: tk.scope, + prefix: tk.tokenPrefix, + created: tk.createdAt, + lastUsed: tk.lastUsedAt || "", + expires: tk.expiresAt || "", + status: tk.revokedAt ? "revoked" : "active", + })); + emit(rows, globalOpts, [ + { key: "id", header: "ID" }, + { key: "name", header: "Name" }, + { key: "scope", header: "Scope" }, + { key: "prefix", header: "Prefix" }, + { key: "status", header: "Status" }, + { key: "lastUsed", header: "Last Used" }, + { key: "expires", header: "Expires" }, + ]); + }); + + tokens + .command("revoke ") + .description("Revoke an access token by id or display prefix") + .action(async (idOrPrefix, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const res = await apiFetch(`/api/cli/tokens/${encodeURIComponent(idOrPrefix)}`, { + ...globalOpts, + method: "DELETE", + acceptNotOk: true, + }); + if (!res.ok) { + printError(`Could not revoke token: ${await readErrorMessage(res)}`); + process.exit(res.exitCode || 1); + } + printSuccess(`Revoked ${idOrPrefix}.`); + }); + + tokens + .command("scopes") + .description("Explain the three access-token scopes") + .action(() => { + printInfo("Access-token scopes (admin ⊃ write ⊃ read):"); + process.stdout.write(" read list/inspect only (models, status, logs, usage)\n"); + process.stdout.write(" write read + configure/apply (setup-codex, keys add, config set)\n"); + process.stdout.write(" admin write + manage (tokens, providers add, services, policy)\n"); + }); +} diff --git a/bin/cli/commands/update.mjs b/bin/cli/commands/update.mjs index e3c95cfd81..f5c696e09d 100644 --- a/bin/cli/commands/update.mjs +++ b/bin/cli/commands/update.mjs @@ -142,7 +142,7 @@ export async function runUpdateCommand(opts = {}) { } if (dryRun) { - console.log("\n [DRY RUN] Would run: npm install -g omniroute@latest"); + console.log("\n [DRY RUN] Would run: npm install -g omniroute@latest --include=optional"); if (!skipBackup) console.log(" [DRY RUN] Would create backup in ~/.omniroute/backups/"); return 0; } @@ -174,7 +174,9 @@ export async function runUpdateCommand(opts = {}) { printInfo("Updating OmniRoute..."); try { const { execSync } = await import("child_process"); - execSync("npm install -g omniroute@latest", { stdio: "inherit" }); + // --include=optional keeps the optionalDependencies (better-sqlite3, keytar, + // tls-client, llmlingua SLM stack) on update so an omit=optional config can't drop them. + execSync("npm install -g omniroute@latest --include=optional", { stdio: "inherit" }); printSuccess(`Updated to version ${latest}`); printInfo("Run `omniroute --version` to verify."); return 0; diff --git a/bin/cli/contexts.mjs b/bin/cli/contexts.mjs index 625114085f..2a691a1ef9 100644 --- a/bin/cli/contexts.mjs +++ b/bin/cli/contexts.mjs @@ -36,8 +36,25 @@ export function saveContexts(cfg) { } catch {} } +/** + * Resolve the active context for a CLI invocation. + * + * Canonical schema is `{ currentContext, contexts }` (written by + * `omniroute contexts ...`). For backward compatibility we also read the legacy + * `{ activeProfile, profiles }` shape and a bare top-level `baseUrl` — older + * configs and `api.mjs::getBaseUrl` used those before remote-mode unified the + * store. `overrideName` (from `--context`/`OMNIROUTE_CONTEXT`) wins when set. + * + * A context may carry `{ baseUrl, accessToken?, apiKey?, scope?, description? }`. + * `accessToken` is the scoped CLI access token (preferred); `apiKey` is the + * legacy inference key kept for back-compat. + */ export function resolveActiveContext(overrideName) { const cfg = loadContexts(); - const name = overrideName || cfg.currentContext || "default"; - return cfg.contexts?.[name] || cfg.contexts?.default || { baseUrl: "http://localhost:20128" }; + const contexts = cfg.contexts || cfg.profiles || {}; + const name = overrideName || cfg.currentContext || cfg.activeProfile || "default"; + const found = contexts[name] || contexts.default; + if (found) return found; + if (cfg.baseUrl) return { baseUrl: cfg.baseUrl }; + return { baseUrl: `http://localhost:${process.env.PORT || "20128"}` }; } diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index bc447aab51..cdecdabafe 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -1262,5 +1262,14 @@ "token": "API key the Claude client should send (ANTHROPIC_AUTH_TOKEN)", "notRunning": "OmniRoute is not running on port {port}. Start it with 'omniroute serve'.", "notFound": "The 'claude' CLI was not found in PATH." + }, + "connect": { + "description": "Connect to a remote OmniRoute server and enter remote mode" + }, + "tokens": { + "description": "Manage scoped CLI access tokens (remote mode)" + }, + "configure": { + "description": "Pick a provider+model from the active server and write a local CLI config" } } diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index e79fc678c1..e907b7c28f 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -1261,5 +1261,14 @@ "token": "Chave de API que o cliente Claude deve enviar (ANTHROPIC_AUTH_TOKEN)", "notRunning": "OmniRoute não está rodando na porta {port}. Inicie com 'omniroute serve'.", "notFound": "O CLI 'claude' não foi encontrado no PATH." + }, + "connect": { + "description": "Conecta a um servidor OmniRoute remoto e entra no modo remoto" + }, + "tokens": { + "description": "Gerencia tokens de acesso CLI com escopo (modo remoto)" + }, + "configure": { + "description": "Escolhe um provedor+modelo do servidor ativo e grava uma configuração de CLI local" } } diff --git a/config/quality/dependency-allowlist.json b/config/quality/dependency-allowlist.json index 691e3d68ad..40d6621723 100644 --- a/config/quality/dependency-allowlist.json +++ b/config/quality/dependency-allowlist.json @@ -38,6 +38,7 @@ "better-sqlite3", "bottleneck", "c8", + "clsx", "commander", "concurrently", "cross-env", @@ -106,6 +107,7 @@ "socks", "sql.js", "sqlite-vec", + "tailwind-merge", "tailwindcss", "tls-client-node", "tsup", diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 8627eb3dfd..b3dccba554 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,5 +1,33 @@ { "_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.", + "_rebaseline_2026_06_19_4249_vercel_gateway_live_models": "Issue #4249 own growth: providers/[id]/models/route.ts 2534->2538 (+4 = one NAMED_OPENAI_STYLE_PROVIDERS Set entry `vercel-ai-gateway` + a 3-line comment). Same fix shape as #4202 (zenmux) / #3976 (llm7/byteplus): vercel-ai-gateway carries a real baseUrl (https://ai-gateway.vercel.sh/v1/chat/completions, format openai) but was unclassified by every live-fetch branch, so import served the 5-entry hardcoded registry catalog instead of the upstream list — the import button looked broken (loaded nothing usable) while manual add worked. The `/models` probe (after stripping /chat/completions) resolves to https://ai-gateway.vercel.sh/v1/models; falls back to the local catalog on upstream error so import never breaks. Pure additive Set membership; not extractable.", + "_rebaseline_2026_06_19_4227_cursor_cloud_ui": "PR #4250 (#4227 Cursor Cloud Agent) cross-layer UI wiring own growth: cloud-agents/page.tsx 913->922 (+9 = one CLOUD_AGENTS dropdown entry for the new `cursor-cloud` provider, mirroring the existing jules/devin/codex-cloud entries). The backend agent (cursor.ts) + registry/types/credentials-route landed in the PR, but the dashboard CLOUD_AGENTS list, health PROVIDER_NAMES, and lobeProviderIcons maps are hardcoded (client/server boundary — the registry can't be imported into the client page), so the agent was API-usable but not selectable in the UI and rendered with the Jules fallback name/icon. The +9 is the single presentational data entry at the existing hardcoded list; not extractable (it IS the list). The health-name + icon adds land in non-frozen files.", + "_rebaseline_2026_06_19_cost_telemetry_combo_prettier": "NOT this PR's logic change: combo.ts 2601->2605 (+4) is a pure Prettier reflow applied by the cost-telemetry-parity merge commit's lint-staged hook. The DEFAULT_WEIGHTS/ProviderCandidate/ScoringWeights import from ./autoCombo/scoring.ts is 102 chars (> printWidth 100) on release (it landed via a GitHub merge that never ran the local Prettier hook), so the hook wrapped it to the canonical 4-line form. Zero logic change (git diff -w empty); cost-telemetry-parity never edits combo.ts. Reverting is futile — Prettier re-wraps the 102-char import on any later commit. Bumped here so the merge surfacing the reflow carries its own baseline.", + "_rebaseline_2026_06_18_cost_telemetry_parity": "Cost Telemetry Parity own growth: chatCore.ts 5095->5097 (+2 net at the existing non-streaming success return chokepoint, ~line 4629). The inline `headers: { ...buildOmniRouteResponseMetaHeaders({…}) }` spread was converted into an explicit `responseHeaders` Record + a single attachOmniRouteMetaHeaders(responseHeaders, {…, requestId: skillRequestId}) call, routing this return through the new choke-point helper and threading the new identity header X-OmniRoute-Request-Id (skillRequestId). The now-orphaned buildOmniRouteResponseMetaHeaders import was dropped (only attachOmniRouteMetaHeaders is used here), netting the construction to +2. The reusable helper attachOmniRouteMetaHeaders + the always-on X-OmniRoute-Version header live in the (non-frozen) src/domain/omnirouteResponseMeta.ts. Cohesive telemetry growth at the response chokepoint; the +2 is the headers-bag construction needed to mutate-in-place rather than spread, not extractable without hiding the return boundary. Structural shrink of chatCore.ts tracked in #3501.", + "_rebaseline_2026_06_19_4235_auto_suffix_combos": "#4235 Phase B own growth: chat.ts 1471->1486 (+15 = auto/: recognition at the existing zero-config auto-routing chokepoint — parseAutoSuffix wiring + autoSpec thread-through to createVirtualAutoCombo) and catalog.ts 1463->1465 (+2 = AUTO_SUFFIX_VARIANTS import + advertise the curated suffix combos in the existing /v1/models auto loop). All real logic (the parser, tier->weights, candidate filter) lives in the new open-sse/services/autoCombo/suffixComposition.ts (5109 (+7 = import + a 4-line comment + the call stripGpt5SamplingWhenReasoning(translatedBody, provider, finalModelToUpstream, log) right after the getUnsupportedParams strip block). GPT-5 reasoning models on the openai Chat Completions surface 400 on temperature/top_p whenever a reasoning effort is active, yet accept them under reasoning_effort=none (the GPT-5.1+ default), so a static unsupportedParams list can't express it — the conditional strip lives in the new pure leaf open-sse/services/gpt5SamplingGuard.ts (81 LOC, 1449 (+2 = import normalizeCodexVerbosity + a 3-line comment/call before the Responses allowlist + the new \"text\" allowlist entry with its 1-line comment). GPT-5 output verbosity (verbosity on Chat, text.verbosity on Responses) was dropped by the allowlist; the fold into a validated text:{verbosity} lives in the new pure leaf open-sse/services/codexVerbosity.ts (53 LOC, 5102 (+7 = wire normalizeClaudeAdaptiveThinking(translatedBody, finalModelToUpstream) at the existing post-translation thinking-normalization chokepoint, right after normalizeThinkingForModel — its import line + a 5-line explanatory comment + the call). Opus 4.7+/Fable 5 removed manual extended thinking: `thinking.type:\"enabled\"` or any `thinking.budget_tokens` is a hard 400, so this collapses any manual thinking that reached dispatch (passthrough legacy shape, reasoning_effort buckets, per-model defaults) to `{type:\"adaptive\"}`, keyed on the resolved upstream model. The reusable guard lives in the new pure leaf open-sse/services/claudeAdaptiveThinking.ts (1468 (+10 at the existing registerTool override in createMcpServer = F4.3 opt-in tool-cardinality wiring). Reads readMcpToolProfileFromEnv(process.env) once (MCP_TOOL_DENY/MCP_TOOL_ALLOW; null = no filter, the default), and when a registered tool is denied by the profile calls registered.disable() so it is not announced in tools/list (token savings). The default (null) profile never enters the branch — existing behavior byte-identical. The reusable parser + reduceToolManifest decision live in the (non-frozen) toolCardinality.ts. Cohesive opt-in feature at the registration chokepoint; not extractable without hiding the register boundary.", + "_rebaseline_2026_06_18_4217_compression_step_streaming": "PR #4217 own growth: chatCore.ts 5063->5086 (+23 at the existing compression-apply chokepoint = the best-effort onEngineStep callback threaded into applyCompressionAsync). The callback builds a compression.step payload and fires emit(\"compression.step\", …) + forwardDashboardEventToLiveWs(…) once per stacked engine as it completes (F3.3 live per-engine streaming), wrapped in try/catch so it never fails the request. It closes over the same emit/traceId/mode locals as the compression.completed emit right below it (line 1749); the reusable per-engine emission lives in strategySelector.ts (reportEngineStep + StackedCompressionStep) and the studio reducers in compressionFlowModel.ts (both 5063 (+3 = wire ensureEngineBreakdown(result.stats) into the existing compression.completed emit + its import line). Single-engine modes (rtk/lite/standard/aggressive/ultra) leave stats.engineBreakdown empty, which made the dashboard studio render an empty Input->Output pipeline (no engine node); the synthesized 1-entry breakdown lives in the new pure leaf open-sse/services/compression/engineBreakdown.ts (2534 (+3 = one NAMED_OPENAI_STYLE_PROVIDERS Set entry `zenmux` + a 2-line comment). zenmux carries a real modelsUrl but was not classified by any live-fetch branch, so its hardcoded 9-entry registry catalog was served (source local_catalog, 'API unavailable — using local catalog') instead of the upstream list — hiding the free models it advertises (z-ai/glm-5.2-free, moonshotai/kimi-k2.7-code-free). Same fix shape as #3976 (llm7/byteplus): the `/models` probe (after stripping /chat/completions) resolves to https://zenmux.ai/api/v1/models. Pure additive Set membership; not extractable.", + "_rebaseline_2026_06_18_4189_combo_token_limits": "PR #4189 (megamen32) own growth: catalog.ts 1440->1463 (+23 at the existing #4164 auto/* emission chokepoint). The bare auto/* /v1/models entries are enriched with the combo's advertised context/output limits (createBuiltinAutoCombo → advertisedContextLength/advertisedMaxOutputTokens computed from the candidate pool, 128000/8192 fallback) + baseline capabilities, with a try/catch that emits the minimal #4164 entry on resolve failure so the id is never dropped. OpenAI-compatible pickers (Hermes) need a context window before the first request. Cohesive emission at the single auto/* loop; not extractable.", + "_rebaseline_2026_06_18_4180_gemini_default_thinking": "PR #4180 own growth: openai-to-gemini.ts 844->864 (+20 = default includeThoughts for modern Gemini 2.5+ at the existing openaiToGeminiBase chokepoint — when the client set no thinkingConfig, inject one (includeThoughts:true + a capped budget) so the model's reasoning is marked thought:true and routed to reasoning_content instead of leaking into visible content, #4170). Cohesive translator branch gated to gemini-2.5+/3 (excludes gemini-1.x and non-thinking 2.0); not extractable. Reconciled here because #4180 merged without the baseline bump.", + "_rebaseline_2026_06_18_qg9_chatcore_split_prB": "QG v2 Fase 9 T5 C6-C7: chatCore.ts 5265->5060 (shrink, stacked on prA #4188) — two pure, byte-identical extractions into open-sse/handlers/chatCore/. (1) comboContextCache.ts (5265 (wc -l 5264 + 1). Fase 2 of the chatCore split following #4159 (which created 10 leaf modules). Three more sibling leaves created under open-sse/handlers/chatCore/, all 866 (+21) and EditConnectionModal.tsx 1174->1204 (+30) = the 'import only free models' connection option — a free-models Toggle gated by providerHasFreeModels() plus its form-state wiring (importFreeModelsOnly field + providerSpecificData persistence, explicit-false on edit so the PUT merge doesn't keep a stale true) added to both connection modals, mirroring the prior per-modal toggle bumps #3879 (redact-thinking) and #2997 (disable-cooling). Detection lives in the new shared src/shared/utils/freeModels.ts (112 LOC, 2531 (+19 = one PROVIDER_MODELS_CONFIG entry for `qwen-web` + a 4-line comment). qwen-web was missing from the config map so its model-discovery page returned nothing (the OAuth fallback only fires for provider===qwen). Pure additive config entry pointing at the public chat.qwen.ai/api/v2/models endpoint; standard per-provider addition, not extractable.", + "_rebaseline_2026_06_18_4165_queue_timeout_msg": "Issue #4165 own growth: rateLimitManager.ts 1022->1035 (+13 at the existing withRateLimit catch chokepoint). Bottleneck's raw `This job timed out after ms.` is rewritten into a clear OmniRoute-owned error (names resilienceSettings.requestQueue.maxWaitMs, disclaims upstream, keeps the original as `cause`, tags code=RATE_LIMIT_QUEUE_TIMEOUT) so queue-saturation 502s stop masquerading as provider outages. The branch already existed (it only logged); this adds the error construction at the same point. Not extractable — closes over provider/model/maxWaitMs locals of the single catch.", + "_rebaseline_2026_06_18_qg9_combo_split_d4": "QG v2 Fase 9 T5 D4: combo.ts 4740->4589 — shadow routing extracted byte-identically to the new open-sse/services/combo/shadowRouting.ts (4430 — target sorters extracted byte-identically to the new open-sse/services/combo/targetSorters.ts (3819 — combo structure resolution extracted byte-identically to the new open-sse/services/combo/comboStructure.ts (638, 3432 — auto-strategy scoring/intent/tag-routing extracted byte-identically to the new open-sse/services/combo/autoStrategy.ts (434, autoStrategy import cycle (a later task can move the reset-window block out of combo.ts without breaking autoStrategy). autoStrategy.ts never imports from the combo barrel. dedupeTargetsByExecutionKey was already in comboData.ts (D6) and is NOT re-moved. QUOTA_SOFT_DEPRIORITIZE_FACTOR + setCandidateQuotaSoftPenalty stay re-exported from combo.ts for chatCore.ts's dynamic import(\"../services/combo\"); scoreAutoTargets/expandAutoComboCandidatePool keep their public re-export too. Orphaned combo.ts imports dropped where moved-out symbols stopped being referenced; ProviderCandidate/AutoProviderCandidate/HistoricalLatencyStatsEntry types are imported into combo.ts for buildAutoCandidates. Pure move, no logic change.", + "_rebaseline_2026_06_18_qg9_combo_split_d7a": "QG v2 Fase 9 T5 D7a: combo.ts 3440->3398 (wc -l 3397 + 1, stacked on #4194 which added the passthrough-quota guards) — round-robin sticky state extracted byte-identically to the new open-sse/services/combo/rrState.ts (71, ) + rrStickyTargets, and the three helpers clampStickyRoundRobinTargetLimit/getStickyRoundRobinStartIndex/recordStickyRoundRobinSuccess. STATE COHESION: rrCounters and rrStickyTargets remain SINGLE instances defined once in rrState.ts; combo.ts imports the same references back and keeps mutating them directly in orderTargetsByResetAwareQuota/orderTargetsByResetWindow/handleRoundRobinCombo (no Map duplicated — sticky-3 round-robin behavior preserved, guarded by combo-routing-e2e.test.ts). The quota state left behind (MAX_RESET_AWARE_CACHE/resetAwareConnectionCache/resetAwareQuotaCache) stays in combo.ts for a later task (D7b). None of the 6 were ever public, so combo.ts imports all six back (no re-export). rrState.ts never imports from the combo barrel; it imports only ResolvedComboTarget from ./types.ts. Pure move, no logic change.", + "_rebaseline_2026_06_18_qg9_combo_split_d7b": "QG v2 Fase 9 T5 D7b: combo.ts 3398->2597 (wc -l 2596 + 1) — the reset-aware / reset-window quota block extracted byte-identically into two new leaves under open-sse/services/combo/ (both 800 cap): quotaScoring.ts (311) holds the PURE half (config consts/resolvers + window-math/scoring helpers, no state, no async); quotaStrategies.ts (568) holds the STATEFUL/async half — the two mutable caches resetAwareConnectionCache + resetAwareQuotaCache (new Map) + MAX_RESET_AWARE_CACHE kept as SINGLE instances next to their only readers/writers getQuotaAwareConnectionsForTarget + fetchResetAwareQuotaWithCache (STATE COHESION; grep 'resetAware*Cache = new Map' in combo.ts == 0), plus normalizeConnectionIds/filterAllowedConnectionIds/getTargetConnectionIds/mapWithConcurrency/preScreenTargets/orderTargetsByResetAwareQuota/orderTargetsByResetWindow. quotaStrategies imports rrCounters + MAX_RR_COUNTERS from ./rrState.ts (D7a) and the pure helpers from ./quotaScoring.ts; neither leaf imports the combo barrel. combo.ts imports back the 3 reset-window helpers buildAutoCandidates needs (resolveResetWindowConfig/fetchResetAwareQuotaWithCache/calculateResetWindowAffinity) + resolveSlaRoutingPolicy + the 3 orderers used by the orchestrator; preScreenTargets stays re-exported. Orphaned imports dropped (clamp01/PRE_SCREEN_CONCURRENCY/ProviderProfile/SlaRoutingPolicy/IsModelAvailable); hasPerModelQuota KEPT (used by the #4194 passthrough-quota guards that stay). buildAutoCandidates + calculateTargetContextAffinity/getBootstrapLatencyMs stay (D8). Pure move, no logic change.", + "_rebaseline_2026_06_18_4206_mcpaccess_bounds": "PR #4206 own growth: open-sse/mcp-server/server.ts 1457->1458 (+1 = one named import `clampMcpAccessibilityConfig` added to the existing constants.ts import block in readMcpAccessibilityConfig). The fix bounds the LIVE read path (a persisted out-of-range maxTextChars made smartFilterText truncate the whole tool result away); all real logic lives in the new clampMcpAccessibilityConfig helper in engines/mcpAccessibility/constants.ts (1022 (+5 = one import + one `await awaitProviderDefaultSlot(...)` call + a 2-line comment at the existing withRateLimit chokepoint). All sliding-window logic was extracted to the new open-sse/services/providerDefaultRateLimit.ts + open-sse/services/slidingWindowLimiter.ts (both 1440 (+5 = appendNoThinkingVariants(finalModels) call + comment at the existing finalModels chokepoint) and chat.ts 1458->1471 (+13 = applyNoThinkingAlias(body) call + comment right after body.model is read, before model resolution). All real logic lives in the new open-sse/utils/noThinkingAlias.ts (6009 (+29 at the existing streaming-return chokepoint = capture streamRecovery.continueMidStream alongside .enabled; refactor the early-retry reopen thunk into a shared runUpstreamStream(body) helper — net DRY — and add the gated continueStream(assistantSoFar) thunk that re-runs the upstream with makeContinuationBody(bodyToSend, …), plus the onContinue log). All continuation logic (scanOpenAiSseText, makeContinuationBody, trimContinuationOverlap, the createRecoverableStream continuation path) lives in open-sse/services/streamRecovery.ts (3169 (+10 = Wafer AI catalog entry, a single Zod-validated provider record in the providers map — pure data, standard per-provider addition; bumps catalog 227->228). Cohesive catalog growth; not extractable.", "_rebaseline_2026_06_17_4096_field_downgrade": "PR #4096 own growth: base.ts 1292->1334 (+42 = generic 400 field-downgrade retry at the executor fetch loop — on an upstream 400 that names an unsupported field, strip it via providerFieldStrips and retry once, plus Groq field stripping wiring). The strip table lives in the new open-sse/config/providerFieldStrips.ts (5289 (+6 = vision-aware routing fix in getTargetCompatibilityFailures — image requests now require supportsVision===true, treating null/unknown as incompatible, with an explanatory comment block; plus exporting filterTargetsByRequestCompatibility for the regression test). The accompanying capability heuristic lives in src/lib/modelCapabilities.ts (419 LOC, 1661 (+28 = the allow_usage_command additive column — fallback definition, parseAllowUsageCommand, prepared-statement SELECT column, and the metadata/create/update wiring, mirroring the existing disable_non_public_models accessor pattern) and chat.ts 1425->1432 (+7 = the handleInternalUsageCommand intercept hook at the existing post-auth chokepoint in handleChat). The reusable command logic lives in the new src/lib/usage/internalUsageCommand.ts (well under cap). Cohesive opt-in feature at the established column/dispatch boundaries; not extractable without splitting the api-keys domain module.", "_rebaseline_2026_06_17_ctx_editing_relays": "Context Editing relay coverage + 400-fallback (F4.2/F4.3) own growth: base.ts 1244->1292 (+48 at the existing dispatch chokepoint: extend the injection gate to anthropic-compatible-cc-* relays with an expanded comment documenting the claude-web / generic-anthropic-compatible exclusions; the contextEditingDisabled loop flag; and the 400-fallback block that, when an upstream rejects context_management with a 400, strips the param, re-serializes + re-signs (CCH), and retries the same URL once). Both edits sit on the per-URL request-build/fetch path and cannot be extracted without splitting the executor's fetch loop. No new file warranted — the logic is a few lines tightly coupled to the in-loop transformedBody/bodyString/response locals.", "_rebaseline_2026_06_17_ctx_editing_telemetry": "Context Editing telemetry (F4.1) own growth: chatCore.ts 5875->5898 (+23 = a best-effort, Claude-only, non-streaming block at the existing non-streaming response handler that extracts the provider's server-side context-editing receipt from responseBody and records it under engine \"context-editing\"). The pure extractor (extractContextEditingTelemetry) lives in the existing small open-sse/config/contextEditing.ts and the DB writer (recordContextEditingTelemetry) in src/lib/db/compressionAnalytics.ts — both under cap. Cohesive at the response-handling chokepoint next to the existing attachCompressionUsageReceipt call; not extractable without hiding the receipt-recording boundary.", + "_rebaseline_2026_06_17_duckduckgo_free": "Free web search (free-claude-code port, Fase 6) own growth: handlers/search.ts 1442->1546 (+104 = tryDuckDuckGoFreeProvider, a dedicated provider path mirroring the existing tryZaiMCPProvider special-case: the lite endpoint returns HTML, not the JSON the generic tryProvider()/normalizeResponse() flow expects, so it cannot reuse that path). The reusable parser + guarded fetch (parseDuckDuckGoLite, freeWebSearch) live in the new small open-sse/services/freeWebSearch.ts (well under cap). Cohesive at the per-provider dispatch chokepoint; not extractable without hiding the provider boundary.", + "_rebaseline_2026_06_18_4228_combo_fallback_bailout": "PR #4228 own growth: combo.ts 2597->2601 (+4 at the existing proactive-fallback applyCompression call in handleComboChat = opt into the TV1 bail-out so a throwing fallback engine is SKIPPED instead of propagating out of executeTarget and being swallowed as a 'Speculative task error' that silently drops the combo target). minGainPercent:0 keeps the advance behavior identical to the default path — this only adds skip-on-throw. The bailout option is threaded through the (non-frozen) strategySelector.ts applyCompression signature down to applyStackedCompression. Cohesive guard at the existing fallback-compression chokepoint; not extractable without hiding the call site.", + "_rebaseline_2026_06_17_stream_recovery": "Stream recovery wiring (free-claude-code port, Fase 4) own growth: chatCore.ts 5898->5980 (+82 at the existing streaming-return chokepoint: read the resolved streamRecovery.enabled setting once; when ON, a reopenStream() thunk re-executes the SAME upstream — it closes over the ~15 per-attempt executor locals (executor/provider/modelToCall/bodyToSend/upstreamStream/execCreds/extendedContext/headers builders/onCredentialsRefreshed/skipUpstreamRetry/contextEditingEnabled) — and the body is wrapped with createRecoverableStream for transparent early-retry; OFF keeps the byte-identical wrapReadableStreamWithFinalize path). All reusable logic (HoldbackBuffer, createRecoverableStream, isRetryableStreamError, hasTerminalMarker) lives in the new open-sse/services/streamRecovery.ts (well under cap). Not extractable without hiding the per-attempt executor-dispatch boundary; opt-in (default OFF). Structural shrink of chatCore.ts tracked in #3501.", "cap": 800, "frozen": { "open-sse/config/providerRegistry.ts": 4731, "open-sse/executors/antigravity.ts": 1664, - "open-sse/executors/base.ts": 1334, + "open-sse/executors/base.ts": 1358, "open-sse/executors/chatgpt-web.ts": 2870, "open-sse/executors/claude-web.ts": 1057, - "open-sse/executors/codex.ts": 1447, + "open-sse/executors/codex.ts": 1449, "open-sse/executors/cursor.ts": 1391, "open-sse/executors/deepseek-web.ts": 1117, "open-sse/executors/duckduckgo-web.ts": 917, @@ -47,24 +78,24 @@ "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": 5898, + "open-sse/handlers/chatCore.ts": 5111, "open-sse/handlers/imageGeneration.ts": 3777, "open-sse/handlers/responseSanitizer.ts": 1103, - "open-sse/handlers/search.ts": 1442, + "open-sse/handlers/search.ts": 1546, "open-sse/handlers/sseParser.ts": 812, "open-sse/handlers/videoGeneration.ts": 1078, "open-sse/mcp-server/schemas/tools.ts": 1437, - "open-sse/mcp-server/server.ts": 1457, + "open-sse/mcp-server/server.ts": 1468, "open-sse/mcp-server/tools/advancedTools.ts": 1118, "open-sse/services/accountFallback.ts": 1727, "open-sse/services/batchProcessor.ts": 828, "open-sse/services/browserBackedChat.ts": 850, "open-sse/services/claudeCodeCompatible.ts": 1202, - "open-sse/services/combo.ts": 5298, - "open-sse/services/rateLimitManager.ts": 1017, + "open-sse/services/combo.ts": 2605, + "open-sse/services/rateLimitManager.ts": 1035, "open-sse/services/tokenRefresh.ts": 1997, "open-sse/services/usage.ts": 3408, - "open-sse/translator/request/openai-to-gemini.ts": 844, + "open-sse/translator/request/openai-to-gemini.ts": 864, "open-sse/translator/response/openai-responses.ts": 903, "open-sse/utils/cursorAgentProtobuf.ts": 1521, "open-sse/utils/stream.ts": 2710, @@ -74,7 +105,7 @@ "src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx": 1105, "src/app/(dashboard)/dashboard/cache/page.tsx": 841, "src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx": 894, - "src/app/(dashboard)/dashboard/cloud-agents/page.tsx": 913, + "src/app/(dashboard)/dashboard/cloud-agents/page.tsx": 922, "src/app/(dashboard)/dashboard/combos/page.tsx": 4350, "src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1495, "src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": 1007, @@ -83,8 +114,8 @@ "src/app/(dashboard)/dashboard/playground/components/tabs/ApiTab.tsx": 847, "src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 784, "src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx": 941, - "src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 845, - "src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1174, + "src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 866, + "src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1204, "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, @@ -104,10 +135,10 @@ "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": 2512, + "src/app/api/providers/[id]/models/route.ts": 2538, "src/app/api/providers/[id]/test/route.ts": 842, "src/app/api/usage/analytics/route.ts": 941, - "src/app/api/v1/models/catalog.ts": 1435, + "src/app/api/v1/models/catalog.ts": 1465, "src/lib/cloudflaredTunnel.ts": 934, "src/lib/db/apiKeys.ts": 1661, "src/lib/db/core.ts": 1820, @@ -134,7 +165,7 @@ "src/shared/constants/sidebarVisibility.ts": 1100, "src/shared/services/cliRuntime.ts": 1090, "src/shared/validation/schemas.ts": 2523, - "src/sse/handlers/chat.ts": 1458, + "src/sse/handlers/chat.ts": 1486, "src/sse/services/auth.ts": 2219 }, "_rebaseline_2026_06_09": "Re-baseline consciente pre-release v3.8.19: 9 arquivos cresceram durante o ciclo (features mergeadas: RequestLoggerV2 +281 request-logger rework, stream +101, combo +73, chatCore +45, catalog +32 fable-5/catalog-flag, callLogs +4, accountFallback +2, usageHistory novo 840) + core.ts +7 (fix resetAllDbModuleState, PR 3536). A catraca segue valendo destes valores — proximo crescimento falha. Decisao: encolher (esp. RequestLoggerV2/chatCore) e a issue #3501 ficam para o ciclo seguinte.", diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index cbb2762b57..12b131006c 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": 3769, + "value": 3816, "direction": "down" }, "eslintErrors": { @@ -50,7 +50,7 @@ "tightenSlack": 10 }, "coverage.auth.lines": { - "value": 69, + "value": 90, "direction": "up", "eps": 1.5, "tightenSlack": 10 @@ -80,12 +80,12 @@ "tightenSlack": 10 }, "openapiCoverage.pct": { - "value": 38.3, + "value": 38.4, "direction": "up", "eps": 0.5 }, "i18nUiCoverage.pct": { - "value": 80.1, + "value": 79.1, "direction": "up", "eps": 0.5 }, @@ -136,7 +136,12 @@ "dedicatedGate": true, "_note": "oasdiff breaking-change gate (Fase 9 Onda 0). Blocks any breaking change vs base spec." }, - "semgrepFindings": { "value": 0, "direction": "down", "dedicatedGate": true, "_note": "semgrep owasp/secrets findings. ADVISORY until first CI value is frozen, then flip blocking (Fase 9)." } + "semgrepFindings": { + "value": 0, + "direction": "down", + "dedicatedGate": true, + "_note": "semgrep owasp/secrets findings. ADVISORY until first CI value is frozen, then flip blocking (Fase 9)." + } }, "_coverage_note": "Pisos anti-flake ~2pt abaixo do real do CI mergeado MEDIDO COM os 135 testes religados (run 27247237268: statements 78.4 / lines 78.4 / functions 83.84 / branches 75.73). O religamento da 6A.1 HONESTIFICOU a regua: os ~82.5 anteriores eram inflados porque modulos nunca importados ficavam fora do denominador do c8. Apertar via --require-tighten na Fase 6A (2026-06-16).", "_eslint_rebaseline_2026_06_09": "Baseline 3482 foi congelado na branch das Fases 0-6 (base ~v3.8.17) ANTES da v3.8.18 sair; a tag v3.8.18 publicada ja media 3501 (delta nasceu no fim daquele ciclo, antes do job quality-gate existir no ci.yml). O ciclo v3.8.19 esta NEUTRO (tag 3501 == HEAD 3501). Re-baseline para o estado real da main publicada; reduzir os ~19 (any em codigo do ciclo v3.8.18) e ligar --require-tighten ficam para a Fase 6A (2026-06-16).", @@ -157,5 +162,7 @@ "_require_tighten_advisory_2026_06_15": "Fase 6A.5: o gate --require-tighten (falha quando uma metrica melhora sem o baseline ser apertado no mesmo PR) foi finalmente WIRADO no CI — porem como STEP ADVISORY (continue-on-error) no job quality-gate, NAO bloqueante. Motivo: (a) o proprio plano sequencia o aperto para 'o fim do ciclo', e a v3.8.26 acabou de abrir; (b) liga-lo bloqueante durante merges ativos travaria qualquer melhoria de metrica ate re-baseline. Para promover a BLOQUEANTE no fim do ciclo: remover o 'continue-on-error: true' do step 'Require-tighten' no ci.yml. tightenSlack nas metricas coverage.* impede falso-disparo pelo gap anti-flake.", "_require_tighten_flip_blocking_2026_06_16_v3827": "Fim do ciclo v3.8.27: o step 'Require-tighten' do job quality-gate (ci.yml) foi PROMOVIDO de ADVISORY para BLOQUEANTE (removido o 'continue-on-error: true', renomeado para 'Require-tighten (blocking)'). Verificado LIMPO (exit 0, '24 metricas, 0 melhoraram') no tip de release/v3.8.27 ANTES do flip: as metricas deterministicas medem == baseline (eslintWarnings 3769, eslintErrors 0) e as coverage.* foram avaliadas contra a cobertura mergeada == baseline (replicando o estado do CI, onde o job baixa o artifact coverage-report antes do collect). tightenSlack nas coverage.* (5 global / 10 por-modulo) absorve a variancia local-vs-CI e impede falso-disparo. Nenhuma metrica melhorou-sem-apertar, logo o flip nao reda o CI verde atual.", "_osv_flip_blocking_2026_06_16_v3827": "Fim do ciclo v3.8.27: vulnCount (value 10, direction down, dedicatedGate) PROMOVIDO de ADVISORY para RATCHET BLOQUEANTE. check-vuln-ratchet.mjs ganhou um modo --ratchet (espelhando check-secrets/check-bundle-size/check-workflows da Etapa 2): le metrics.vulnCount.value daqui, compara a contagem MEDIDA pelo osv-scanner e sai 1 SOMENTE numa regressao real (medida > baseline). Sem --ratchet permanece advisory (exit 0). QUALQUER SKIP gracioso (osv-scanner ausente do PATH, osv.dev/rede inacessivel, timeout, JSON invalido) sai 0 MESMO com --ratchet — uma falha de MEDICAO nunca bloqueia, so uma regressao MEDIDA bloqueia. No ci.yml (job quality-extended) o step 'Vulnerability ratchet' passou a rodar 'npm run check:vuln-ratchet -- --ratchet' sem continue-on-error. Verificado no tip de release/v3.8.27: osv mede vulnCount=10 == baseline 10 (exit 0); forcar baseline=9 da exit 1 (10>9); PATH vazio da SKIP binary-absent exit 0. NB de VARIANCIA DE CVE: um CVE recem-divulgado numa dep ja presente pode redar o gate sem mudanca de dependencia — comportamento esperado, remedio = bumpar a dep ou re-baseline vulnCount com justificativa+issue. Ver docs/security/SUPPLY_CHAIN.md -> 'Variancia de CVE'.", - "_trivy_flip_blocking_2026_06_16_v3827": "Fim do ciclo v3.8.27: Trivy (scan de CVE da imagem Docker em docker-publish.yml) promovido para BLOQUEANTE em CRITICAL. Abordagem de DOIS PASSOS: o passo SARIF existente (severity HIGH,CRITICAL / exit-code 0 / upload SARIF) fica INTACTO para visibilidade na aba Security; um novo passo 'Trivy CRITICAL gate (blocking)' (severity CRITICAL / ignore-unfixed:true / exit-code 1) falha o release num CVE CRITICO FIXAVEL. ignore-unfixed evita travar por CVE de base-image sem patch upstream (reduz falso-bloqueio). Mesma variancia-de-CVE do osv: um novo CRITICAL fixavel divulgado pode redar; remedio = rebuild sobre base patcheada, bumpar dep, ou .trivyignore com justificativa+issue. Ver docs/security/SUPPLY_CHAIN.md. vulnCount permanece 10 (intocado neste flip — so a postura advisory->bloqueante mudou)." + "_trivy_flip_blocking_2026_06_16_v3827": "Fim do ciclo v3.8.27: Trivy (scan de CVE da imagem Docker em docker-publish.yml) promovido para BLOQUEANTE em CRITICAL. Abordagem de DOIS PASSOS: o passo SARIF existente (severity HIGH,CRITICAL / exit-code 0 / upload SARIF) fica INTACTO para visibilidade na aba Security; um novo passo 'Trivy CRITICAL gate (blocking)' (severity CRITICAL / ignore-unfixed:true / exit-code 1) falha o release num CVE CRITICO FIXAVEL. ignore-unfixed evita travar por CVE de base-image sem patch upstream (reduz falso-bloqueio). Mesma variancia-de-CVE do osv: um novo CRITICAL fixavel divulgado pode redar; remedio = rebuild sobre base patcheada, bumpar dep, ou .trivyignore com justificativa+issue. Ver docs/security/SUPPLY_CHAIN.md. vulnCount permanece 10 (intocado neste flip — so a postura advisory->bloqueante mudou).", + "_rebaseline_2026_06_18_v3828_cycle_close": "Fim do ciclo v3.8.28 (RELEASED; ciclo v3.8.29 aberto): 3 metricas re-baselineadas para o valor REAL medido no push->main pos-release (run 27725117464, step 'Ratchet check') — eslintWarnings 3769->3779, openapiCoverage.pct 38.3->37.6, i18nUiCoverage.pct 80.1->79.1. Reproduzido localmente em release/v3.8.29 (9f14c1294): identico ao CI. Drift de fim-de-ciclo de features legitimas, NAO hand-cleanable: (a) eslint +10 = 'any' PERMITIDO (warn) em testes do ciclo + 4 react-hooks/exhaustive-deps em RequestLoggerV2.tsx (componente com bugs sutis de refresh #4103/#3972, arriscado mexer em deps de hook sem teste de UI); (b) openapi -0.7 = drop por rotas NOVAS INTERNAS (/api/tools/agent-bridge/* LOCAL_ONLY, spawnam MITM/DNS) — documenta-las no spec PUBLICO seria gaming; (c) i18n -1.0 = 37/41 locales em 79.1% (1741 chaves faltando cada, ~3000 traducoes via 'npm run i18n:run' que exige creds OMNIROUTE_TRANSLATION_API_KEY indisponiveis localmente). Mesmo precedente do _eslint_rebaseline_2026_06_16_v3826_forward_merge. Apertar no fim do ciclo: eslint/openapi via --require-tighten; i18n via i18n:run com creds. Autorizado pelo operador (decisao explicita).", + "_rebaseline_2026_06_19_v3829_cycle_close": "Release do ciclo v3.8.29: eslintWarnings re-baselineado 3779->3816 para o valor REAL medido em release/v3.8.29 (tip da3...; `npm run lint` local = 3816, identico ao Quality Ratchet do CI no PR #4126). O +37 e drift de fim-de-ciclo de 115 commits de features legitimas — `any` PERMITIDO (warn) em open-sse/ e tests/ do ciclo; os arquivos de reconciliacao deste release nao adicionam warnings (scripts/check/*.mjs sao eslint-ignored, o teste novo de check-fabricated-docs nao usa any). Mesmo precedente de _rebaseline_2026_06_18_v3828_cycle_close. Apertar via --require-tighten no fim do ciclo seguinte. ALÉM disso, o step Require-tighten (blocking) exigiu apertar 2 métricas que MELHORARAM no ciclo (medidas no CI do PR #4126): coverage.auth.lines 69->90 (CI mediu 92.6; piso ~2pt-abaixo-do-real anti-flake, dentro do tightenSlack 10) e openapiCoverage.pct 37.6->38.4 (rotas novas documentadas). Melhorias legitimas travadas no baseline, nao gaming. Autorizado pelo operador (release end-to-end, validado na VPS)." } diff --git a/config/quality/test-masking-allowlist.json b/config/quality/test-masking-allowlist.json index 84b5ab24ef..d772f350a6 100644 --- a/config/quality/test-masking-allowlist.json +++ b/config/quality/test-masking-allowlist.json @@ -1,5 +1,10 @@ { "_comment": "Anti test-masking allowlist (check-test-masking.mjs). Files here are EXEMPT ONLY from the net-assert-REDUCTION signal, when the reduction is a verified-legitimate refactor or field removal (NOT weakening to go green). New tautologies (assert.ok(true)), new .skip/.todo/.only, and test-file deletions are STILL flagged for these files. Every entry needs a reason with the PR ref. Prune an entry once the release that introduced the reduction has merged to main (the merge-base then already reflects the reduced state, so the signal no longer fires).", "tests/unit/appearance-widget-settings-schema.test.ts": "v3.8.27 #4033: the `showTokenSaverOnEndpoint` schema field was removed (renamed/consolidated into the settings surface); its 2 asserts were removed accordingly — the field no longer exists in source. Verified legitimate, not masking.", - "tests/unit/dashboard-shell-tabs.test.ts": "v3.8.27 #3973: settings UI refactored from a tabbed client component to redirect-based routing; 5 old-structure asserts (tabpanel/aria/initialTab) were replaced by 4 new-structure asserts (redirect/resolveSettingsRoute), net -1. Asserts replaced, not weakened. Verified legitimate." + "tests/unit/dashboard-shell-tabs.test.ts": "v3.8.27 #3973: settings UI refactored from a tabbed client component to redirect-based routing; 5 old-structure asserts (tabpanel/aria/initialTab) were replaced by 4 new-structure asserts (redirect/resolveSettingsRoute), net -1. Asserts replaced, not weakened. Verified legitimate.", + "tests/integration/combo-routing-e2e.test.ts": "v3.8.29 #4129: integration tests aligned to post-v3.8.28 routing behavior — 3 separate per-call status asserts collapsed into a single loop assertion and seenProviders expectations updated to the new routing order (42→39). Asserts restructured/updated, not weakened. Verified legitimate. Prune after v3.8.29 merges to main.", + "tests/unit/compression/ccr-marker-retrieve.test.ts": "v3.8.29 #4226: the vestigial reconstructCcr round-trip helper was removed from source; its 3 asserts were removed accordingly (36→33). Helper no longer exists. Verified legitimate, not masking. Prune after v3.8.29 merges to main.", + "tests/unit/compression/session-dedup.test.ts": "v3.8.29 #4226: the vestigial SessionDedup round-trip helper was removed from source; its 2 asserts were removed accordingly (32→30). Helper no longer exists. Verified legitimate, not masking. Prune after v3.8.29 merges to main.", + "tests/unit/compression/ultra.test.ts": "v3.8.29 #4253: the vestigial SLM seam + dead deprecated alias were removed from the ultra compression engine; 6 asserts covering the removed seam were removed accordingly (49→43). Verified legitimate, not masking. Prune after v3.8.29 merges to main.", + "tests/unit/db-backup-extended.test.ts": "v3.8.29 #4132: db-backup de-flake — 1 timing-sensitive assertion on fire-and-forget backup completion was removed in favor of awaiting actual completion (44→43). Verified legitimate, not masking. Prune after v3.8.29 merges to main." } diff --git a/design.md b/design.md new file mode 100644 index 0000000000..64794e44a1 --- /dev/null +++ b/design.md @@ -0,0 +1,221 @@ +# OmniRoute — Design System & Visual Identity + +> **Status:** standardization plan. **Phases 1–3 are implemented in this PR** (grid, primitives, status-color centralization, mono token, and the DataTable token migration). The DataTable migration is **faithful** — dark stays byte-identical (the new `--table-*` dark values equal the old hardcoded rgba); light is fixed (it was buggy always-dark via dead `var()` fallbacks). **⚠️ Wants a visual pass before merge** (light-theme tables + the secondary-text shift `#888`→`--color-text-muted`). **Phase 4 is now largely done too** (C6 focus-ring → accent, C7 Checkbox/Textarea primitives, C9 `cn()`→tailwind-merge); only the selective C8 hex-sweep remains. Note several remaining "hardcoded" hex are _intentional_ (always-dark console terminal, ReactFlow SVG strokes) and must NOT be swept. **Phase 5 (D4 + D8): the grid now reaches every standalone screen** (login/auth/error/legal/status/onboarding — their opaque `bg-bg` full-screen wrappers were hiding it) **and the dashboard content shell is fluid up to 4K** (`max-w-7xl` → `max-w-[3840px]`) so it follows the viewport on large monitors instead of centering with wide side gutters. **Phase 6 (D9): data tables are now opaque surfaces** so the grid no longer bleeds through their rows — card-less tables paint `bg-surface`, and the two log tables' semi-transparent `bg-black/5` tint (which tailwind-merge let win over the Card's `bg-surface`) is removed. The grid size itself is already correct (32px, identical to the site); a "bigger" grid on a running instance is a stale build, not code. +> **Date:** 2026-06-16 · **Scope:** unify the OmniRoute dashboard (`src/`) with the marketing site (`_mono_repo/omnirouteSite/`) into **one visual identity** — same graph-paper grid background, same color tokens, standardized components. + +--- + +## 1. Purpose + +The marketing site (`viral.omniroute.online`, `why.omniroute.online`, `omniroute.online`) and the product dashboard should look like **one product**. The site already borrowed its palette from the dashboard — its `css/tokens.css` even says _"Palette mirrors the OmniRoute dashboard (src/app/globals.css)"_. So the two are already ~80% aligned at the color level. What's missing on the dashboard: + +1. The **graph-paper grid wallpaper** the site uses on every page. +2. A handful of **shared design tokens** the site has but the dashboard lacks (radius scale, brand gradient, `surface-2`, mono font). +3. **Component-level consistency** — a number of dashboard components bypass the theme tokens with hardcoded hex/rgba. + +This document is the analysis and the plan. + +--- + +## 2. Principles + +- **Single source of truth = `src/app/globals.css`.** The site mirrors the dashboard, never the other way around. New tokens land in `globals.css` first. +- **Tokens, never literals.** Components consume semantic tokens (`bg-surface`, `text-primary`, `border-border`), never raw `#hex`. +- **Subtle, not loud.** The grid is a faint wallpaper that sits behind content — it must never reduce text contrast or fight the UI. +- **Theme-aware.** Everything works in both `.dark` (the product's signature look) and light. +- **Surgical rollout.** Ship the grid + tokens first (low risk, high visibility), then component cleanups in waves. + +--- + +## 3. Current state — what's already aligned vs. what's not + +### 3.1 Colors — already unified ✅ + +Every brand color and surface already matches the site **by value** (only the names differ — dashboard prefixes with `--color-`). Verified in `src/app/globals.css:30-128`: + +| Concept | Site token (`tokens.css`) | Dashboard token (`globals.css`) | Match | +| -------------------------- | ------------------------------------------- | ------------------------------- | ------------ | +| primary | `--primary #e54d5e` | `--color-primary #e54d5e` | ✅ | +| primary-hover | `--primary-hover #c93d4e` | `--color-primary-hover #c93d4e` | ✅ | +| accent | `--accent #6366f1` | `--color-accent #6366f1` | ✅ | +| accent-2 | `--accent-2 #8b5cf6` | `--color-accent-hover #8b5cf6` | ✅ (renamed) | +| accent-3 | `--accent-3 #a855f7` | `--color-accent-light #a855f7` | ✅ (renamed) | +| success / warning / error | `#22c55e / #f59e0b / #ef4444` | identical | ✅ | +| traffic lights | `#ff5f56 / #ffbd2e / #27c93f` | identical | ✅ | +| dark bg / surface / border | `#0b0e14 / #161b22 / rgba(255,255,255,.08)` | identical | ✅ | +| light bg / surface / text | `#f9f9fb / #fff / #1a1a2e` | identical | ✅ | + +**Conclusion:** there is no color migration to do. The identity is already shared; we are _finishing_ it, not rebuilding it. + +### 3.2 Gaps — what the dashboard is missing + +| Gap | Site has | Dashboard | Action | +| ----------------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------- | ---------------------- | +| **Grid wallpaper** | `body::before` graph-paper, `--grid-line`, `--grid-size 32px`, `--section-alt` | **✅ added (Phase 1)** | **Part A** | +| **Radius scale** | `--radius 14px`, `--radius-sm 9px` | `--radius 14px` added; `-sm` + component repoint pending | **Part B / Phase 2** | +| **Brand gradient** | `--grad-brand 135deg primary→accent-3` | **✅ token added (Phase 1)**; consumed in Phase 2 | **Part B** | +| **Nested surface** | `--surface-2 #1c2230` | **✅ added (Phase 1)** | **Part B** | +| **Mono font** | `--font-mono` (ui-monospace stack) | pending (Phase 4, with consumers) | **Part B** | +| **`text-muted` (dark)** | `#8b8b9e` | `#a1a1aa` (zinc-400) | reconcile — **Part B** | + +### 3.3 Theming mechanics (so we don't break anything) + +- **Tailwind v4, CSS-first** (no `tailwind.config.*`). Tokens are defined in `:root`/`.dark` and exposed to utilities via `@theme inline` (`globals.css:130-179`). +- **Dark via `.dark` class** on `` (`@custom-variant dark` at `globals.css:22`), toggled by a custom Zustand store (`src/store/themeStore.ts`), default theme = `system` (`src/shared/constants/appConfig.ts:11`). The site uses `html[data-theme="light"]` instead — **the mechanisms differ but never meet** (separate origins), so no conflict. We keep the dashboard's `.dark` mechanism. +- **Runtime primary override** exists (`themeStore.ts:85-97`, presets in `COLOR_THEMES`) — users can swap `--color-primary`. Any new token (gradient, etc.) that references `--color-primary` inherits those overrides for free. ✅ +- **Tailwind v4 reserved radius names:** `--radius-sm/md/lg/...` back the `rounded-*` utilities. Redefining them retroactively changes every existing `rounded-*` (e.g. `rounded-sm` is used in 12 files). So the small-radius value and component repoint are deliberately deferred to Phase 2, where consumers change together. + +--- + +## 4. Part A — The graph-paper grid background (headline ask) — IMPLEMENTED (Phase 1) + +### 4.1 What it is + +The exact recipe from the site (`_mono_repo/omnirouteSite/css/base.css`): a **fixed, full-viewport pseudo-element** painting two 1px line gradients, sitting at `z-index:-1` behind all content. + +```css +body::before { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + background-image: + linear-gradient(to right, var(--grid-line) 1px, transparent 1px), + linear-gradient(to bottom, var(--grid-line) 1px, transparent 1px); + background-size: var(--grid-size) var(--grid-size); +} +``` + +**Why this works even though `body` has an opaque `background-color`:** a `::before` with `z-index:-1` paints _above_ the element's own background but _below_ its in-flow content. So `--color-bg` is the base fill, the grid is layered on top of it, and the app renders above the grid. + +### 4.2 Precedent already in the codebase + +`src/app/landing/page.tsx:16-26` **already implements this same grid per-page** — but with **red** lines (`#E54D5E`, opacity `0.06`) at **50px**, plus animated orbs. So the pattern is proven in the product; this work promotes it to a **global, theme-aware** wallpaper. + +### 4.3 Tokens added (in `globals.css`) + +```css +:root { + /* light — grid opacity tuned up from the site's 0.045 so the wallpaper is + actually visible on the dense dashboard (cards/chrome cover most of the viewport) */ + --grid-line: rgba(0, 0, 0, 0.07); + --grid-size: 32px; + --section-alt: rgba(0, 0, 0, 0.022); +} +.dark { + /* dark — tuned up from 0.035 for the same reason */ + --grid-line: rgba(255, 255, 255, 0.06); + --section-alt: rgba(255, 255, 255, 0.018); +} +``` + +### 4.4 The single blocker — removed + +The grid is global by construction (it covers the panel, `auth`/`login`, error pages — every route — at once). Exactly **one** element hid it inside the panel: + +- `src/shared/components/layouts/DashboardLayout.tsx` — the outer wrapper painted an opaque `bg-bg`. Everything below it is already transparent (`
`, the scroll container, the `max-w-7xl` inner), so **removing `bg-bg`** lets the body grid show through the content area (the body's `--color-bg` remains the base fill). + + ```diff + -
+ +
+ ``` + +### 4.5 Chrome interaction (sidebar / header) + +- `Header` (`Header.tsx:207`, `bg-bg`) and `Sidebar` (`Sidebar.tsx:430`, `bg-sidebar`) stay **opaque** → the grid shows in the **content area only**, with solid chrome framing it. Calm default, matches how the site separates chrome from canvas (decision D3 = solid). + +### 4.6 Login / auth / error pages + +These render directly under `` (no panel chrome), so the global grid should appear behind them automatically. **Phase 5 — DONE:** the standalone full-screen wrappers were in fact opaque (`min-h-screen … bg-bg`, where `bg-bg` is the same solid fill as ``), which hid the grid on every non-dashboard screen — not just login. All of them are now transparent so the shared wallpaper shows through: `login`, `forgot-password`, `callback`, `maintenance`, `offline`, `status`, `terms`, `privacy`, `onboarding`, and `ErrorPageScaffold` (covers `400`/`401`). This closes **D4** (extended from login-only to every standalone screen). Guarded by `tests/unit/design-grid-background.test.ts`. + +### 4.7 Landing page + +`landing/page.tsx` keeps its richer animated background (orbs + vignette) — its own marketing splash (decision D5 = leave as-is). + +--- + +## 5. Part B — Token unification + +Phase 1 adds the inert, collision-free identity tokens (`--surface-2`/`--color-surface-2`, `--grad-brand`, `--radius`). Phase 2 wires the radius scale into Tailwind and repoints components; Phase 4 adds `--font-mono` with its consumers. + +| Token | Why | Phase | +| -------------------------- | --------------------------------------------------------------- | ------------------------------ | +| `--radius` / `--radius-sm` | One radius scale (14/9) instead of 6/8/12 ad-hoc | 1 (value) / 2 (wire + repoint) | +| `--grad-brand` | Brand gradient for primary CTAs (red→violet), matching the site | 1 (token) / 2 (Button) | +| `--surface-2` | Nested panels / table headers / inset rows | 1 | +| `--font-mono` | Code blocks, terminal, IDs, endpoints | 4 | +| `--text-muted` reconcile | Pick one value site↔panel (`#a1a1aa` recommended) | 2 | + +**D2 (text-muted):** site `#8b8b9e` vs dashboard `#a1a1aa`. Recommend keeping the **dashboard's `#a1a1aa`** and updating the _site_ to match. Cosmetic. + +--- + +## 6. Part C — Component standardization (Phases 2–4) + +Custom components (no shadcn/Radix), Tailwind v4, semantic tokens **mostly** adopted (195 files import the shared barrel). The work is removing the **bypasses**. Home: `src/shared/components/`. + +| # | Item | File(s) | Problem → Target | Phase | +| --- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | ----- | +| C1 | **Radius alignment** | `Button.tsx:14-18`, `Card.tsx:39`, `Modal.tsx`, `Input.tsx`, `Select.tsx` | mixed 6/8/12px → `--radius`/`--radius-sm` (14/9) | 2 | +| C2 | **Button gradient + `accent` variant** | `Button.tsx:5-12` | primary is flat red→red; align to `--grad-brand`; add missing `accent` variant. ~195 importers — highest visibility | 2 | +| C3 | **Tables** | `DataTable.tsx:122-176`, `logTableStyles.ts`, `globals.css:405-414` | 100% inline hardcoded rgba + non-existent vars; migrate to tokens, retire divergent styles | 3 | +| C4 | **Centralize status colors** | `flow/edgeStyles.ts`, `TokenHealthBadge.tsx`, `DegradationBadge.tsx`, `ProviderCascadeNode.tsx`, `Badge.tsx` + 5 helpers | 6+ copies of the same hex → one module off `--color-success/warning/error` | 3 | +| C5 | **Card border** | `Card.tsx:39` | `border-white/5` → brand `/8` | 2 | +| C6 | **Focus ring reconcile** ✅ DONE | `globals.css` `--focus-ring` (accent) vs form controls' `ring-primary/30` | unified on **accent (violet)** to match the global ring + disambiguate from the red error ring; error stays red | 4 | +| C7 | **Add `Checkbox` + `Textarea`** | raw ``/`